Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions packages/angular/ssr/node/src/app-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@ export class AngularNodeAppEngine {
* @remarks
* To prevent potential Server-Side Request Forgery (SSRF), this function verifies the hostname
* of the `request.url` against a list of authorized hosts.
* If the hostname is not recognized and `allowedHosts` is not empty, a Client-Side Rendered (CSR) version of the
* page is returned otherwise a 400 Bad Request is returned.
* If the hostname is not recognized a 400 Bad Request is returned.
*
* Resolution:
* Authorize your hostname by configuring `allowedHosts` in `angular.json` in:
Expand Down
17 changes: 0 additions & 17 deletions packages/angular/ssr/node/src/common-engine/common-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,29 +92,12 @@ export class CommonEngine {
try {
validateUrl(urlObj, this.allowedHosts);
} catch (error) {
const isAllowedHostConfigured = this.allowedHosts.size > 0;
// eslint-disable-next-line no-console
console.error(
`ERROR: ${(error as Error).message}` +
'Please provide a list of allowed hosts in the "allowedHosts" option in the "CommonEngine" constructor.',
isAllowedHostConfigured
? ''
: '\nFalling back to client side rendering. This will become a 400 Bad Request in a future major version.',
);

if (!isAllowedHostConfigured) {
// Fallback to CSR to avoid a breaking change.
// TODO(alanagius): Return a 400 and remove this fallback in the next major version (v22).
let document = opts.document;
if (!document && opts.documentFilePath) {
document = opts.document ?? (await this.getDocument(opts.documentFilePath));
}

if (document) {
return document;
}
}

throw error;
}
}
Expand Down
43 changes: 15 additions & 28 deletions packages/angular/ssr/src/app-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ export class AngularAppEngine {
* @remarks
* To prevent potential Server-Side Request Forgery (SSRF), this function verifies the hostname
* of the `request.url` against a list of authorized hosts.
* If the hostname is not recognized and `allowedHosts` is not empty, a Client-Side Rendered (CSR) version of the
* page is returned otherwise a 400 Bad Request is returned.
* If the hostname is not recognized a 400 Bad Request is returned.
*
* Resolution:
* Authorize your hostname by configuring `allowedHosts` in `angular.json` in:
* `projects.[project-name].architect.build.options.security.allowedHosts`.
Expand All @@ -110,7 +110,7 @@ export class AngularAppEngine {
try {
validateRequest(request, allowedHost);
} catch (error) {
return this.handleValidationError(error as Error, request);
return this.handleValidationError(request.url, error as Error);
}

// Clone request with patched headers to prevent unallowed host header access.
Expand All @@ -120,7 +120,9 @@ export class AngularAppEngine {
const serverApp = await this.getAngularServerAppForRequest(securedRequest);
if (serverApp) {
return Promise.race([
onHeaderValidationError.then((error) => this.handleValidationError(error, securedRequest)),
onHeaderValidationError.then((error) =>
this.handleValidationError(securedRequest.url, error),
),
serverApp.handle(securedRequest, requestContext),
]);
}
Expand Down Expand Up @@ -255,38 +257,23 @@ export class AngularAppEngine {
/**
* Handles validation errors by logging the error and returning an appropriate response.
*
* @param url - The URL of the request.
* @param error - The validation error to handle.
* @param request - The HTTP request that caused the validation error.
* @returns A promise that resolves to a `Response` object with a 400 status code if allowed hosts are configured,
* or `null` if allowed hosts are not configured (in which case the request is served client-side).
* @returns A `Response` object with a 400 status code.
*/
private async handleValidationError(error: Error, request: Request): Promise<Response | null> {
const isAllowedHostConfigured = this.allowedHosts.size > 0;
private handleValidationError(url: string, error: Error): Response {
const errorMessage = error.message;

// eslint-disable-next-line no-console
console.error(
`ERROR: Bad Request ("${request.url}").\n` +
`ERROR: Bad Request ("${url}").\n` +
errorMessage +
(isAllowedHostConfigured
? ''
: '\nFalling back to client side rendering. This will become a 400 Bad Request in a future major version.') +
'\n\nFor more information, see https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf',
);

if (isAllowedHostConfigured) {
// Allowed hosts has been configured incorrectly, thus we can return a 400 bad request.
return new Response(errorMessage, {
status: 400,
statusText: 'Bad Request',
headers: { 'Content-Type': 'text/plain' },
});
}

// Fallback to CSR to avoid a breaking change.
// TODO(alanagius): Return a 400 and remove this fallback in the next major version (v22).
const serverApp = await this.getAngularServerAppForRequest(request);

return serverApp?.serveClientSidePage() ?? null;
return new Response(errorMessage, {
status: 400,
statusText: 'Bad Request',
headers: { 'Content-Type': 'text/plain' },
});
}
}
21 changes: 0 additions & 21 deletions packages/angular/ssr/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,27 +484,6 @@ export class AngularServerApp {

return html;
}

/**
* Serves the client-side version of the application.
* TODO(alanagius): Remove this method in version 22.
* @internal
*/
async serveClientSidePage(): Promise<Response> {
const {
manifest: { locale },
assets,
} = this;

const html = await assets.getServerAsset('index.csr.html').text();

return new Response(html, {
headers: new Headers({
'Content-Type': 'text/html;charset=UTF-8',
...(locale !== undefined ? { 'Content-Language': locale } : {}),
}),
});
}
}

let angularServerApp: AngularServerApp | undefined;
Expand Down
191 changes: 69 additions & 122 deletions packages/angular/ssr/test/app-engine_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,140 +290,87 @@ describe('AngularAppEngine', () => {
describe('Invalid host headers', () => {
let consoleErrorSpy: jasmine.Spy;

describe('with allowed hosts configured', () => {
beforeAll(() => {
setAngularAppEngineManifest({
allowedHosts: ['example.com'],
entryPoints: {
'': async () => {
setAngularAppTestingManifest(
[{ path: 'home', component: TestHomeComponent }],
[{ path: '**', renderMode: RenderMode.Server }],
);

return {
ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp,
ɵdestroyAngularServerApp: destroyAngularServerApp,
};
},
},
basePath: '/',
supportedLocales: { 'en-US': '' },
});

appEngine = new AngularAppEngine();
});

beforeEach(() => {
consoleErrorSpy = spyOn(console, 'error');
});

it('should return 400 when disallowed host', async () => {
const request = new Request('https://evil.com');
const response = await appEngine.handle(request);
expect(response).not.toBeNull();
expect(response?.status).toBe(400);
expect(await response?.text()).toContain('URL with hostname "evil.com" is not allowed.');
expect(consoleErrorSpy).toHaveBeenCalledWith(
jasmine.stringMatching('URL with hostname "evil.com" is not allowed.'),
);
});

it('should return 400 when disallowed host header', async () => {
const request = new Request('https://example.com/home', {
headers: { 'host': 'evil.com' },
});
const response = await appEngine.handle(request);
expect(response).not.toBeNull();
expect(response?.status).toBe(400);
expect(await response?.text()).toContain(
'Header "host" with value "evil.com" is not allowed.',
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
jasmine.stringMatching('Header "host" with value "evil.com" is not allowed.'),
);
});
beforeAll(() => {
setAngularAppEngineManifest({
allowedHosts: ['example.com'],
entryPoints: {
'': async () => {
setAngularAppTestingManifest(
[{ path: 'home', component: TestHomeComponent }],
[{ path: '**', renderMode: RenderMode.Server }],
);

it('should return 400 when disallowed x-forwarded-host header', async () => {
const request = new Request('https://example.com/home', {
headers: { 'x-forwarded-host': 'evil.com' },
});
const response = await appEngine.handle(request);
expect(response).not.toBeNull();
expect(response?.status).toBe(400);
expect(await response?.text()).toContain(
'Header "x-forwarded-host" with value "evil.com" is not allowed.',
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
jasmine.stringMatching('Header "x-forwarded-host" with value "evil.com" is not allowed.'),
);
return {
ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp,
ɵdestroyAngularServerApp: destroyAngularServerApp,
};
},
},
basePath: '/',
supportedLocales: { 'en-US': '' },
});

it('should return 400 when host with path separator', async () => {
const request = new Request('https://example.com/home', {
headers: { 'host': 'example.com/evil' },
});
const response = await appEngine.handle(request);
expect(response).not.toBeNull();
expect(response?.status).toBe(400);
expect(await response?.text()).toContain(
'Header "host" contains characters that are not allowed.',
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
jasmine.stringMatching('Header "host" contains characters that are not allowed.'),
);
});
appEngine = new AngularAppEngine();
});

describe('without allowed hosts configured', () => {
beforeAll(() => {
setAngularAppEngineManifest({
allowedHosts: [],
entryPoints: {
'': async () => {
setAngularAppTestingManifest(
[{ path: 'home', component: TestHomeComponent }],
[{ path: '**', renderMode: RenderMode.Server }],
);

return {
ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp,
ɵdestroyAngularServerApp: destroyAngularServerApp,
};
},
},
basePath: '/',
supportedLocales: { 'en-US': '' },
});
beforeEach(() => {
consoleErrorSpy = spyOn(console, 'error');
});

appEngine = new AngularAppEngine();
});
it('should return 400 when disallowed host', async () => {
const request = new Request('https://evil.com');
const response = await appEngine.handle(request);
expect(response).not.toBeNull();
expect(response?.status).toBe(400);
expect(await response?.text()).toContain('URL with hostname "evil.com" is not allowed.');
expect(consoleErrorSpy).toHaveBeenCalledWith(
jasmine.stringMatching('URL with hostname "evil.com" is not allowed.'),
);
});

beforeEach(() => {
consoleErrorSpy = spyOn(console, 'error');
it('should return 400 when disallowed host header', async () => {
const request = new Request('https://example.com/home', {
headers: { 'host': 'evil.com' },
});
const response = await appEngine.handle(request);
expect(response).not.toBeNull();
expect(response?.status).toBe(400);
expect(await response?.text()).toContain(
'Header "host" with value "evil.com" is not allowed.',
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
jasmine.stringMatching('Header "host" with value "evil.com" is not allowed.'),
);
});

it('should log error and fallback to CSR when disallowed host', async () => {
const request = new Request('https://example.com');
const response = await appEngine.handle(request);
expect(response).not.toBeNull();
expect(await response?.text()).toContain('<title>CSR page</title>');
expect(consoleErrorSpy).toHaveBeenCalledWith(
jasmine.stringMatching('URL with hostname "example.com" is not allowed.'),
);
it('should return 400 when disallowed x-forwarded-host header', async () => {
const request = new Request('https://example.com/home', {
headers: { 'x-forwarded-host': 'evil.com' },
});
const response = await appEngine.handle(request);
expect(response).not.toBeNull();
expect(response?.status).toBe(400);
expect(await response?.text()).toContain(
'Header "x-forwarded-host" with value "evil.com" is not allowed.',
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
jasmine.stringMatching('Header "x-forwarded-host" with value "evil.com" is not allowed.'),
);
});

it('should log error and fallback to CSR when host with path separator', async () => {
const request = new Request('https://example.com/home', {
headers: { 'host': 'example.com/evil' },
});
const response = await appEngine.handle(request);
expect(response).not.toBeNull();
expect(await response?.text()).toContain('<title>CSR page</title>');
expect(consoleErrorSpy).toHaveBeenCalledWith(
jasmine.stringMatching('Header "host" contains characters that are not allowed.'),
);
it('should return 400 when host with path separator', async () => {
const request = new Request('https://example.com/home', {
headers: { 'host': 'example.com/evil' },
});
const response = await appEngine.handle(request);
expect(response).not.toBeNull();
expect(response?.status).toBe(400);
expect(await response?.text()).toContain(
'Header "host" contains characters that are not allowed.',
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
jasmine.stringMatching('Header "host" contains characters that are not allowed.'),
);
});
});
});