|
| 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 | +} |
0 commit comments