Skip to content

Commit

Permalink
Merge pull request github#36572 from github/repo-sync
Browse files Browse the repository at this point in the history
Repo sync
  • Loading branch information
docs-bot authored Feb 28, 2025
2 parents aea537c + 1564ce8 commit 82c08e5
Show file tree
Hide file tree
Showing 5 changed files with 145 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,14 @@ You can filter by specific text fields or use a general text filter across all t
| <code><em>TEXT</em></code> | **API** will show items with "API" in the title or any other text field.
| <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.

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 <code>&ast;</code> as a wildcard.
Expand Down
5 changes: 5 additions & 0 deletions src/events/lib/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
81 changes: 81 additions & 0 deletions src/shielding/lib/fastly-ips.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
// 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<boolean> {
// 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))
}
6 changes: 5 additions & 1 deletion src/shielding/middleware/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions src/shielding/tests/shielding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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', () => {
Expand Down

0 comments on commit 82c08e5

Please sign in to comment.