From 87e31ba77d88709d32a9e04b72f301e9ea9ee157 Mon Sep 17 00:00:00 2001 From: Ashish Keshan Date: Fri, 28 Feb 2025 14:27:03 -0500 Subject: [PATCH 1/3] Fix KeyboardEvent 400 error (#54624) --- src/events/lib/schema.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/events/lib/schema.ts b/src/events/lib/schema.ts index 98cbb97d3dff..d169abaf85f7 100644 --- a/src/events/lib/schema.ts +++ b/src/events/lib/schema.ts @@ -267,6 +267,11 @@ const keyboard = { additionalProperties: false, required: ['pressed_key', 'pressed_on'], properties: { + context, + type: { + type: 'string', + pattern: '^keyboard$', + }, pressed_key: { type: 'string', description: 'The key the user pressed.', From 78330732f93f8e22b572618021fbd87c14f4d422 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Fri, 28 Feb 2025 11:40:11 -0800 Subject: [PATCH 2/3] updates to convey full-text search behaviour in new projects experience (#54545) Co-authored-by: Jed Verity --- .../filtering-projects.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/content/issues/planning-and-tracking-with-projects/customizing-views-in-your-project/filtering-projects.md b/content/issues/planning-and-tracking-with-projects/customizing-views-in-your-project/filtering-projects.md index f35e784ea947..2e44162ce85b 100644 --- a/content/issues/planning-and-tracking-with-projects/customizing-views-in-your-project/filtering-projects.md +++ b/content/issues/planning-and-tracking-with-projects/customizing-views-in-your-project/filtering-projects.md @@ -205,6 +205,14 @@ You can filter by specific text fields or use a general text filter across all t | TEXT | **API** will show items with "API" in the title or any other text field. | field:TEXT TEXT | **label:bug rendering** will show items with the "bug" label and with "rendering" in the title or any other text field. +For general text search across all text fields and titles, matches are based only on the beginning of a word, not any part of it. +For example, if the issue title is **"Document full-text search"**: + +* **Matches**: "Doc", "full", "search" +* **Doesn't match**: "cument", "ext", "arch" + +This approach helps keep general text search more precise and relevant. + {% ifversion projects-v2-wildcard-text-filtering %} You can also use a * as a wildcard. From 1564ce8965876097f94ab7892169f8f5c36feab2 Mon Sep 17 00:00:00 2001 From: Evan Bonsignori Date: Fri, 28 Feb 2025 11:52:12 -0800 Subject: [PATCH 3/3] don't rate limit public Fastly IPs (#54625) --- src/shielding/lib/fastly-ips.ts | 81 ++++++++++++++++++++++++++ src/shielding/middleware/rate-limit.ts | 6 +- src/shielding/tests/shielding.ts | 46 +++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 src/shielding/lib/fastly-ips.ts diff --git a/src/shielding/lib/fastly-ips.ts b/src/shielding/lib/fastly-ips.ts new file mode 100644 index 000000000000..f010031ec62d --- /dev/null +++ b/src/shielding/lib/fastly-ips.ts @@ -0,0 +1,81 @@ +// Logic to get and store the current list of public Fastly IPs from the Fastly API: https://www.fastly.com/documentation/reference/api/utils/public-ip-list/ + +// Default returned from ➜ curl "https://api.fastly.com/public-ip-list" +export const DEFAULT_FASTLY_IPS: string[] = [ + '23.235.32.0/20', + '43.249.72.0/22', + '103.244.50.0/24', + '103.245.222.0/23', + '103.245.224.0/24', + '104.156.80.0/20', + '140.248.64.0/18', + '140.248.128.0/17', + '146.75.0.0/17', + '151.101.0.0/16', + '157.52.64.0/18', + '167.82.0.0/17', + '167.82.128.0/20', + '167.82.160.0/20', + '167.82.224.0/20', + '172.111.64.0/18', + '185.31.16.0/22', + '199.27.72.0/21', + '199.232.0.0/16', +] + +let ipCache: string[] = [] + +export async function getPublicFastlyIPs(): Promise { + // Don't fetch the list in dev & testing, just use the defaults + if (process.env.NODE_ENV !== 'production') { + ipCache = DEFAULT_FASTLY_IPS + } + + if (ipCache.length) { + return ipCache + } + + const endpoint = 'https://api.fastly.com/public-ip-list' + let ips: string[] = [] + let attempt = 0 + + while (attempt < 3) { + try { + const response = await fetch(endpoint) + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status}`) + } + const data = await response.json() + if (data && Array.isArray(data.addresses)) { + ips = data.addresses + break + } else { + throw new Error('Invalid response structure') + } + } catch (error: any) { + console.error( + `Failed to fetch Fastly IPs: ${error.message}. Retrying ${3 - attempt} more times`, + ) + attempt++ + if (attempt >= 3) { + ips = DEFAULT_FASTLY_IPS + } + } + } + + ipCache = ips + return ips +} + +// The IPs we check in the rate-limiter are in the form `X.X.X.X` +// But the IPs returned from the Fastly API are in the form `X.X.X.X/Y` +// For an IP in the rate-limiter, we want `X.X.X.*` to match `X.X.X.X/Y` +export async function isFastlyIP(ip: string): Promise { + // If IPs aren't initialized, fetch them + if (!ipCache.length) { + await getPublicFastlyIPs() + } + const parts = ip.split('.') + const prefix = parts.slice(0, 3).join('.') + return ipCache.some((fastlyIP) => fastlyIP.startsWith(prefix)) +} diff --git a/src/shielding/middleware/rate-limit.ts b/src/shielding/middleware/rate-limit.ts index ae98144aa282..8de54a228cf7 100644 --- a/src/shielding/middleware/rate-limit.ts +++ b/src/shielding/middleware/rate-limit.ts @@ -4,6 +4,7 @@ import rateLimit from 'express-rate-limit' import statsd from '@/observability/lib/statsd.js' import { noCacheControl } from '@/frame/middleware/cache-control.js' +import { isFastlyIP } from '@/shielding/lib/fastly-ips' const EXPIRES_IN_AS_SECONDS = 60 @@ -35,8 +36,11 @@ export function createRateLimiter(max = MAX, isAPILimiter = false) { return getClientIPFromReq(req) }, - skip: (req) => { + skip: async (req) => { const ip = getClientIPFromReq(req) + if (await isFastlyIP(ip)) { + return true + } // IP is empty when we are in a non-production (not behind Fastly) environment // In these environments, we don't want to rate limit (including tests) // However, if you want to test rate limiting locally, you can manually set diff --git a/src/shielding/tests/shielding.ts b/src/shielding/tests/shielding.ts index 7d72943bf415..817758af4ced 100644 --- a/src/shielding/tests/shielding.ts +++ b/src/shielding/tests/shielding.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest' import { SURROGATE_ENUMS } from '@/frame/middleware/set-fastly-surrogate-key.js' import { get } from '@/tests/helpers/e2etest.js' +import { DEFAULT_FASTLY_IPS } from '@/shielding/lib/fastly-ips' describe('honeypotting', () => { test('any GET with survey-vote and survey-token query strings is 400', async () => { @@ -136,6 +137,51 @@ describe('rate limiting', () => { expect(res.headers['ratelimit-limit']).toBeUndefined() expect(res.headers['ratelimit-remaining']).toBeUndefined() }) + + test('/api/cookies only allows 1 request per minute', async () => { + // Cookies only allows 1 request per minute + const res1 = await get('/api/cookies', { + headers: { + 'fastly-client-ip': 'abc123', + }, + }) + expect(res1.statusCode).toBe(200) + expect(res1.headers['ratelimit-limit']).toBe('1') + expect(res1.headers['ratelimit-remaining']).toBe('0') + + // A second request should be rate limited + const res2 = await get('/api/cookies', { + headers: { + 'fastly-client-ip': 'abc123', + }, + }) + expect(res2.statusCode).toBe(429) + expect(res2.headers['ratelimit-limit']).toBe('1') + expect(res2.headers['ratelimit-remaining']).toBe('0') + }) + + test('Fastly IPs are not rate limited', async () => { + // Fastly IPs are in the form `X.X.X.X/Y` + // Rate limited IPs are in the form `X.X.X.X` + // Where the last X could be any 2-3 digit number + const mockFastlyIP = + DEFAULT_FASTLY_IPS[0].split('.').slice(0, 3).join('.') + `.${Math.floor(Math.random() * 100)}` + // Cookies only allows 1 request per minute + const res1 = await get('/api/cookies', { + headers: { + 'fastly-client-ip': mockFastlyIP, + }, + }) + expect(res1.statusCode).toBe(200) + + // A second request shouldn't be rate limited because it's from a Fastly IP + const res2 = await get('/api/cookies', { + headers: { + 'fastly-client-ip': mockFastlyIP, + }, + }) + expect(res2.statusCode).toBe(200) + }) }) describe('404 pages and their content-type', () => {