Skip to content

Commit ebba697

Browse files
committed
fix: prevent open redirect in redirect back via referrer host validation
Add config-driven security controls for redirect back. The referrer URL is now validated against the request Host header and a configurable allowedHosts list. Introduce a getPreviousUrl helper and expose it on both the Request and Redirect classes. The Redirect class now extends Macroable so getPreviousUrl can be overridden for custom resolution (e.g. session-based). Also add config options for forwardQueryString default and a withQs(boolean) overload.
1 parent 3e6ffad commit ebba697

File tree

8 files changed

+165
-24
lines changed

8 files changed

+165
-24
lines changed

factories/response.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export class HttpResponseFactory {
4545
etag: false,
4646
serializeJSON: safeStringify,
4747
jsonpCallbackName: 'callback',
48+
redirect: {
49+
allowedHosts: [] as string[],
50+
forwardQueryString: false,
51+
},
4852
cookie: {
4953
maxAge: 90,
5054
path: '/',

src/define_config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ export function defineConfig(config: UserDefinedServerConfig): ServerConfig {
5757
useAsyncLocalStorage: false,
5858
etag: false,
5959
jsonpCallbackName: 'callback',
60+
redirect: {
61+
allowedHosts: [] as string[],
62+
forwardQueryString: false,
63+
},
6064
cookie: {
6165
maxAge: '2h',
6266
path: '/',

src/helpers.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* file that was distributed with this source code.
88
*/
99

10+
import type { IncomingHttpHeaders } from 'node:http'
1011
import { serialize } from 'cookie-es'
1112
// @ts-expect-error
1213
import matchit from '@poppinss/matchit'
@@ -30,6 +31,49 @@ import {
3031

3132
export { createURL }
3233

34+
/**
35+
* Returns the previous URL from the request's `Referer` header,
36+
* validated against the request's `Host` header and an optional
37+
* list of allowed hosts.
38+
*
39+
* The referrer is accepted when its host matches the request's
40+
* `Host` header or is listed in `allowedHosts`. Otherwise the
41+
* `fallback` value is returned.
42+
*
43+
* @param headers - The incoming request headers
44+
* @param allowedHosts - Array of allowed referrer hosts
45+
* @param fallback - URL to return when referrer is missing or invalid
46+
*/
47+
export function getPreviousUrl(
48+
headers: IncomingHttpHeaders,
49+
allowedHosts: string[],
50+
fallback: string
51+
): string {
52+
let referrer = headers['referer'] || headers['referrer']
53+
if (!referrer) {
54+
return fallback
55+
}
56+
57+
if (Array.isArray(referrer)) {
58+
referrer = referrer[0]
59+
}
60+
61+
try {
62+
const parsed = new URL(referrer)
63+
const host = headers['host']
64+
if (host && parsed.host === host) {
65+
return referrer
66+
}
67+
if (allowedHosts.length > 0 && allowedHosts.includes(parsed.host)) {
68+
return referrer
69+
}
70+
} catch {
71+
// malformed URL
72+
}
73+
74+
return fallback
75+
}
76+
3377
/**
3478
* This function is similar to the intrinsic function encodeURI. However, it will not encode:
3579
* - The \, ^, or | characters

src/redirect.ts

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ import type { IncomingMessage } from 'node:http'
1111

1212
import debug from './debug.ts'
1313
import type { Qs } from './qs.ts'
14-
import { encodeUrl } from './helpers.ts'
14+
import { encodeUrl, getPreviousUrl } from './helpers.ts'
1515
import type { Router } from './router/main.ts'
1616
import type { HttpResponse } from './response.ts'
17+
import type { ResponseConfig } from './types/response.ts'
1718
import type {
1819
RoutesList,
1920
LookupList,
@@ -22,6 +23,7 @@ import type {
2223
RouteBuilderArguments,
2324
} from './types/url_builder.ts'
2425
import { safeDecodeURI } from './utils.ts'
26+
import Macroable from '@poppinss/macroable'
2527

2628
/**
2729
* Provides a fluent API for constructing HTTP redirect responses.
@@ -45,7 +47,13 @@ import { safeDecodeURI } from './utils.ts'
4547
* .toPath('/dashboard')
4648
* ```
4749
*/
48-
export class Redirect {
50+
export class Redirect extends Macroable {
51+
/**
52+
* Array of allowed hosts for referrer-based redirects.
53+
* When empty, only the request's own host is allowed.
54+
*/
55+
allowedHosts: string[]
56+
4957
/**
5058
* Flag indicating whether to forward the existing query string from the current request
5159
*/
@@ -87,12 +95,22 @@ export class Redirect {
8795
* @param response - AdonisJS response instance
8896
* @param router - AdonisJS router instance
8997
* @param qs - Query string parser instance
98+
* @param config - Redirect configuration
9099
*/
91-
constructor(request: IncomingMessage, response: HttpResponse, router: Router, qs: Qs) {
100+
constructor(
101+
request: IncomingMessage,
102+
response: HttpResponse,
103+
router: Router,
104+
qs: Qs,
105+
config: ResponseConfig['redirect']
106+
) {
107+
super()
92108
this.#request = request
93109
this.#response = response
94110
this.#router = router
95111
this.#qs = qs
112+
this.allowedHosts = config.allowedHosts
113+
this.#forwardQueryString = config.forwardQueryString
96114
}
97115

98116
/**
@@ -113,12 +131,17 @@ export class Redirect {
113131
}
114132

115133
/**
116-
* Extracts and returns the referrer URL from request headers
117-
* @returns {string} The referrer URL or '/' if not found
134+
* Returns the previous URL for redirect back. By default reads
135+
* the `Referer` header and validates the host.
136+
*
137+
* Since `Redirect` extends `Macroable`, this method can be overridden
138+
* to implement custom logic such as session-based previous URL
139+
* resolution.
140+
*
141+
* @param fallback - URL to return when no valid previous URL is found
118142
*/
119-
#getReferrerUrl(): string {
120-
let url = this.#request.headers['referer'] || this.#request.headers['referrer'] || '/'
121-
return Array.isArray(url) ? url[0] : url
143+
getPreviousUrl(fallback: string): string {
144+
return getPreviousUrl(this.#request.headers, this.allowedHosts, fallback)
122145
}
123146

124147
/**
@@ -157,6 +180,23 @@ export class Redirect {
157180
* ```
158181
*/
159182
withQs(): this
183+
/**
184+
* Enables or disables query string forwarding from the current request.
185+
*
186+
* Use this overload to explicitly control query string forwarding,
187+
* especially useful when `forwardQueryString` is enabled by default
188+
* in the redirect config and you want to disable it for a specific redirect.
189+
*
190+
* @param forward - Whether to forward the query string
191+
* @returns The Redirect instance for method chaining
192+
*
193+
* @example
194+
* ```ts
195+
* // Disable query string forwarding for this redirect
196+
* response.redirect().withQs(false).toPath('/dashboard')
197+
* ```
198+
*/
199+
withQs(forward: boolean): this
160200
/**
161201
* Adds multiple query string parameters to the redirect URL
162202
*
@@ -190,12 +230,17 @@ export class Redirect {
190230
* ```
191231
*/
192232
withQs(name: string, value: any): this
193-
withQs(name?: Record<string, any> | string, value?: any): this {
233+
withQs(name?: Record<string, any> | string | boolean, value?: any): this {
194234
if (typeof name === 'undefined') {
195235
this.#forwardQueryString = true
196236
return this
197237
}
198238

239+
if (typeof name === 'boolean') {
240+
this.#forwardQueryString = name
241+
return this
242+
}
243+
199244
if (typeof name === 'string') {
200245
this.#queryString[name] = value
201246
return this
@@ -206,20 +251,21 @@ export class Redirect {
206251
}
207252

208253
/**
209-
* Redirects to the previous path using the Referer header
210-
* Falls back to '/' if no referrer is found
254+
* Redirects to the previous URL resolved via `getPreviousUrl`.
255+
*
256+
* @param fallback - URL to redirect to when no valid previous URL is found
211257
*/
212-
back() {
258+
back(fallback: string = '/') {
213259
let query: Record<string, any> = {}
214260

215-
const referrerUrl = this.#getReferrerUrl()
216-
const url = safeDecodeURI(referrerUrl, false)
261+
const previousUrl = this.getPreviousUrl(fallback)
262+
const url = safeDecodeURI(previousUrl, false)
217263

218-
debug('referrer url "%s"', referrerUrl)
219-
debug('referrer base url "%s"', url.pathname)
264+
debug('previous url "%s"', previousUrl)
265+
debug('previous base url "%s"', url.pathname)
220266

221267
/**
222-
* Parse query string from the referrer url
268+
* Parse query string from the previous url
223269
*/
224270
if (this.#forwardQueryString) {
225271
query = this.#qs.parse(url.query || '')

src/request.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { type ServerResponse, type IncomingMessage, type IncomingHttpHeaders } f
2121

2222
import type { Qs } from './qs.ts'
2323
import { CookieParser } from './cookies/parser.ts'
24+
import { getPreviousUrl } from './helpers.ts'
2425
import { safeDecodeURI, trustProxy } from './utils.ts'
2526
import { type RequestConfig } from './types/request.ts'
2627
import type { HttpContext } from './http_context/main.ts'
@@ -697,6 +698,21 @@ export class HttpRequest extends Macroable {
697698
return `${protocol}://${hostname}${this.url(includeQueryString)}`
698699
}
699700

701+
/**
702+
* Returns the previous URL from the `Referer` header, validated against
703+
* the request's `Host` header and an optional list of allowed hosts.
704+
*
705+
* The referrer is accepted when its host matches the request's `Host`
706+
* header or is listed in `allowedHosts`. Otherwise the `fallback`
707+
* value is returned.
708+
*
709+
* @param allowedHosts - Array of allowed referrer hosts
710+
* @param fallback - URL to return when referrer is missing or invalid
711+
*/
712+
getPreviousUrl(allowedHosts: string[], fallback: string = '/'): string {
713+
return getPreviousUrl(this.request.headers, allowedHosts, fallback)
714+
}
715+
700716
/**
701717
* Find if the current HTTP request is for the given route or the routes
702718
* @param routeIdentifier - Route name, pattern, or handler reference to match

src/response.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1038,7 +1038,7 @@ export class HttpResponse extends Macroable {
10381038
forwardQueryString: boolean = false,
10391039
statusCode: number = ResponseStatus.Found
10401040
): Redirect | void {
1041-
const handler = new Redirect(this.request, this, this.#router, this.#qs)
1041+
const handler = new Redirect(this.request, this, this.#router, this.#qs, this.#config.redirect)
10421042

10431043
if (forwardQueryString) {
10441044
handler.withQs()

src/types/response.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,27 @@ export type ResponseConfig = {
6767
* Default options to apply when setting cookies
6868
*/
6969
cookie: Partial<CookieOptions>
70+
71+
/**
72+
* Configuration for HTTP redirects
73+
*/
74+
redirect: {
75+
/**
76+
* Array of allowed hosts for referrer-based redirects.
77+
* When empty, only the request's own host is allowed.
78+
*
79+
* Defaults to []
80+
*/
81+
allowedHosts: string[]
82+
83+
/**
84+
* Whether to forward the query string from the current request
85+
* by default on redirects.
86+
*
87+
* Defaults to false
88+
*/
89+
forwardQueryString: boolean
90+
}
7091
}
7192

7293
/**

tests/redirect.spec.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,8 @@ test.group('Redirect', () => {
140140
response.finish()
141141
})
142142

143-
const { header } = await supertest(url).get('/').set('referrer', '/foo').redirects(1)
144-
assert.equal(header.location, '/foo')
143+
const { header } = await supertest(url).get('/').set('referrer', `${url}/foo`).redirects(1)
144+
assert.equal(header.location, `${url}/foo`)
145145
})
146146

147147
test('redirect back to referrer with existing query string', async ({ assert }) => {
@@ -152,9 +152,12 @@ test.group('Redirect', () => {
152152
response.finish()
153153
})
154154

155-
const { header } = await supertest(url).get('/').set('referrer', '/foo?name=virk').redirects(1)
155+
const { header } = await supertest(url)
156+
.get('/')
157+
.set('referrer', `${url}/foo?name=virk`)
158+
.redirects(1)
156159

157-
assert.equal(header.location, '/foo?name=virk')
160+
assert.equal(header.location, `${url}/foo?name=virk`)
158161
})
159162

160163
test('redirect back to referrer with query string', async ({ assert }) => {
@@ -165,9 +168,12 @@ test.group('Redirect', () => {
165168
response.finish()
166169
})
167170

168-
const { header } = await supertest(url).get('/').set('referer', '/foo?name=virk').redirects(1)
171+
const { header } = await supertest(url)
172+
.get('/')
173+
.set('referer', `${url}/foo?name=virk`)
174+
.redirects(1)
169175

170-
assert.equal(header.location, '/foo?name=virk')
176+
assert.equal(header.location, `${url}/foo?name=virk`)
171177
})
172178

173179
test('redirect back to root (/) when referrer header is not set', async ({ assert }) => {

0 commit comments

Comments
 (0)