Skip to content

Commit eca7b0e

Browse files
heiskrCopilot
andauthored
Fix open redirect via protocol-relative URLs (#60215)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d49d0ff commit eca7b0e

22 files changed

+146
-27
lines changed

eslint.config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,16 @@ export default [
102102

103103
// Custom rules (disabled by default for now)
104104
'custom-rules/use-custom-logger': 'off',
105+
106+
// Prevent direct res.redirect() usage — use res.safeRedirect() instead
107+
// to avoid open redirect vulnerabilities via protocol-relative URLs.
108+
'no-restricted-syntax': [
109+
'error',
110+
{
111+
selector: "CallExpression[callee.object.name='res'][callee.property.name='redirect']",
112+
message: 'Use res.safeRedirect() instead of res.redirect() to prevent open redirects.',
113+
},
114+
],
105115
},
106116
},
107117

src/archives/middleware/archived-asset-redirects.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export default function archivedAssetRedirects(
2929
) {
3030
if (req.path in REDIRECTS) {
3131
const redirect = REDIRECTS[req.path].replace('/assets/', '/assets/cb-0000/')
32-
return res.redirect(308, redirect)
32+
return res.safeRedirect(308, redirect)
3333
}
3434

3535
return next()

src/archives/middleware/archived-enterprise-versions.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export default async function archivedEnterpriseVersions(
117117
languageCacheControl(res) // call first to get `vary`
118118
}
119119
archivedCacheControl(res) // call second to extend duration
120-
return res.redirect(redirectCode, redirectTo)
120+
return res.safeRedirect(redirectCode, redirectTo)
121121
}
122122

123123
const redirectJson = (await getRemoteJSON(getProxyPath('redirects.json', requestedVersion), {
@@ -137,7 +137,7 @@ export default async function archivedEnterpriseVersions(
137137
languageCacheControl(res) // call first to get `vary`
138138
}
139139
archivedCacheControl(res) // call second to extend duration
140-
return res.redirect(redirectCode, `/${language}${newRedirectTo}`)
140+
return res.safeRedirect(redirectCode, `/${language}${newRedirectTo}`)
141141
}
142142
}
143143
// 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(
146146
versionSatisfiesRange(requestedVersion, `<${firstVersionDeprecatedOnNewSite}`)
147147
) {
148148
archivedCacheControl(res)
149-
return res.redirect(redirectCode, req.baseUrl + req.path.replace(/^\/en/, ''))
149+
return res.safeRedirect(redirectCode, req.baseUrl + req.path.replace(/^\/en/, ''))
150150
}
151151

152152
// Redirects for releases 2.13 - 2.17
@@ -170,7 +170,7 @@ export default async function archivedEnterpriseVersions(
170170
// new destination.
171171
const redirect = `/${language || 'en'}${newPath || withoutLanguagePath}`
172172
cacheAggressively(res)
173-
return res.redirect(redirectCode, redirect)
173+
return res.safeRedirect(redirectCode, redirect)
174174
}
175175
}
176176
// Redirects for 2.18 - 3.0. Starting with 2.18, we updated the archival
@@ -193,7 +193,7 @@ export default async function archivedEnterpriseVersions(
193193
if (redirectJson[req.path]) {
194194
res.set('x-robots-tag', 'noindex')
195195
cacheAggressively(res)
196-
return res.redirect(redirectCode, redirectJson[req.path])
196+
return res.safeRedirect(redirectCode, redirectJson[req.path])
197197
}
198198
}
199199
// Retrieve the page from the archived repo
@@ -260,7 +260,7 @@ export default async function archivedEnterpriseVersions(
260260
const staticRedirect = body.match(patterns.staticRedirect)
261261
if (staticRedirect) {
262262
cacheAggressively(res)
263-
return res.redirect(redirectCode, staticRedirect[1])
263+
return res.safeRedirect(redirectCode, staticRedirect[1])
264264
}
265265

266266
res.set('content-type', r.headers.get('content-type') || '')
@@ -373,7 +373,7 @@ export default async function archivedEnterpriseVersions(
373373
statsTags.push(`fallback:${fallbackRedirect}`)
374374
statsd.increment('middleware.trying_fallback_redirect_success', 1, statsTags)
375375
cacheAggressively(res)
376-
return res.redirect(redirectCode, fallbackRedirect)
376+
return res.safeRedirect(redirectCode, fallbackRedirect)
377377
}
378378
statsd.increment('middleware.trying_fallback_redirect_failure', 1, statsTags)
379379
}

src/article-api/middleware/pagelist.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ router.get(
9292
'/',
9393
pagelistValidationMiddleware as RequestHandler,
9494
catchMiddlewareError(async function (req: ExtendedRequest, res: Response) {
95-
res.redirect(
95+
res.safeRedirect(
9696
308,
9797
req.originalUrl.replace(
9898
'/pagelist',
@@ -108,7 +108,7 @@ router.get(
108108
pagelistValidationMiddleware as RequestHandler,
109109
catchMiddlewareError(async function (req: ExtendedRequest, res: Response) {
110110
const { someParam } = req.params
111-
res.redirect(
111+
res.safeRedirect(
112112
308,
113113
req.originalUrl.replace(
114114
`/pagelist/${someParam}`,

src/assets/middleware/asset-preprocessing.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export default function assetPreprocessing(
3030
// By forcing this to be a redirect, it means we only serve
3131
// 1 single file. All other requests will be redirects.
3232
// Otherwise someone might trigger too much bypassing of the CDN.
33-
return res.redirect(req.url.toLowerCase())
33+
return res.safeRedirect(req.url.toLowerCase())
3434
}
3535

3636
// We're only confident enough to set the *manual* surrogate key if the

src/assets/middleware/dynamic-assets.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export default async function dynamicAssets(
7676
// < 302
7777
// < location: /assets/images/site/logo.web
7878
//
79-
return res.redirect(302, req.path)
79+
return res.safeRedirect(302, req.path)
8080
}
8181

8282
// From PNG to WEBP, if the PNG exists

src/frame/middleware/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import mockVaPortal from './mock-va-portal'
6666
import dynamicAssets from '@/assets/middleware/dynamic-assets'
6767
import generalSearchMiddleware from '@/search/middleware/general-search-middleware'
6868
import shielding from '@/shielding/middleware'
69+
import safeRedirect from './safe-redirect'
6970
import { MAX_REQUEST_TIMEOUT } from '@/frame/lib/constants'
7071
import { initLoggerContext } from '@/observability/logger/lib/logger-context'
7172
import { getAutomaticRequestLogger } from '@/observability/logger/middleware/get-automatic-request-logger'
@@ -125,6 +126,10 @@ export default function index(app: Express) {
125126
// for static assets as well.
126127
app.use(setDefaultFastlySurrogateKey)
127128

129+
// Attaches res.safeRedirect() to every response. Must appear before
130+
// any middleware that redirects.
131+
app.use(safeRedirect)
132+
128133
// archivedEnterpriseVersionsAssets must come before static/assets
129134
app.use(asyncMiddleware(archivedEnterpriseVersionsAssets))
130135

src/frame/middleware/manifest-json.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export default async function manifestJson(req: Request, res: Response, next: Ne
3232
if (req.url !== '/manifest.json') {
3333
// E.g. `/manifest.json/anything` or `/manifest.json?foo=bar`
3434
defaultCacheControl(res)
35-
return res.redirect(302, '/manifest.json')
35+
return res.safeRedirect(302, '/manifest.json')
3636
}
3737

3838
const icons: Icon[] = []
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { Response, NextFunction } from 'express'
2+
3+
import type { ExtendedRequest } from '@/types'
4+
5+
// Normalizes a redirect URL to prevent open redirects via protocol-relative
6+
// URLs (e.g. "//evil.com" which browsers interpret as "https://evil.com").
7+
export function safeRedirectUrl(url: string): string {
8+
return url.replace(/^\/\/+/, '/')
9+
}
10+
11+
// Matches the overloaded signature of Express's res.redirect().
12+
export type SafeRedirect = {
13+
(url: string): void
14+
(status: number, url: string): void
15+
}
16+
17+
// Attaches res.safeRedirect() to the response for all downstream middleware.
18+
// Same signature as res.redirect() but normalizes the URL first.
19+
export default function safeRedirect(req: ExtendedRequest, res: Response, next: NextFunction) {
20+
res.safeRedirect = function (statusOrUrl: number | string, url?: string) {
21+
if (typeof statusOrUrl === 'number' && url !== undefined) {
22+
// eslint-disable-next-line no-restricted-syntax
23+
return res.redirect(statusOrUrl, safeRedirectUrl(url))
24+
}
25+
// eslint-disable-next-line no-restricted-syntax
26+
return res.redirect(safeRedirectUrl(statusOrUrl as string))
27+
}
28+
return next()
29+
}

src/frame/middleware/trailing-slashes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export default function trailingSlashes(req: ExtendedRequest, res: Response, nex
1717
}
1818
url = url.replace(/\/+/g, '/') // Prevent multiple slashes
1919
defaultCacheControl(res)
20-
return res.redirect(301, url)
20+
return res.safeRedirect(301, url)
2121
}
2222
}
2323

0 commit comments

Comments
 (0)