Skip to content

Commit 82a8b75

Browse files
committed
fixup! fix(@angular/build): escape prerender redirect URLs
1 parent 979da8a commit 82a8b75

4 files changed

Lines changed: 2 additions & 185 deletions

File tree

‎packages/angular/ssr/src/routes/ng-routes.ts‎

Lines changed: 0 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -676,40 +676,6 @@ function invalidRedirectError(routePath: string, reason: string): string {
676676
return `The 'redirectTo' value for the '${stripLeadingSlash(routePath)}' route is invalid: ${reason}`;
677677
}
678678

679-
/**
680-
* Validates and normalizes the `Location` header of a server route configuration.
681-
*
682-
* A configured `Location` header turns the route into a redirect, both at runtime and in the static
683-
* redirect page generated when prerendering, so its value is validated as a redirect target. HTTP
684-
* header names are case-insensitive, so every entry which names the `Location` header is checked.
685-
*
686-
* @param headers - The headers configured for the route.
687-
* @returns The headers with each `Location` value normalized, or the reason one was rejected.
688-
*/
689-
function normalizeLocationHeaders(
690-
headers: Record<string, string>,
691-
): { headers: Record<string, string>; error?: undefined } | { headers?: undefined; error: string } {
692-
let normalized: Record<string, string> | undefined;
693-
694-
for (const [name, value] of Object.entries(headers)) {
695-
if (name.toLowerCase() !== 'location') {
696-
continue;
697-
}
698-
699-
const { url, error } = normalizeAndValidateRedirect(value);
700-
if (error !== undefined) {
701-
return { error };
702-
}
703-
704-
// Store the normalized value so that the header sent at runtime and the generated static
705-
// redirect page are the value which was validated.
706-
normalized ??= { ...headers };
707-
normalized[name] = url;
708-
}
709-
710-
return { headers: normalized ?? headers };
711-
}
712-
713679
/**
714680
* Replaces the route parameters, such as `:id`, in the path of `redirectTo`.
715681
*
@@ -792,18 +758,6 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi
792758
continue;
793759
}
794760

795-
if (metadata.headers) {
796-
const { headers, error } = normalizeLocationHeaders(metadata.headers);
797-
if (error !== undefined) {
798-
errors.push(
799-
`Invalid '${path}' route configuration: the 'headers.Location' value is invalid: ${error}`,
800-
);
801-
continue;
802-
}
803-
804-
metadata.headers = headers;
805-
}
806-
807761
serverConfigRouteTree.insert(path, metadata);
808762
}
809763

‎packages/angular/ssr/src/utils/redirect.ts‎

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -238,18 +238,11 @@ export function normalizeAndValidateRedirect(target: string): NormalizedUrl {
238238
/**
239239
* Creates an HTTP redirect response with a specified location and status code.
240240
*
241-
* This is the only place where a `Location` header is set, so the target is normalized and
242-
* validated here rather than only where it is configured. A location is not always a configured
243-
* value which route extraction has already checked: it can be produced by a guard at render time,
244-
* and it can be composed from the untrusted `X-Forwarded-Prefix` request header. The normalized
245-
* value is the one sent, so that the emitted header and the value which was checked are the same.
246-
*
247241
* @param location - The URL to which the response should redirect.
248242
* @param status - The HTTP status code for the redirection. Defaults to 302 (Found).
249243
* See: https://developer.mozilla.org/en-US/docs/Web/API/Response/redirect_static#status
250244
* @param headers - Additional headers to include in the response.
251245
* @returns A `Response` object representing the HTTP redirect.
252-
* @throws If `location` is not a supported redirect target.
253246
*/
254247
export function createRedirectResponse(
255248
location: string,
@@ -263,16 +256,11 @@ export function createRedirectResponse(
263256
);
264257
}
265258

266-
const { url: normalizedLocation, error } = normalizeAndValidateRedirect(location);
267-
if (error !== undefined) {
268-
throw new Error(`Cannot redirect to '${location}': ${error}`);
269-
}
270-
271259
const resHeaders = headers instanceof Headers ? headers : new Headers(headers);
272260
if (ngDevMode && resHeaders.has('location')) {
273261
// eslint-disable-next-line no-console
274262
console.warn(
275-
`Location header "${resHeaders.get('location')}" will be ignored and set to "${normalizedLocation}".`,
263+
`Location header "${resHeaders.get('location')}" will be ignored and set to "${location}".`,
276264
);
277265
}
278266

@@ -288,7 +276,7 @@ export function createRedirectResponse(
288276
}
289277

290278
resHeaders.set('Vary', [...varySet].join(', '));
291-
resHeaders.set('Location', normalizedLocation);
279+
resHeaders.set('Location', location);
292280

293281
return new Response(null, {
294282
status,

‎packages/angular/ssr/test/routes/ng-routes_spec.ts‎

Lines changed: 0 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -176,108 +176,6 @@ describe('extractRoutesAndCreateRouteTree', () => {
176176
);
177177
});
178178

179-
it("should error when a server route 'headers.Location' uses an unsupported protocol", async () => {
180-
setAngularAppTestingManifest(
181-
[{ path: 'external', component: DummyComponent }],
182-
[
183-
{
184-
path: 'external',
185-
renderMode: RenderMode.Prerender,
186-
headers: { Location: 'javascript:alert(1)' },
187-
},
188-
{ path: '**', renderMode: RenderMode.Server },
189-
],
190-
);
191-
192-
const { errors } = await extractRoutesAndCreateRouteTree({ url });
193-
expect(errors[0]).toContain(
194-
`Invalid 'external' route configuration: the 'headers.Location' value is invalid: ` +
195-
`the 'javascript:' protocol is not supported as a redirect target.`,
196-
);
197-
});
198-
199-
it("should error when a server route 'headers.Location' contains a backslash", async () => {
200-
setAngularAppTestingManifest(
201-
[{ path: 'external', component: DummyComponent }],
202-
[
203-
{
204-
path: 'external',
205-
renderMode: RenderMode.Prerender,
206-
headers: { Location: '/\\evil.example' },
207-
},
208-
{ path: '**', renderMode: RenderMode.Server },
209-
],
210-
);
211-
212-
const { errors } = await extractRoutesAndCreateRouteTree({ url });
213-
expect(errors[0]).toContain(
214-
`Invalid 'external' route configuration: the 'headers.Location' value is invalid: ` +
215-
`'/\\evil.example' is not a valid same-origin path.`,
216-
);
217-
});
218-
219-
it("should error when a server route 'headers.Location' resolves into a protocol-relative path", async () => {
220-
setAngularAppTestingManifest(
221-
[{ path: 'external', component: DummyComponent }],
222-
[
223-
{
224-
path: 'external',
225-
renderMode: RenderMode.Server,
226-
status: 302,
227-
headers: { Location: '/..//evil.example' },
228-
},
229-
{ path: '**', renderMode: RenderMode.Server },
230-
],
231-
);
232-
233-
const { errors } = await extractRoutesAndCreateRouteTree({ url });
234-
expect(errors[0]).toContain(
235-
`Invalid 'external' route configuration: the 'headers.Location' value is invalid: ` +
236-
`'/..//evil.example' is not a valid same-origin path. ` +
237-
`It resolves to the protocol-relative path '//evil.example'.`,
238-
);
239-
});
240-
241-
it("should error when a server route 'headers.Location' uses an unsupported protocol regardless of the header casing", async () => {
242-
setAngularAppTestingManifest(
243-
[{ path: 'external', component: DummyComponent }],
244-
[
245-
{
246-
path: 'external',
247-
renderMode: RenderMode.Prerender,
248-
headers: { LoCaTiOn: 'javascript:alert(1)' },
249-
},
250-
{ path: '**', renderMode: RenderMode.Server },
251-
],
252-
);
253-
254-
const { errors } = await extractRoutesAndCreateRouteTree({ url });
255-
expect(errors[0]).toContain(
256-
`Invalid 'external' route configuration: the 'headers.Location' value is invalid: ` +
257-
`the 'javascript:' protocol is not supported as a redirect target.`,
258-
);
259-
});
260-
261-
it("should store the normalized value of a server route 'headers.Location'", async () => {
262-
setAngularAppTestingManifest(
263-
[{ path: 'external', component: DummyComponent }],
264-
[
265-
{
266-
path: 'external',
267-
renderMode: RenderMode.Server,
268-
headers: { location: 'HTTPS://Example.com/A b' },
269-
},
270-
{ path: '**', renderMode: RenderMode.Server },
271-
],
272-
);
273-
274-
const { routeTree, errors } = await extractRoutesAndCreateRouteTree({ url });
275-
expect(errors).toHaveSize(0);
276-
expect(routeTree.match('/external')?.headers).toEqual({
277-
location: 'https://example.com/A%20b',
278-
});
279-
});
280-
281179
it("should error when 'getPrerenderParams' produces a segment which decodes to a separator", async () => {
282180
setAngularAppTestingManifest(
283181
[{ path: 'store/:slug', component: DummyComponent }],

‎packages/angular/ssr/test/utils/redirect_spec.ts‎

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -67,29 +67,6 @@ describe('Redirect Utils', () => {
6767
/Invalid redirect status code: 200/,
6868
);
6969
});
70-
71-
it('should throw for a location which uses an unsupported protocol', () => {
72-
// A `Location` can also be produced at render time, for example by a guard which returns a
73-
// `RedirectCommand`, so it cannot rely on the validation performed during route extraction.
74-
expect(() => createRedirectResponse('javascript:alert(1)')).toThrowError(
75-
/Cannot redirect to 'javascript:alert\(1\)': the 'javascript:' protocol is not supported/,
76-
);
77-
});
78-
79-
it('should throw for a location which an untrusted proxy prefix made protocol-relative', () => {
80-
// 'X-Forwarded-Prefix' is a request header, so a location built from it is untrusted. The
81-
// URL parser reads a leading '/\\' as an authority, which would redirect off-origin.
82-
for (const location of ['/\\evil.example/home', '//evil.example/home']) {
83-
expect(() => createRedirectResponse(location))
84-
.withContext(location)
85-
.toThrowError(/is not a valid same-origin path/);
86-
}
87-
});
88-
89-
it('should send the normalized location', () => {
90-
const response = createRedirectResponse('/docs/../about');
91-
expect(response.headers.get('Location')).toBe('/about');
92-
});
9370
});
9471

9572
describe('normalizeAndValidateRoutePath', () => {

0 commit comments

Comments
 (0)