From eca7b0ed05354179ba1a2236d26e6e4613f6b602 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 16 Mar 2026 10:46:45 -0700 Subject: [PATCH 1/8] Fix open redirect via protocol-relative URLs (#60215) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eslint.config.ts | 10 +++++ .../middleware/archived-asset-redirects.ts | 2 +- .../archived-enterprise-versions.ts | 14 +++---- src/article-api/middleware/pagelist.ts | 4 +- src/assets/middleware/asset-preprocessing.ts | 2 +- src/assets/middleware/dynamic-assets.ts | 2 +- src/frame/middleware/index.ts | 5 +++ src/frame/middleware/manifest-json.ts | 2 +- src/frame/middleware/safe-redirect.ts | 29 +++++++++++++++ src/frame/middleware/trailing-slashes.ts | 2 +- src/redirects/middleware/handle-redirects.ts | 8 ++-- .../middleware/language-code-redirects.ts | 2 +- src/redirects/tests/safe-redirect-url.ts | 37 +++++++++++++++++++ .../middleware/ghes-release-notes.ts | 2 +- src/search/middleware/ai-search.ts | 2 +- src/search/middleware/search-routes.ts | 6 +-- .../middleware/handle-invalid-paths.ts | 2 +- .../handle-invalid-query-string-values.ts | 2 +- .../handle-invalid-query-strings.ts | 2 +- src/shielding/tests/invalid-querystrings.ts | 9 +++++ src/shielding/tests/shielding.ts | 20 ++++++++++ src/types/express.d.ts | 9 +++++ 22 files changed, 146 insertions(+), 27 deletions(-) create mode 100644 src/frame/middleware/safe-redirect.ts create mode 100644 src/redirects/tests/safe-redirect-url.ts create mode 100644 src/types/express.d.ts diff --git a/eslint.config.ts b/eslint.config.ts index 5ca834ddbfb5..97380c8f8ab0 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -102,6 +102,16 @@ export default [ // Custom rules (disabled by default for now) 'custom-rules/use-custom-logger': 'off', + + // Prevent direct res.redirect() usage — use res.safeRedirect() instead + // to avoid open redirect vulnerabilities via protocol-relative URLs. + 'no-restricted-syntax': [ + 'error', + { + selector: "CallExpression[callee.object.name='res'][callee.property.name='redirect']", + message: 'Use res.safeRedirect() instead of res.redirect() to prevent open redirects.', + }, + ], }, }, diff --git a/src/archives/middleware/archived-asset-redirects.ts b/src/archives/middleware/archived-asset-redirects.ts index 9ee3a72946f0..5dac6e511bd3 100644 --- a/src/archives/middleware/archived-asset-redirects.ts +++ b/src/archives/middleware/archived-asset-redirects.ts @@ -29,7 +29,7 @@ export default function archivedAssetRedirects( ) { if (req.path in REDIRECTS) { const redirect = REDIRECTS[req.path].replace('/assets/', '/assets/cb-0000/') - return res.redirect(308, redirect) + return res.safeRedirect(308, redirect) } return next() diff --git a/src/archives/middleware/archived-enterprise-versions.ts b/src/archives/middleware/archived-enterprise-versions.ts index 4ea074cc89fd..6f4c001e4e9f 100644 --- a/src/archives/middleware/archived-enterprise-versions.ts +++ b/src/archives/middleware/archived-enterprise-versions.ts @@ -117,7 +117,7 @@ export default async function archivedEnterpriseVersions( languageCacheControl(res) // call first to get `vary` } archivedCacheControl(res) // call second to extend duration - return res.redirect(redirectCode, redirectTo) + return res.safeRedirect(redirectCode, redirectTo) } const redirectJson = (await getRemoteJSON(getProxyPath('redirects.json', requestedVersion), { @@ -137,7 +137,7 @@ export default async function archivedEnterpriseVersions( languageCacheControl(res) // call first to get `vary` } archivedCacheControl(res) // call second to extend duration - return res.redirect(redirectCode, `/${language}${newRedirectTo}`) + return res.safeRedirect(redirectCode, `/${language}${newRedirectTo}`) } } // For releases 2.13 and lower, redirect language-prefixed URLs like /en/enterprise/2.10 -> /enterprise/2.10 @@ -146,7 +146,7 @@ export default async function archivedEnterpriseVersions( versionSatisfiesRange(requestedVersion, `<${firstVersionDeprecatedOnNewSite}`) ) { archivedCacheControl(res) - return res.redirect(redirectCode, req.baseUrl + req.path.replace(/^\/en/, '')) + return res.safeRedirect(redirectCode, req.baseUrl + req.path.replace(/^\/en/, '')) } // Redirects for releases 2.13 - 2.17 @@ -170,7 +170,7 @@ export default async function archivedEnterpriseVersions( // new destination. const redirect = `/${language || 'en'}${newPath || withoutLanguagePath}` cacheAggressively(res) - return res.redirect(redirectCode, redirect) + return res.safeRedirect(redirectCode, redirect) } } // Redirects for 2.18 - 3.0. Starting with 2.18, we updated the archival @@ -193,7 +193,7 @@ export default async function archivedEnterpriseVersions( if (redirectJson[req.path]) { res.set('x-robots-tag', 'noindex') cacheAggressively(res) - return res.redirect(redirectCode, redirectJson[req.path]) + return res.safeRedirect(redirectCode, redirectJson[req.path]) } } // Retrieve the page from the archived repo @@ -260,7 +260,7 @@ export default async function archivedEnterpriseVersions( const staticRedirect = body.match(patterns.staticRedirect) if (staticRedirect) { cacheAggressively(res) - return res.redirect(redirectCode, staticRedirect[1]) + return res.safeRedirect(redirectCode, staticRedirect[1]) } res.set('content-type', r.headers.get('content-type') || '') @@ -373,7 +373,7 @@ export default async function archivedEnterpriseVersions( statsTags.push(`fallback:${fallbackRedirect}`) statsd.increment('middleware.trying_fallback_redirect_success', 1, statsTags) cacheAggressively(res) - return res.redirect(redirectCode, fallbackRedirect) + return res.safeRedirect(redirectCode, fallbackRedirect) } statsd.increment('middleware.trying_fallback_redirect_failure', 1, statsTags) } diff --git a/src/article-api/middleware/pagelist.ts b/src/article-api/middleware/pagelist.ts index 095f4b9bab59..295a18c49acf 100644 --- a/src/article-api/middleware/pagelist.ts +++ b/src/article-api/middleware/pagelist.ts @@ -92,7 +92,7 @@ router.get( '/', pagelistValidationMiddleware as RequestHandler, catchMiddlewareError(async function (req: ExtendedRequest, res: Response) { - res.redirect( + res.safeRedirect( 308, req.originalUrl.replace( '/pagelist', @@ -108,7 +108,7 @@ router.get( pagelistValidationMiddleware as RequestHandler, catchMiddlewareError(async function (req: ExtendedRequest, res: Response) { const { someParam } = req.params - res.redirect( + res.safeRedirect( 308, req.originalUrl.replace( `/pagelist/${someParam}`, diff --git a/src/assets/middleware/asset-preprocessing.ts b/src/assets/middleware/asset-preprocessing.ts index 1a5a1cd6d8c0..a61be8044422 100644 --- a/src/assets/middleware/asset-preprocessing.ts +++ b/src/assets/middleware/asset-preprocessing.ts @@ -30,7 +30,7 @@ export default function assetPreprocessing( // By forcing this to be a redirect, it means we only serve // 1 single file. All other requests will be redirects. // Otherwise someone might trigger too much bypassing of the CDN. - return res.redirect(req.url.toLowerCase()) + return res.safeRedirect(req.url.toLowerCase()) } // We're only confident enough to set the *manual* surrogate key if the diff --git a/src/assets/middleware/dynamic-assets.ts b/src/assets/middleware/dynamic-assets.ts index 9053151a3181..b59d558097e1 100644 --- a/src/assets/middleware/dynamic-assets.ts +++ b/src/assets/middleware/dynamic-assets.ts @@ -76,7 +76,7 @@ export default async function dynamicAssets( // < 302 // < location: /assets/images/site/logo.web // - return res.redirect(302, req.path) + return res.safeRedirect(302, req.path) } // From PNG to WEBP, if the PNG exists diff --git a/src/frame/middleware/index.ts b/src/frame/middleware/index.ts index c2ef6d86b824..871605038ba0 100644 --- a/src/frame/middleware/index.ts +++ b/src/frame/middleware/index.ts @@ -66,6 +66,7 @@ import mockVaPortal from './mock-va-portal' import dynamicAssets from '@/assets/middleware/dynamic-assets' import generalSearchMiddleware from '@/search/middleware/general-search-middleware' import shielding from '@/shielding/middleware' +import safeRedirect from './safe-redirect' import { MAX_REQUEST_TIMEOUT } from '@/frame/lib/constants' import { initLoggerContext } from '@/observability/logger/lib/logger-context' import { getAutomaticRequestLogger } from '@/observability/logger/middleware/get-automatic-request-logger' @@ -125,6 +126,10 @@ export default function index(app: Express) { // for static assets as well. app.use(setDefaultFastlySurrogateKey) + // Attaches res.safeRedirect() to every response. Must appear before + // any middleware that redirects. + app.use(safeRedirect) + // archivedEnterpriseVersionsAssets must come before static/assets app.use(asyncMiddleware(archivedEnterpriseVersionsAssets)) diff --git a/src/frame/middleware/manifest-json.ts b/src/frame/middleware/manifest-json.ts index df951553b5d1..87b666fd2066 100644 --- a/src/frame/middleware/manifest-json.ts +++ b/src/frame/middleware/manifest-json.ts @@ -32,7 +32,7 @@ export default async function manifestJson(req: Request, res: Response, next: Ne if (req.url !== '/manifest.json') { // E.g. `/manifest.json/anything` or `/manifest.json?foo=bar` defaultCacheControl(res) - return res.redirect(302, '/manifest.json') + return res.safeRedirect(302, '/manifest.json') } const icons: Icon[] = [] diff --git a/src/frame/middleware/safe-redirect.ts b/src/frame/middleware/safe-redirect.ts new file mode 100644 index 000000000000..0c875bfe9ed7 --- /dev/null +++ b/src/frame/middleware/safe-redirect.ts @@ -0,0 +1,29 @@ +import type { Response, NextFunction } from 'express' + +import type { ExtendedRequest } from '@/types' + +// Normalizes a redirect URL to prevent open redirects via protocol-relative +// URLs (e.g. "//evil.com" which browsers interpret as "https://evil.com"). +export function safeRedirectUrl(url: string): string { + return url.replace(/^\/\/+/, '/') +} + +// Matches the overloaded signature of Express's res.redirect(). +export type SafeRedirect = { + (url: string): void + (status: number, url: string): void +} + +// Attaches res.safeRedirect() to the response for all downstream middleware. +// Same signature as res.redirect() but normalizes the URL first. +export default function safeRedirect(req: ExtendedRequest, res: Response, next: NextFunction) { + res.safeRedirect = function (statusOrUrl: number | string, url?: string) { + if (typeof statusOrUrl === 'number' && url !== undefined) { + // eslint-disable-next-line no-restricted-syntax + return res.redirect(statusOrUrl, safeRedirectUrl(url)) + } + // eslint-disable-next-line no-restricted-syntax + return res.redirect(safeRedirectUrl(statusOrUrl as string)) + } + return next() +} diff --git a/src/frame/middleware/trailing-slashes.ts b/src/frame/middleware/trailing-slashes.ts index 572cb5678692..53e34ac1856d 100644 --- a/src/frame/middleware/trailing-slashes.ts +++ b/src/frame/middleware/trailing-slashes.ts @@ -17,7 +17,7 @@ export default function trailingSlashes(req: ExtendedRequest, res: Response, nex } url = url.replace(/\/+/g, '/') // Prevent multiple slashes defaultCacheControl(res) - return res.redirect(301, url) + return res.safeRedirect(301, url) } } diff --git a/src/redirects/middleware/handle-redirects.ts b/src/redirects/middleware/handle-redirects.ts index 606744a7c8b9..c7850e3c1215 100644 --- a/src/redirects/middleware/handle-redirects.ts +++ b/src/redirects/middleware/handle-redirects.ts @@ -18,7 +18,7 @@ export default function handleRedirects(req: ExtendedRequest, res: Response, nex // This must be done before checking if the path // is an asset (patterns.assetPaths) if (req.path.includes('//')) { - return res.redirect(301, req.path.replace(/\/+/g, '/')) + return res.safeRedirect(301, req.path.replace(/\/+/g, '/')) } // never redirect assets @@ -45,7 +45,7 @@ export default function handleRedirects(req: ExtendedRequest, res: Response, nex if (queryParams) { queryParams = `?${queryParams}` } - return res.redirect(302, redirectPath + queryParams) + return res.safeRedirect(302, redirectPath + queryParams) } // begin redirect handling @@ -80,7 +80,7 @@ export default function handleRedirects(req: ExtendedRequest, res: Response, nex } redirectTo += `/search?${sp.toString()}` - return res.redirect(301, redirectTo) + return res.safeRedirect(301, redirectTo) } // have to do this now because searchPath replacement changes the path as well as the query params @@ -146,7 +146,7 @@ export default function handleRedirects(req: ExtendedRequest, res: Response, nex } const permanent = redirect.includes('://') || usePermanentRedirect(req) - return res.redirect(permanent ? 301 : 302, redirect) + return res.safeRedirect(permanent ? 301 : 302, redirect) } function getLanguage(req: ExtendedRequest, default_ = 'en') { diff --git a/src/redirects/middleware/language-code-redirects.ts b/src/redirects/middleware/language-code-redirects.ts index fc45fca9eac9..d4561c734497 100644 --- a/src/redirects/middleware/language-code-redirects.ts +++ b/src/redirects/middleware/language-code-redirects.ts @@ -44,7 +44,7 @@ export default function languageCodeRedirects( const [code, pattern] = matched if (code && pattern) { defaultCacheControl(res) - return res.redirect(301, req.path.replace(pattern, `/${code}`)) + return res.safeRedirect(301, req.path.replace(pattern, `/${code}`)) } } return next() diff --git a/src/redirects/tests/safe-redirect-url.ts b/src/redirects/tests/safe-redirect-url.ts new file mode 100644 index 000000000000..962dc6c47a73 --- /dev/null +++ b/src/redirects/tests/safe-redirect-url.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'vitest' + +import { safeRedirectUrl } from '@/frame/middleware/safe-redirect' + +describe('safeRedirectUrl', () => { + test('collapses leading double slashes to single slash', () => { + expect(safeRedirectUrl('//evil.com')).toBe('/evil.com') + }) + + test('collapses leading triple slashes to single slash', () => { + expect(safeRedirectUrl('///evil.com')).toBe('/evil.com') + }) + + test('collapses many leading slashes to single slash', () => { + expect(safeRedirectUrl('////evil.com/path')).toBe('/evil.com/path') + }) + + test('preserves single leading slash', () => { + expect(safeRedirectUrl('/en/get-started')).toBe('/en/get-started') + }) + + test('preserves query strings', () => { + expect(safeRedirectUrl('//evil.com?a=1&b=2')).toBe('/evil.com?a=1&b=2') + }) + + test('does not modify double slashes in the middle of a path', () => { + expect(safeRedirectUrl('/en//get-started')).toBe('/en//get-started') + }) + + test('handles root path', () => { + expect(safeRedirectUrl('/')).toBe('/') + }) + + test('handles empty string', () => { + expect(safeRedirectUrl('')).toBe('') + }) +}) diff --git a/src/release-notes/middleware/ghes-release-notes.ts b/src/release-notes/middleware/ghes-release-notes.ts index c2cd946d80f5..f2b563d878b8 100644 --- a/src/release-notes/middleware/ghes-release-notes.ts +++ b/src/release-notes/middleware/ghes-release-notes.ts @@ -34,7 +34,7 @@ export default async function ghesReleaseNotesContext( // Otherwise, 404. if (!Object.keys(ghesReleaseNotes).includes(requestedRelease.replace(/\./, '-'))) { return all.includes(requestedRelease) - ? res.redirect(`https://enterprise.github.com/releases/${requestedRelease}.0/notes`) + ? res.safeRedirect(`https://enterprise.github.com/releases/${requestedRelease}.0/notes`) : next() } diff --git a/src/search/middleware/ai-search.ts b/src/search/middleware/ai-search.ts index 71b32e29c4f2..0ff0aff98edd 100644 --- a/src/search/middleware/ai-search.ts +++ b/src/search/middleware/ai-search.ts @@ -16,7 +16,7 @@ router.post( // Redirect to most recent version router.post('/', (req, res) => { - res.redirect(307, req.originalUrl.replace('/ai-search', '/ai-search/v1')) + res.safeRedirect(307, req.originalUrl.replace('/ai-search', '/ai-search/v1')) }) export default router diff --git a/src/search/middleware/search-routes.ts b/src/search/middleware/search-routes.ts index 8deb2040f641..fdb85d187ddd 100644 --- a/src/search/middleware/search-routes.ts +++ b/src/search/middleware/search-routes.ts @@ -53,18 +53,18 @@ export async function handleGetSearchResultsError( // Redirects search routes to their latest versions router.get('/', (req: Request, res: Response) => { - res.redirect(307, req.originalUrl.replace('/search', '/search/v1')) + res.safeRedirect(307, req.originalUrl.replace('/search', '/search/v1')) }) router.get('/ai-search-autocomplete', (req: Request, res: Response) => { - res.redirect( + res.safeRedirect( 307, req.originalUrl.replace('/search/ai-search-autocomplete', '/search/ai-search-autocomplete/v1'), ) }) router.get('/combined-search', (req: Request, res: Response) => { - res.redirect( + res.safeRedirect( 307, req.originalUrl.replace('/search/combined-search', '/search/combined-search/v1'), ) diff --git a/src/shielding/middleware/handle-invalid-paths.ts b/src/shielding/middleware/handle-invalid-paths.ts index b13e99d3ca2d..0970bd3281cd 100644 --- a/src/shielding/middleware/handle-invalid-paths.ts +++ b/src/shielding/middleware/handle-invalid-paths.ts @@ -86,7 +86,7 @@ export default function handleInvalidPaths( if (req.path.endsWith('/index.md')) { defaultCacheControl(res) const newUrl = req.originalUrl.replace(req.path, req.path.replace(/\/index\.md$/, '')) - return res.redirect(newUrl) + return res.safeRedirect(newUrl) } else if (req.path.endsWith('.md')) { req.url = req.url.replace(/\.md($|\?)/, '$1') req.originalUrl = req.originalUrl.replace(/\.md($|\?)/, '$1') diff --git a/src/shielding/middleware/handle-invalid-query-string-values.ts b/src/shielding/middleware/handle-invalid-query-string-values.ts index 39409d63be86..3ff3cdbb42ed 100644 --- a/src/shielding/middleware/handle-invalid-query-string-values.ts +++ b/src/shielding/middleware/handle-invalid-query-string-values.ts @@ -60,7 +60,7 @@ export default function handleInvalidQuerystringValues( defaultCacheControl(res) let newURL = req.path if (sp.toString()) newURL += `?${sp}` - res.redirect(302, newURL) + res.safeRedirect(302, newURL) const tags = ['response:302', `url:${req.url}`, `path:${req.path}`, `key:${key}`] statsd.increment(STATSD_KEY, 1, tags) diff --git a/src/shielding/middleware/handle-invalid-query-strings.ts b/src/shielding/middleware/handle-invalid-query-strings.ts index d53e911a5468..0bb7ec2942e4 100644 --- a/src/shielding/middleware/handle-invalid-query-strings.ts +++ b/src/shielding/middleware/handle-invalid-query-strings.ts @@ -148,7 +148,7 @@ export default function handleInvalidQuerystrings( let newURL = req.path if (sp.toString()) newURL += `?${sp}` - res.redirect(302, newURL) + res.safeRedirect(302, newURL) const tags = [ 'response:302', diff --git a/src/shielding/tests/invalid-querystrings.ts b/src/shielding/tests/invalid-querystrings.ts index 2e4ef943c523..1619ae7f25a1 100644 --- a/src/shielding/tests/invalid-querystrings.ts +++ b/src/shielding/tests/invalid-querystrings.ts @@ -101,6 +101,15 @@ describe('invalid query strings', () => { expect(res.body).not.toContain('