Skip to content

Commit 27cd355

Browse files
committed
refactor(@angular/ssr): remove CSR fallback for invalid hosts
Previously, when a request contained an unrecognized host header, the server would fallback to serving the client-side application (CSR) as a temporary migration path. This commit removes this fallback behavior. Requests with invalid or unrecognized host headers will now strictly return a 400 Bad Request response. BREAKING CHANGE: The server no longer falls back to Client-Side Rendering (CSR) when a request fails host validation. Requests with unrecognized 'Host' headers will now return a 400 Bad Request status code. Users must ensure all valid hosts are correctly configured in the 'allowedHosts' option.
1 parent b5868a8 commit 27cd355

File tree

5 files changed

+85
-190
lines changed

5 files changed

+85
-190
lines changed

packages/angular/ssr/node/src/app-engine.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@ export class AngularNodeAppEngine {
6060
* @remarks
6161
* To prevent potential Server-Side Request Forgery (SSRF), this function verifies the hostname
6262
* of the `request.url` against a list of authorized hosts.
63-
* If the hostname is not recognized and `allowedHosts` is not empty, a Client-Side Rendered (CSR) version of the
64-
* page is returned otherwise a 400 Bad Request is returned.
63+
* If the hostname is not recognized a 400 Bad Request is returned.
6564
*
6665
* Resolution:
6766
* Authorize your hostname by configuring `allowedHosts` in `angular.json` in:

packages/angular/ssr/node/src/common-engine/common-engine.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -92,29 +92,12 @@ export class CommonEngine {
9292
try {
9393
validateUrl(urlObj, this.allowedHosts);
9494
} catch (error) {
95-
const isAllowedHostConfigured = this.allowedHosts.size > 0;
9695
// eslint-disable-next-line no-console
9796
console.error(
9897
`ERROR: ${(error as Error).message}` +
9998
'Please provide a list of allowed hosts in the "allowedHosts" option in the "CommonEngine" constructor.',
100-
isAllowedHostConfigured
101-
? ''
102-
: '\nFalling back to client side rendering. This will become a 400 Bad Request in a future major version.',
10399
);
104100

105-
if (!isAllowedHostConfigured) {
106-
// Fallback to CSR to avoid a breaking change.
107-
// TODO(alanagius): Return a 400 and remove this fallback in the next major version (v22).
108-
let document = opts.document;
109-
if (!document && opts.documentFilePath) {
110-
document = opts.document ?? (await this.getDocument(opts.documentFilePath));
111-
}
112-
113-
if (document) {
114-
return document;
115-
}
116-
}
117-
118101
throw error;
119102
}
120103
}

packages/angular/ssr/src/app-engine.ts

Lines changed: 15 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ export class AngularAppEngine {
9595
* @remarks
9696
* To prevent potential Server-Side Request Forgery (SSRF), this function verifies the hostname
9797
* of the `request.url` against a list of authorized hosts.
98-
* If the hostname is not recognized and `allowedHosts` is not empty, a Client-Side Rendered (CSR) version of the
99-
* page is returned otherwise a 400 Bad Request is returned.
98+
* If the hostname is not recognized a 400 Bad Request is returned.
99+
*
100100
* Resolution:
101101
* Authorize your hostname by configuring `allowedHosts` in `angular.json` in:
102102
* `projects.[project-name].architect.build.options.security.allowedHosts`.
@@ -110,7 +110,7 @@ export class AngularAppEngine {
110110
try {
111111
validateRequest(request, allowedHost);
112112
} catch (error) {
113-
return this.handleValidationError(error as Error, request);
113+
return this.handleValidationError(request.url, error as Error);
114114
}
115115

116116
// Clone request with patched headers to prevent unallowed host header access.
@@ -120,7 +120,9 @@ export class AngularAppEngine {
120120
const serverApp = await this.getAngularServerAppForRequest(securedRequest);
121121
if (serverApp) {
122122
return Promise.race([
123-
onHeaderValidationError.then((error) => this.handleValidationError(error, securedRequest)),
123+
onHeaderValidationError.then((error) =>
124+
this.handleValidationError(securedRequest.url, error),
125+
),
124126
serverApp.handle(securedRequest, requestContext),
125127
]);
126128
}
@@ -255,38 +257,23 @@ export class AngularAppEngine {
255257
/**
256258
* Handles validation errors by logging the error and returning an appropriate response.
257259
*
260+
* @param url - The URL of the request.
258261
* @param error - The validation error to handle.
259-
* @param request - The HTTP request that caused the validation error.
260-
* @returns A promise that resolves to a `Response` object with a 400 status code if allowed hosts are configured,
261-
* or `null` if allowed hosts are not configured (in which case the request is served client-side).
262+
* @returns A `Response` object with a 400 status code.
262263
*/
263-
private async handleValidationError(error: Error, request: Request): Promise<Response | null> {
264-
const isAllowedHostConfigured = this.allowedHosts.size > 0;
264+
private handleValidationError(url: string, error: Error): Response {
265265
const errorMessage = error.message;
266-
267266
// eslint-disable-next-line no-console
268267
console.error(
269-
`ERROR: Bad Request ("${request.url}").\n` +
268+
`ERROR: Bad Request ("${url}").\n` +
270269
errorMessage +
271-
(isAllowedHostConfigured
272-
? ''
273-
: '\nFalling back to client side rendering. This will become a 400 Bad Request in a future major version.') +
274270
'\n\nFor more information, see https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf',
275271
);
276272

277-
if (isAllowedHostConfigured) {
278-
// Allowed hosts has been configured incorrectly, thus we can return a 400 bad request.
279-
return new Response(errorMessage, {
280-
status: 400,
281-
statusText: 'Bad Request',
282-
headers: { 'Content-Type': 'text/plain' },
283-
});
284-
}
285-
286-
// Fallback to CSR to avoid a breaking change.
287-
// TODO(alanagius): Return a 400 and remove this fallback in the next major version (v22).
288-
const serverApp = await this.getAngularServerAppForRequest(request);
289-
290-
return serverApp?.serveClientSidePage() ?? null;
273+
return new Response(errorMessage, {
274+
status: 400,
275+
statusText: 'Bad Request',
276+
headers: { 'Content-Type': 'text/plain' },
277+
});
291278
}
292279
}

packages/angular/ssr/src/app.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -484,27 +484,6 @@ export class AngularServerApp {
484484

485485
return html;
486486
}
487-
488-
/**
489-
* Serves the client-side version of the application.
490-
* TODO(alanagius): Remove this method in version 22.
491-
* @internal
492-
*/
493-
async serveClientSidePage(): Promise<Response> {
494-
const {
495-
manifest: { locale },
496-
assets,
497-
} = this;
498-
499-
const html = await assets.getServerAsset('index.csr.html').text();
500-
501-
return new Response(html, {
502-
headers: new Headers({
503-
'Content-Type': 'text/html;charset=UTF-8',
504-
...(locale !== undefined ? { 'Content-Language': locale } : {}),
505-
}),
506-
});
507-
}
508487
}
509488

510489
let angularServerApp: AngularServerApp | undefined;

packages/angular/ssr/test/app-engine_spec.ts

Lines changed: 69 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -290,140 +290,87 @@ describe('AngularAppEngine', () => {
290290
describe('Invalid host headers', () => {
291291
let consoleErrorSpy: jasmine.Spy;
292292

293-
describe('with allowed hosts configured', () => {
294-
beforeAll(() => {
295-
setAngularAppEngineManifest({
296-
allowedHosts: ['example.com'],
297-
entryPoints: {
298-
'': async () => {
299-
setAngularAppTestingManifest(
300-
[{ path: 'home', component: TestHomeComponent }],
301-
[{ path: '**', renderMode: RenderMode.Server }],
302-
);
303-
304-
return {
305-
ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp,
306-
ɵdestroyAngularServerApp: destroyAngularServerApp,
307-
};
308-
},
309-
},
310-
basePath: '/',
311-
supportedLocales: { 'en-US': '' },
312-
});
313-
314-
appEngine = new AngularAppEngine();
315-
});
316-
317-
beforeEach(() => {
318-
consoleErrorSpy = spyOn(console, 'error');
319-
});
320-
321-
it('should return 400 when disallowed host', async () => {
322-
const request = new Request('https://evil.com');
323-
const response = await appEngine.handle(request);
324-
expect(response).not.toBeNull();
325-
expect(response?.status).toBe(400);
326-
expect(await response?.text()).toContain('URL with hostname "evil.com" is not allowed.');
327-
expect(consoleErrorSpy).toHaveBeenCalledWith(
328-
jasmine.stringMatching('URL with hostname "evil.com" is not allowed.'),
329-
);
330-
});
331-
332-
it('should return 400 when disallowed host header', async () => {
333-
const request = new Request('https://example.com/home', {
334-
headers: { 'host': 'evil.com' },
335-
});
336-
const response = await appEngine.handle(request);
337-
expect(response).not.toBeNull();
338-
expect(response?.status).toBe(400);
339-
expect(await response?.text()).toContain(
340-
'Header "host" with value "evil.com" is not allowed.',
341-
);
342-
expect(consoleErrorSpy).toHaveBeenCalledWith(
343-
jasmine.stringMatching('Header "host" with value "evil.com" is not allowed.'),
344-
);
345-
});
293+
beforeAll(() => {
294+
setAngularAppEngineManifest({
295+
allowedHosts: ['example.com'],
296+
entryPoints: {
297+
'': async () => {
298+
setAngularAppTestingManifest(
299+
[{ path: 'home', component: TestHomeComponent }],
300+
[{ path: '**', renderMode: RenderMode.Server }],
301+
);
346302

347-
it('should return 400 when disallowed x-forwarded-host header', async () => {
348-
const request = new Request('https://example.com/home', {
349-
headers: { 'x-forwarded-host': 'evil.com' },
350-
});
351-
const response = await appEngine.handle(request);
352-
expect(response).not.toBeNull();
353-
expect(response?.status).toBe(400);
354-
expect(await response?.text()).toContain(
355-
'Header "x-forwarded-host" with value "evil.com" is not allowed.',
356-
);
357-
expect(consoleErrorSpy).toHaveBeenCalledWith(
358-
jasmine.stringMatching('Header "x-forwarded-host" with value "evil.com" is not allowed.'),
359-
);
303+
return {
304+
ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp,
305+
ɵdestroyAngularServerApp: destroyAngularServerApp,
306+
};
307+
},
308+
},
309+
basePath: '/',
310+
supportedLocales: { 'en-US': '' },
360311
});
361312

362-
it('should return 400 when host with path separator', async () => {
363-
const request = new Request('https://example.com/home', {
364-
headers: { 'host': 'example.com/evil' },
365-
});
366-
const response = await appEngine.handle(request);
367-
expect(response).not.toBeNull();
368-
expect(response?.status).toBe(400);
369-
expect(await response?.text()).toContain(
370-
'Header "host" contains characters that are not allowed.',
371-
);
372-
expect(consoleErrorSpy).toHaveBeenCalledWith(
373-
jasmine.stringMatching('Header "host" contains characters that are not allowed.'),
374-
);
375-
});
313+
appEngine = new AngularAppEngine();
376314
});
377315

378-
describe('without allowed hosts configured', () => {
379-
beforeAll(() => {
380-
setAngularAppEngineManifest({
381-
allowedHosts: [],
382-
entryPoints: {
383-
'': async () => {
384-
setAngularAppTestingManifest(
385-
[{ path: 'home', component: TestHomeComponent }],
386-
[{ path: '**', renderMode: RenderMode.Server }],
387-
);
388-
389-
return {
390-
ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp,
391-
ɵdestroyAngularServerApp: destroyAngularServerApp,
392-
};
393-
},
394-
},
395-
basePath: '/',
396-
supportedLocales: { 'en-US': '' },
397-
});
316+
beforeEach(() => {
317+
consoleErrorSpy = spyOn(console, 'error');
318+
});
398319

399-
appEngine = new AngularAppEngine();
400-
});
320+
it('should return 400 when disallowed host', async () => {
321+
const request = new Request('https://evil.com');
322+
const response = await appEngine.handle(request);
323+
expect(response).not.toBeNull();
324+
expect(response?.status).toBe(400);
325+
expect(await response?.text()).toContain('URL with hostname "evil.com" is not allowed.');
326+
expect(consoleErrorSpy).toHaveBeenCalledWith(
327+
jasmine.stringMatching('URL with hostname "evil.com" is not allowed.'),
328+
);
329+
});
401330

402-
beforeEach(() => {
403-
consoleErrorSpy = spyOn(console, 'error');
331+
it('should return 400 when disallowed host header', async () => {
332+
const request = new Request('https://example.com/home', {
333+
headers: { 'host': 'evil.com' },
404334
});
335+
const response = await appEngine.handle(request);
336+
expect(response).not.toBeNull();
337+
expect(response?.status).toBe(400);
338+
expect(await response?.text()).toContain(
339+
'Header "host" with value "evil.com" is not allowed.',
340+
);
341+
expect(consoleErrorSpy).toHaveBeenCalledWith(
342+
jasmine.stringMatching('Header "host" with value "evil.com" is not allowed.'),
343+
);
344+
});
405345

406-
it('should log error and fallback to CSR when disallowed host', async () => {
407-
const request = new Request('https://example.com');
408-
const response = await appEngine.handle(request);
409-
expect(response).not.toBeNull();
410-
expect(await response?.text()).toContain('<title>CSR page</title>');
411-
expect(consoleErrorSpy).toHaveBeenCalledWith(
412-
jasmine.stringMatching('URL with hostname "example.com" is not allowed.'),
413-
);
346+
it('should return 400 when disallowed x-forwarded-host header', async () => {
347+
const request = new Request('https://example.com/home', {
348+
headers: { 'x-forwarded-host': 'evil.com' },
414349
});
350+
const response = await appEngine.handle(request);
351+
expect(response).not.toBeNull();
352+
expect(response?.status).toBe(400);
353+
expect(await response?.text()).toContain(
354+
'Header "x-forwarded-host" with value "evil.com" is not allowed.',
355+
);
356+
expect(consoleErrorSpy).toHaveBeenCalledWith(
357+
jasmine.stringMatching('Header "x-forwarded-host" with value "evil.com" is not allowed.'),
358+
);
359+
});
415360

416-
it('should log error and fallback to CSR when host with path separator', async () => {
417-
const request = new Request('https://example.com/home', {
418-
headers: { 'host': 'example.com/evil' },
419-
});
420-
const response = await appEngine.handle(request);
421-
expect(response).not.toBeNull();
422-
expect(await response?.text()).toContain('<title>CSR page</title>');
423-
expect(consoleErrorSpy).toHaveBeenCalledWith(
424-
jasmine.stringMatching('Header "host" contains characters that are not allowed.'),
425-
);
361+
it('should return 400 when host with path separator', async () => {
362+
const request = new Request('https://example.com/home', {
363+
headers: { 'host': 'example.com/evil' },
426364
});
365+
const response = await appEngine.handle(request);
366+
expect(response).not.toBeNull();
367+
expect(response?.status).toBe(400);
368+
expect(await response?.text()).toContain(
369+
'Header "host" contains characters that are not allowed.',
370+
);
371+
expect(consoleErrorSpy).toHaveBeenCalledWith(
372+
jasmine.stringMatching('Header "host" contains characters that are not allowed.'),
373+
);
427374
});
428375
});
429376
});

0 commit comments

Comments
 (0)