Skip to content

Commit 82c08e5

Browse files
authored
Merge pull request github#36572 from github/repo-sync
Repo sync
2 parents aea537c + 1564ce8 commit 82c08e5

File tree

5 files changed

+145
-1
lines changed

5 files changed

+145
-1
lines changed

content/issues/planning-and-tracking-with-projects/customizing-views-in-your-project/filtering-projects.md

+8
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,14 @@ You can filter by specific text fields or use a general text filter across all t
205205
| <code><em>TEXT</em></code> | **API** will show items with "API" in the title or any other text field.
206206
| <code>field:<em>TEXT</em> TEXT | **label:bug rendering** will show items with the "bug" label and with "rendering" in the title or any other text field.
207207

208+
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.
209+
For example, if the issue title is **"Document full-text search"**:
210+
211+
* **Matches**: "Doc", "full", "search"
212+
* **Doesn't match**: "cument", "ext", "arch"
213+
214+
This approach helps keep general text search more precise and relevant.
215+
208216
{% ifversion projects-v2-wildcard-text-filtering %}
209217

210218
You can also use a <code>&ast;</code> as a wildcard.

src/events/lib/schema.ts

+5
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,11 @@ const keyboard = {
267267
additionalProperties: false,
268268
required: ['pressed_key', 'pressed_on'],
269269
properties: {
270+
context,
271+
type: {
272+
type: 'string',
273+
pattern: '^keyboard$',
274+
},
270275
pressed_key: {
271276
type: 'string',
272277
description: 'The key the user pressed.',

src/shielding/lib/fastly-ips.ts

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// 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/
2+
3+
// Default returned from ➜ curl "https://api.fastly.com/public-ip-list"
4+
export const DEFAULT_FASTLY_IPS: string[] = [
5+
'23.235.32.0/20',
6+
'43.249.72.0/22',
7+
'103.244.50.0/24',
8+
'103.245.222.0/23',
9+
'103.245.224.0/24',
10+
'104.156.80.0/20',
11+
'140.248.64.0/18',
12+
'140.248.128.0/17',
13+
'146.75.0.0/17',
14+
'151.101.0.0/16',
15+
'157.52.64.0/18',
16+
'167.82.0.0/17',
17+
'167.82.128.0/20',
18+
'167.82.160.0/20',
19+
'167.82.224.0/20',
20+
'172.111.64.0/18',
21+
'185.31.16.0/22',
22+
'199.27.72.0/21',
23+
'199.232.0.0/16',
24+
]
25+
26+
let ipCache: string[] = []
27+
28+
export async function getPublicFastlyIPs(): Promise<string[]> {
29+
// Don't fetch the list in dev & testing, just use the defaults
30+
if (process.env.NODE_ENV !== 'production') {
31+
ipCache = DEFAULT_FASTLY_IPS
32+
}
33+
34+
if (ipCache.length) {
35+
return ipCache
36+
}
37+
38+
const endpoint = 'https://api.fastly.com/public-ip-list'
39+
let ips: string[] = []
40+
let attempt = 0
41+
42+
while (attempt < 3) {
43+
try {
44+
const response = await fetch(endpoint)
45+
if (!response.ok) {
46+
throw new Error(`Failed to fetch: ${response.status}`)
47+
}
48+
const data = await response.json()
49+
if (data && Array.isArray(data.addresses)) {
50+
ips = data.addresses
51+
break
52+
} else {
53+
throw new Error('Invalid response structure')
54+
}
55+
} catch (error: any) {
56+
console.error(
57+
`Failed to fetch Fastly IPs: ${error.message}. Retrying ${3 - attempt} more times`,
58+
)
59+
attempt++
60+
if (attempt >= 3) {
61+
ips = DEFAULT_FASTLY_IPS
62+
}
63+
}
64+
}
65+
66+
ipCache = ips
67+
return ips
68+
}
69+
70+
// The IPs we check in the rate-limiter are in the form `X.X.X.X`
71+
// But the IPs returned from the Fastly API are in the form `X.X.X.X/Y`
72+
// For an IP in the rate-limiter, we want `X.X.X.*` to match `X.X.X.X/Y`
73+
export async function isFastlyIP(ip: string): Promise<boolean> {
74+
// If IPs aren't initialized, fetch them
75+
if (!ipCache.length) {
76+
await getPublicFastlyIPs()
77+
}
78+
const parts = ip.split('.')
79+
const prefix = parts.slice(0, 3).join('.')
80+
return ipCache.some((fastlyIP) => fastlyIP.startsWith(prefix))
81+
}

src/shielding/middleware/rate-limit.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import rateLimit from 'express-rate-limit'
44

55
import statsd from '@/observability/lib/statsd.js'
66
import { noCacheControl } from '@/frame/middleware/cache-control.js'
7+
import { isFastlyIP } from '@/shielding/lib/fastly-ips'
78

89
const EXPIRES_IN_AS_SECONDS = 60
910

@@ -35,8 +36,11 @@ export function createRateLimiter(max = MAX, isAPILimiter = false) {
3536
return getClientIPFromReq(req)
3637
},
3738

38-
skip: (req) => {
39+
skip: async (req) => {
3940
const ip = getClientIPFromReq(req)
41+
if (await isFastlyIP(ip)) {
42+
return true
43+
}
4044
// IP is empty when we are in a non-production (not behind Fastly) environment
4145
// In these environments, we don't want to rate limit (including tests)
4246
// However, if you want to test rate limiting locally, you can manually set

src/shielding/tests/shielding.ts

+46
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'
22

33
import { SURROGATE_ENUMS } from '@/frame/middleware/set-fastly-surrogate-key.js'
44
import { get } from '@/tests/helpers/e2etest.js'
5+
import { DEFAULT_FASTLY_IPS } from '@/shielding/lib/fastly-ips'
56

67
describe('honeypotting', () => {
78
test('any GET with survey-vote and survey-token query strings is 400', async () => {
@@ -136,6 +137,51 @@ describe('rate limiting', () => {
136137
expect(res.headers['ratelimit-limit']).toBeUndefined()
137138
expect(res.headers['ratelimit-remaining']).toBeUndefined()
138139
})
140+
141+
test('/api/cookies only allows 1 request per minute', async () => {
142+
// Cookies only allows 1 request per minute
143+
const res1 = await get('/api/cookies', {
144+
headers: {
145+
'fastly-client-ip': 'abc123',
146+
},
147+
})
148+
expect(res1.statusCode).toBe(200)
149+
expect(res1.headers['ratelimit-limit']).toBe('1')
150+
expect(res1.headers['ratelimit-remaining']).toBe('0')
151+
152+
// A second request should be rate limited
153+
const res2 = await get('/api/cookies', {
154+
headers: {
155+
'fastly-client-ip': 'abc123',
156+
},
157+
})
158+
expect(res2.statusCode).toBe(429)
159+
expect(res2.headers['ratelimit-limit']).toBe('1')
160+
expect(res2.headers['ratelimit-remaining']).toBe('0')
161+
})
162+
163+
test('Fastly IPs are not rate limited', async () => {
164+
// Fastly IPs are in the form `X.X.X.X/Y`
165+
// Rate limited IPs are in the form `X.X.X.X`
166+
// Where the last X could be any 2-3 digit number
167+
const mockFastlyIP =
168+
DEFAULT_FASTLY_IPS[0].split('.').slice(0, 3).join('.') + `.${Math.floor(Math.random() * 100)}`
169+
// Cookies only allows 1 request per minute
170+
const res1 = await get('/api/cookies', {
171+
headers: {
172+
'fastly-client-ip': mockFastlyIP,
173+
},
174+
})
175+
expect(res1.statusCode).toBe(200)
176+
177+
// A second request shouldn't be rate limited because it's from a Fastly IP
178+
const res2 = await get('/api/cookies', {
179+
headers: {
180+
'fastly-client-ip': mockFastlyIP,
181+
},
182+
})
183+
expect(res2.statusCode).toBe(200)
184+
})
139185
})
140186

141187
describe('404 pages and their content-type', () => {

0 commit comments

Comments
 (0)