Skip to content

Commit 1f11a20

Browse files
authored
Look up fastly IPs using cidr (#55119)
1 parent f4ec00c commit 1f11a20

File tree

4 files changed

+37
-26
lines changed

4 files changed

+37
-26
lines changed

package-lock.json

Lines changed: 14 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@
286286
"html-entities": "^2.5.2",
287287
"http-proxy-middleware": "3.0.3",
288288
"imurmurhash": "^0.1.4",
289+
"ipaddr.js": "^2.2.0",
289290
"is-svg": "5.0.0",
290291
"javascript-stringify": "^2.1.0",
291292
"js-cookie": "^3.0.1",

src/shielding/lib/fastly-ips.ts

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
// 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/
22

3+
import ipaddr, { IPv4, IPv6 } from 'ipaddr.js'
4+
5+
type IPRangeArr = [IPv4 | IPv6, number][]
6+
37
// Default returned from ➜ curl "https://api.fastly.com/public-ip-list"
4-
export const DEFAULT_FASTLY_IPS: string[] = [
8+
export const DEFAULT_FASTLY_IPS: IPRangeArr = [
59
'23.235.32.0/20',
610
'43.249.72.0/22',
711
'103.244.50.0/24',
@@ -21,22 +25,21 @@ export const DEFAULT_FASTLY_IPS: string[] = [
2125
'185.31.16.0/22',
2226
'199.27.72.0/21',
2327
'199.232.0.0/16',
24-
]
28+
].map((cidr) => ipaddr.parseCIDR(cidr))
2529

26-
let ipCache: string[] = []
30+
let ipRangeCache: IPRangeArr = []
2731

28-
export async function getPublicFastlyIPs(): Promise<string[]> {
32+
export async function getPublicFastlyIPs(): Promise<IPRangeArr> {
2933
// Don't fetch the list in dev & testing, just use the defaults
3034
if (process.env.NODE_ENV !== 'production') {
31-
ipCache = DEFAULT_FASTLY_IPS
35+
ipRangeCache = DEFAULT_FASTLY_IPS
3236
}
3337

34-
if (ipCache.length) {
35-
return ipCache
38+
if (ipRangeCache.length) {
39+
return ipRangeCache
3640
}
3741

3842
const endpoint = 'https://api.fastly.com/public-ip-list'
39-
let ips: string[] = []
4043
let attempt = 0
4144

4245
while (attempt < 3) {
@@ -47,8 +50,8 @@ export async function getPublicFastlyIPs(): Promise<string[]> {
4750
}
4851
const data = await response.json()
4952
if (data && Array.isArray(data.addresses)) {
50-
ips = data.addresses
51-
break
53+
ipRangeCache = data.addresses.map((cidr: string) => ipaddr.parseCIDR(cidr))
54+
return ipRangeCache
5255
} else {
5356
throw new Error('Invalid response structure')
5457
}
@@ -57,25 +60,22 @@ export async function getPublicFastlyIPs(): Promise<string[]> {
5760
`Failed to fetch Fastly IPs: ${error.message}. Retrying ${3 - attempt} more times`,
5861
)
5962
attempt++
60-
if (attempt >= 3) {
61-
ips = DEFAULT_FASTLY_IPS
62-
}
6363
}
6464
}
6565

66-
ipCache = ips
67-
return ips
66+
ipRangeCache = DEFAULT_FASTLY_IPS
67+
return ipRangeCache
6868
}
6969

7070
// The IPs we check in the rate-limiter are in the form `X.X.X.X`
7171
// But the IPs returned from the Fastly API are in the form `X.X.X.X/Y`
7272
// For an IP in the rate-limiter, we want `X.X.X.*` to match `X.X.X.X/Y`
7373
export async function isFastlyIP(ip: string): Promise<boolean> {
7474
// If IPs aren't initialized, fetch them
75-
if (!ipCache.length) {
75+
if (!ipRangeCache.length) {
7676
await getPublicFastlyIPs()
7777
}
78-
const parts = ip.split('.')
79-
const prefix = parts.slice(0, 3).join('.')
80-
return ipCache.some((fastlyIP) => fastlyIP.startsWith(prefix))
78+
if (!ip) return false // localhost
79+
const addr = ipaddr.parse(ip)
80+
return ipRangeCache.some((range) => addr.match(range))
8181
}

src/shielding/tests/shielding.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ 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'
65

76
describe('honeypotting', () => {
87
test('any GET with survey-vote and survey-token query strings is 400', async () => {
@@ -105,7 +104,7 @@ describe('rate limiting', () => {
105104
headers: {
106105
// Rate limiting only happens in production, so we need to
107106
// make the environment look like production.
108-
'fastly-client-ip': 'abc',
107+
'fastly-client-ip': '0.0.0.0',
109108
},
110109
})
111110
expect(res.statusCode).toBe(200)
@@ -118,7 +117,7 @@ describe('rate limiting', () => {
118117
{
119118
const res = await get('/robots.txt?foo=buzz', {
120119
headers: {
121-
'fastly-client-ip': 'abc',
120+
'fastly-client-ip': '0.0.0.0',
122121
},
123122
})
124123
expect(res.statusCode).toBe(200)
@@ -142,8 +141,7 @@ describe('rate limiting', () => {
142141
// Fastly IPs are in the form `X.X.X.X/Y`
143142
// Rate limited IPs are in the form `X.X.X.X`
144143
// Where the last X could be any 2-3 digit number
145-
const mockFastlyIP =
146-
DEFAULT_FASTLY_IPS[0].split('.').slice(0, 3).join('.') + `.${Math.floor(Math.random() * 100)}`
144+
const mockFastlyIP = '23.235.32.0'
147145
// Cookies only allows 1 request per minute
148146
const res1 = await get('/api/cookies', {
149147
headers: {

0 commit comments

Comments
 (0)