Skip to content

Commit 1564ce8

Browse files
authored
don't rate limit public Fastly IPs (#54625)
1 parent 7833073 commit 1564ce8

File tree

3 files changed

+132
-1
lines changed

3 files changed

+132
-1
lines changed

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)