| | import type { Response, NextFunction } from 'express' |
| |
|
| | import statsd from '@/observability/lib/statsd' |
| | import { noCacheControl, defaultCacheControl } from '@/frame/middleware/cache-control' |
| | import { ExtendedRequest } from '@/types' |
| |
|
| | const STATSD_KEY = 'middleware.handle_invalid_querystrings' |
| |
|
| | |
| | export const MAX_UNFAMILIAR_KEYS_BAD_REQUEST = 15 |
| | export const MAX_UNFAMILIAR_KEYS_REDIRECT = 3 |
| |
|
| | const RECOGNIZED_KEYS_BY_PREFIX = { |
| | '/_next/data/': ['versionId', 'productId', 'restPage', 'apiVersion', 'category', 'subcategory'], |
| | '/api/search': ['query', 'language', 'version', 'page', 'product', 'autocomplete', 'limit'], |
| | '/api/combined-search': ['query', 'version', 'size', 'debug'], |
| | '/api/anchor-redirect': ['hash', 'path'], |
| | '/api/webhooks': ['category', 'version'], |
| | '/api/pageinfo': ['pathname'], |
| | } |
| |
|
| | const RECOGNIZED_KEYS_BY_ANY = new Set([ |
| | |
| | 'learn', |
| | 'learnProduct', |
| | |
| | 'platform', |
| | |
| | 'tool', |
| | |
| | 'apiVersion', |
| | |
| | 'query', |
| | |
| | 'search-overlay-input', |
| | 'search-overlay-open', |
| | 'search-overlay-ask-ai', |
| | |
| | 'actionType', |
| | |
| | 'articles-category', |
| | 'articles-filter', |
| | 'articles-page', |
| | |
| | 'ghdomain', |
| | |
| | 'utm_source', |
| | 'utm_medium', |
| | 'utm_campaign', |
| | |
| | 'feature', |
| | |
| | 'client_name', |
| | ]) |
| |
|
| | export default function handleInvalidQuerystrings( |
| | req: ExtendedRequest, |
| | res: Response, |
| | next: NextFunction, |
| | ) { |
| | const { method, query, path } = req |
| | if (method === 'GET' || method === 'HEAD') { |
| | const originalKeys = Object.keys(query) |
| |
|
| | |
| | const invalidKeys = originalKeys.filter((key) => { |
| | |
| | return key.includes('[') || key.includes(']') |
| | }) |
| |
|
| | if (invalidKeys.length > 0) { |
| | noCacheControl(res) |
| | const invalidKey = invalidKeys[0].replace(/\[.*$/, '') |
| | res.status(400).send(`Invalid query string key (${invalidKey})`) |
| |
|
| | const tags = [ |
| | 'response:400', |
| | 'reason:invalid-brackets', |
| | `url:${req.url}`, |
| | `path:${req.path}`, |
| | `keys:${originalKeys.length}`, |
| | ] |
| | statsd.increment(STATSD_KEY, 1, tags) |
| |
|
| | return |
| | } |
| |
|
| | let keys = originalKeys.filter((key) => !RECOGNIZED_KEYS_BY_ANY.has(key)) |
| | if (keys.length > 0) { |
| | |
| | |
| | for (const [prefix, recognizedKeys] of Object.entries(RECOGNIZED_KEYS_BY_PREFIX)) { |
| | if (path.startsWith(prefix)) { |
| | keys = keys.filter((key) => !recognizedKeys.includes(key)) |
| | } |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | const honeypotted = 'survey-token' in query && 'survey-vote' in query |
| |
|
| | if (keys.length >= MAX_UNFAMILIAR_KEYS_BAD_REQUEST || honeypotted) { |
| | noCacheControl(res) |
| |
|
| | const message = honeypotted ? 'Honeypotted' : 'Too many unrecognized query string parameters' |
| | res.status(400).send(message) |
| |
|
| | const tags = [ |
| | 'response:400', |
| | `url:${req.url}`, |
| | `path:${req.path}`, |
| | `keys:${originalKeys.length}`, |
| | ] |
| | statsd.increment(STATSD_KEY, 1, tags) |
| |
|
| | return |
| | } |
| |
|
| | |
| | |
| | |
| | const rootHomePage = path.split('/').length === 2 |
| | const badKeylessQuery = |
| | rootHomePage && keys.length === 1 && keys[0].length === 8 && !query[keys[0]] |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | const badToolsQuery = keys.some((key) => key.startsWith('tool%') && !query[key]) |
| |
|
| | if (keys.length >= MAX_UNFAMILIAR_KEYS_REDIRECT || badKeylessQuery || badToolsQuery) { |
| | if (process.env.NODE_ENV === 'development') { |
| | console.warn( |
| | 'Redirecting because of a questionable query string, see https://github.com/github/docs/blob/main/src/shielding/README.md', |
| | ) |
| | } |
| | defaultCacheControl(res) |
| | const sp = new URLSearchParams(query as any) |
| | for (const key of keys) { |
| | sp.delete(key) |
| | } |
| | let newURL = req.path |
| | if (sp.toString()) newURL += `?${sp}` |
| |
|
| | res.redirect(302, newURL) |
| |
|
| | const tags = [ |
| | 'response:302', |
| | `url:${req.url}`, |
| | `path:${req.path}`, |
| | `keys:${originalKeys.length}`, |
| | ] |
| | statsd.increment(STATSD_KEY, 1, tags) |
| |
|
| | return |
| | } |
| | } |
| |
|
| | return next() |
| | } |
| |
|