Skip to content

Commit eb7f9a9

Browse files
authored
Merge pull request #25590 from github/repo-sync
repo sync
2 parents b54719a + d562087 commit eb7f9a9

File tree

3 files changed

+138
-0
lines changed

3 files changed

+138
-0
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import statsd from '../lib/statsd.js'
2+
import { noCacheControl, defaultCacheControl } from './cache-control.js'
3+
4+
const STATSD_KEY = 'middleware.handle_invalid_querystrings'
5+
6+
// Exported for the sake of end-to-end tests
7+
export const MAX_UNFAMILIAR_KEYS_BAD_REQUEST = 15
8+
export const MAX_UNFAMILIAR_KEYS_REDIRECT = 3
9+
10+
const RECOGNIZED_KEYS_BY_PREFIX = {
11+
'/_next/data/': ['versionId', 'productId', 'restPage', 'apiVersion', 'category', 'subcategory'],
12+
'/api/search': ['query', 'language', 'version', 'page', 'product', 'autocomplete', 'limit'],
13+
'/api/anchor-redirect': ['hash', 'path'],
14+
'/api/webhooks': ['category', 'version'],
15+
'/api/pageinfo': ['pathname'],
16+
}
17+
18+
const RECOGNIZED_KEYS_BY_ANY = new Set([
19+
// Learning track pages
20+
'learn',
21+
'learnProduct',
22+
// Platform picker
23+
'platform',
24+
// Tool picker
25+
'tool',
26+
// When apiVersion isn't the only one. E.g. ?apiVersion=XXX&tool=vscode
27+
'apiVersion',
28+
// Search
29+
'query',
30+
// The drop-downs on "Webhook events and payloads"
31+
'actionType',
32+
])
33+
34+
export default function handleInvalidQuerystrings(req, res, next) {
35+
const { method, query, path } = req
36+
if (method === 'GET' || method === 'HEAD') {
37+
const originalKeys = Object.keys(query)
38+
let keys = originalKeys.filter((key) => !RECOGNIZED_KEYS_BY_ANY.has(key))
39+
if (keys.length > 0) {
40+
// Before we judge the number of query strings, strip out all the ones
41+
// we're familiar with.
42+
for (const [prefix, recognizedKeys] of Object.entries(RECOGNIZED_KEYS_BY_PREFIX)) {
43+
if (path.startsWith(prefix)) {
44+
keys = keys.filter((key) => !recognizedKeys.includes(key))
45+
}
46+
}
47+
}
48+
49+
if (keys.length >= MAX_UNFAMILIAR_KEYS_BAD_REQUEST) {
50+
noCacheControl(res)
51+
52+
res.status(400).send('Too many unrecognized query string parameters')
53+
54+
const tags = [
55+
'response:400',
56+
`url:${req.url}`,
57+
`ip:${req.ip}`,
58+
`path:${req.path}`,
59+
`keys:${originalKeys.length}`,
60+
]
61+
statsd.increment(STATSD_KEY, 1, tags)
62+
63+
return
64+
}
65+
66+
if (keys.length >= MAX_UNFAMILIAR_KEYS_REDIRECT) {
67+
defaultCacheControl(res)
68+
const sp = new URLSearchParams(query)
69+
keys.forEach((key) => sp.delete(key))
70+
let newURL = req.path
71+
if (sp.toString()) newURL += `?${sp}`
72+
73+
res.redirect(302, newURL)
74+
75+
const tags = [
76+
'response:302',
77+
`url:${req.url}`,
78+
`ip:${req.ip}`,
79+
`path:${req.path}`,
80+
`keys:${originalKeys.length}`,
81+
]
82+
statsd.increment(STATSD_KEY, 1, tags)
83+
84+
return
85+
}
86+
}
87+
88+
return next()
89+
}

middleware/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import fastlyBehavior from './fastly-behavior.js'
6464
import mockVaPortal from './mock-va-portal.js'
6565
import dynamicAssets from './dynamic-assets.js'
6666
import rateLimit from './rate-limit.js'
67+
import handleInvalidQuerystrings from './handle-invalid-querystrings.js'
6768

6869
const { DEPLOYMENT_ENV, NODE_ENV } = process.env
6970
const isTest = NODE_ENV === 'test' || process.env.GITHUB_ACTIONS === 'true'
@@ -198,6 +199,7 @@ export default function (app) {
198199

199200
// *** Early exits ***
200201
app.use(rateLimit)
202+
app.use(instrument(handleInvalidQuerystrings, './handle-invalid-querystrings'))
201203
app.use(instrument(handleInvalidPaths, './handle-invalid-paths'))
202204
app.use(instrument(handleNextDataPath, './handle-next-data-path'))
203205

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe } from '@jest/globals'
2+
3+
import { get } from '../helpers/e2etest.js'
4+
import {
5+
MAX_UNFAMILIAR_KEYS_BAD_REQUEST,
6+
MAX_UNFAMILIAR_KEYS_REDIRECT,
7+
} from '../../middleware/handle-invalid-querystrings.js'
8+
9+
const alpha = Array.from(Array(26)).map((e, i) => i + 65)
10+
const alphabet = alpha.map((x) => String.fromCharCode(x))
11+
12+
describe('invalid query strings', () => {
13+
test('400 for too many unrecognized query strings', async () => {
14+
// This test depends on knowing exactly the number
15+
// of unrecognized query strings that will trigger a 400.
16+
const sp = new URLSearchParams()
17+
alphabet.slice(0, MAX_UNFAMILIAR_KEYS_BAD_REQUEST).forEach((letter) => sp.set(letter, '1'))
18+
const url = `/?${sp}`
19+
const res = await get(url)
20+
expect(res.statusCode).toBe(400)
21+
expect(res.headers['cache-control']).toMatch('no-store')
22+
expect(res.headers['cache-control']).toMatch('private')
23+
})
24+
25+
test('302 redirect for many unrecognized query strings', async () => {
26+
// This test depends on knowing exactly the number
27+
// of unrecognized query strings that will trigger a 400.
28+
const sp = new URLSearchParams()
29+
alphabet.slice(0, MAX_UNFAMILIAR_KEYS_REDIRECT).forEach((letter) => sp.set(letter, '1'))
30+
const url = `/?${sp}`
31+
const res = await get(url)
32+
expect(res.statusCode).toBe(302)
33+
expect(res.headers.location).toBe('/')
34+
expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/)
35+
expect(res.headers['cache-control']).toMatch('public')
36+
})
37+
38+
test('302 redirect but keeping recognized query strings', async () => {
39+
const sp = new URLSearchParams()
40+
alphabet.slice(0, MAX_UNFAMILIAR_KEYS_REDIRECT).forEach((letter) => sp.set(letter, '1'))
41+
sp.set('platform', 'concrete')
42+
const url = `/en/pages?${sp}`
43+
const res = await get(url)
44+
expect(res.statusCode).toBe(302)
45+
expect(res.headers.location).toBe('/en/pages?platform=concrete')
46+
})
47+
})

0 commit comments

Comments
 (0)