Skip to content

Commit 2a48c44

Browse files
committed
fixup! fix(@angular/ssr): validate host headers to prevent header-based SSRF
1 parent 0be2667 commit 2a48c44

File tree

5 files changed

+92
-39
lines changed

5 files changed

+92
-39
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/plat
1212
import * as fs from 'node:fs';
1313
import { dirname, join, normalize, resolve } from 'node:path';
1414
import { URL } from 'node:url';
15-
import { isHostAllowed } from '../../../src/utils/headers';
15+
import { isHostAllowed } from '../../../src/utils/validation';
1616
import { attachNodeGlobalErrorHandlers } from '../errors';
1717
import { CommonEngineInlineCriticalCssProcessor } from './inline-css-processor';
1818
import {

packages/angular/ssr/node/src/request.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import type { IncomingHttpHeaders, IncomingMessage } from 'node:http';
1010
import type { Http2ServerRequest } from 'node:http2';
11-
import { getFirstHeaderValue } from '../../src/utils/headers';
11+
import { getFirstHeaderValue } from '../../src/utils/validation';
1212

1313
/**
1414
* A set containing all the pseudo-headers defined in the HTTP/2 specification.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import type { AngularServerApp, getOrCreateAngularServerApp } from './app';
1010
import { Hooks } from './hooks';
1111
import { getPotentialLocaleIdFromUrl, getPreferredLocale } from './i18n';
1212
import { EntryPointExports, getAngularAppEngineManifest } from './manifest';
13-
import { validateHeaders } from './utils/headers';
1413
import { joinUrlParts } from './utils/url';
14+
import { validateRequest } from './utils/validation';
1515

1616
/**
1717
* Angular server application engine.
@@ -80,7 +80,7 @@ export class AngularAppEngine {
8080
*/
8181
async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
8282
try {
83-
validateHeaders(request, this.allowedHosts);
83+
validateRequest(request, this.allowedHosts);
8484
} catch (error) {
8585
const body = error instanceof Error ? error.message : undefined;
8686

packages/angular/ssr/src/utils/headers.ts renamed to packages/angular/ssr/src/utils/validation.ts

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,29 +53,35 @@ export function getFirstHeaderValue(
5353
}
5454

5555
/**
56-
* Validates the headers of an incoming request.
57-
*
58-
* This function checks for the validity of critical headers such as `x-forwarded-host`,
59-
* `host`, `x-forwarded-port`, and `x-forwarded-proto`.
60-
* It ensures that the hostnames match the allowed hosts and that ports and protocols adhere to expected formats.
56+
* Validates a request.
6157
*
62-
* @param request - The incoming `Request` object containing the headers to validate.
58+
* @param request - The incoming `Request` object to validate.
6359
* @param allowedHosts - A set of allowed hostnames.
6460
* @throws Error if any of the validated headers contain invalid values.
6561
*/
66-
export function validateHeaders(request: Request, allowedHosts: ReadonlySet<string>): void {
67-
const headers = request.headers;
68-
validateHost('x-forwarded-host', headers, allowedHosts);
69-
validateHost('host', headers, allowedHosts);
62+
export function validateRequest(request: Request, allowedHosts: ReadonlySet<string>): void {
63+
validateHeaders(request, allowedHosts);
64+
validateUrl(new URL(request.url), allowedHosts);
65+
}
7066

71-
const xForwardedPort = getFirstHeaderValue(headers.get('x-forwarded-port'));
72-
if (xForwardedPort && !VALID_PORT_REGEX.test(xForwardedPort)) {
73-
throw new Error('Header "x-forwarded-port" must be a numeric value.');
74-
}
67+
/**
68+
* Validates that the hostname of a given URL is allowed.
69+
*
70+
* @param url - The URL object to validate.
71+
* @param allowedHosts - A set of allowed hostnames.
72+
* @throws Error if the hostname is not in the allowlist.
73+
*/
74+
export function validateUrl(url: URL, allowedHosts: ReadonlySet<string>): void {
75+
if (!isHostAllowed(url.hostname, allowedHosts)) {
76+
let errorMessage = `URL with hostname "${url.hostname}" is not allowed.`;
77+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
78+
errorMessage +=
79+
'\n\nAction Required: Update your "angular.json" to include this hostname. ' +
80+
'Path: "projects.[project-name].architect.build.options.security.allowedHosts".' +
81+
'\n\nFor more information, see https://angular.dev/guide/ssr#configuring-allowed-hosts';
82+
}
7583

76-
const xForwardedProto = getFirstHeaderValue(headers.get('x-forwarded-proto'));
77-
if (xForwardedProto && !VALID_PROTO_REGEX.test(xForwardedProto)) {
78-
throw new Error('Header "x-forwarded-proto" must be either "http" or "https".');
84+
throw new Error(errorMessage);
7985
}
8086
}
8187

@@ -87,7 +93,7 @@ export function validateHeaders(request: Request, allowedHosts: ReadonlySet<stri
8793
* @param allowedHosts - A set of allowed hostnames.
8894
* @throws Error if the header value is invalid or the hostname is not in the allowlist.
8995
*/
90-
function validateHost(
96+
function validateHostHeaders(
9197
headerName: string,
9298
headers: Headers,
9399
allowedHosts: ReadonlySet<string>,
@@ -113,7 +119,8 @@ function validateHost(
113119
if (typeof ngDevMode === 'undefined' || ngDevMode) {
114120
errorMessage +=
115121
'\n\nAction Required: Update your "angular.json" to include this hostname. ' +
116-
'Path: "projects.[project-name].architect.build.options.security.allowedHosts".';
122+
'Path: "projects.[project-name].architect.build.options.security.allowedHosts".' +
123+
'\n\nFor more information, see https://angular.dev/guide/ssr#configuring-allowed-hosts';
117124
}
118125

119126
throw new Error(errorMessage);
@@ -157,3 +164,30 @@ function checkWildcardHostnames(hostname: string, allowedHosts: ReadonlySet<stri
157164

158165
return false;
159166
}
167+
168+
/**
169+
* Validates the headers of an incoming request.
170+
*
171+
* This function checks for the validity of critical headers such as `x-forwarded-host`,
172+
* `host`, `x-forwarded-port`, and `x-forwarded-proto`.
173+
* It ensures that the hostnames match the allowed hosts and that ports and protocols adhere to expected formats.
174+
*
175+
* @param request - The incoming `Request` object containing the headers to validate.
176+
* @param allowedHosts - A set of allowed hostnames.
177+
* @throws Error if any of the validated headers contain invalid values.
178+
*/
179+
function validateHeaders(request: Request, allowedHosts: ReadonlySet<string>): void {
180+
const headers = request.headers;
181+
validateHostHeaders('x-forwarded-host', headers, allowedHosts);
182+
validateHostHeaders('host', headers, allowedHosts);
183+
184+
const xForwardedPort = getFirstHeaderValue(headers.get('x-forwarded-port'));
185+
if (xForwardedPort && !VALID_PORT_REGEX.test(xForwardedPort)) {
186+
throw new Error('Header "x-forwarded-port" must be a numeric value.');
187+
}
188+
189+
const xForwardedProto = getFirstHeaderValue(headers.get('x-forwarded-proto'));
190+
if (xForwardedProto && !VALID_PROTO_REGEX.test(xForwardedProto)) {
191+
throw new Error('Header "x-forwarded-proto" must be either "http" or "https".');
192+
}
193+
}

packages/angular/ssr/test/utils/headers_spec.ts renamed to packages/angular/ssr/test/utils/validation_spec.ts

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { validateHeaders } from '../../src/utils/headers';
9+
import { validateRequest, validateUrl } from '../../src/utils/validation';
1010

11-
describe('validateHeaders', () => {
11+
describe('validateRequest', () => {
1212
const allowedHosts = new Set(['example.com', 'sub.example.com']);
1313

1414
it('should pass valid headers with allowed host', () => {
@@ -21,7 +21,7 @@ describe('validateHeaders', () => {
2121
},
2222
});
2323

24-
expect(() => validateHeaders(request, allowedHosts)).not.toThrow();
24+
expect(() => validateRequest(request, allowedHosts)).not.toThrow();
2525
});
2626

2727
it('should pass valid headers with localhost (default allowed)', () => {
@@ -31,7 +31,7 @@ describe('validateHeaders', () => {
3131
},
3232
});
3333

34-
expect(() => validateHeaders(request, allowedHosts)).not.toThrow();
34+
expect(() => validateRequest(request, allowedHosts)).not.toThrow();
3535
});
3636

3737
it('should throw error for disallowed host', () => {
@@ -41,11 +41,13 @@ describe('validateHeaders', () => {
4141
},
4242
});
4343

44-
expect(() => validateHeaders(request, allowedHosts)).toThrowError(
44+
expect(() => validateRequest(request, allowedHosts)).toThrowError(
4545
/Header "host" with value "evil\.com" is not allowed/,
4646
);
4747
});
4848

49+
// ...
50+
4951
it('should throw error for disallowed x-forwarded-host', () => {
5052
const request = new Request('https://example.com', {
5153
headers: {
@@ -54,7 +56,7 @@ describe('validateHeaders', () => {
5456
},
5557
});
5658

57-
expect(() => validateHeaders(request, allowedHosts)).toThrowError(
59+
expect(() => validateRequest(request, allowedHosts)).toThrowError(
5860
/Header "x-forwarded-host" with value "evil\.com" is not allowed/,
5961
);
6062
});
@@ -67,7 +69,7 @@ describe('validateHeaders', () => {
6769
},
6870
});
6971

70-
expect(() => validateHeaders(request, allowedHosts)).toThrowError(
72+
expect(() => validateRequest(request, allowedHosts)).toThrowError(
7173
'Header "x-forwarded-host" contains path separators which is not allowed.',
7274
);
7375
});
@@ -80,7 +82,7 @@ describe('validateHeaders', () => {
8082
},
8183
});
8284

83-
expect(() => validateHeaders(request, allowedHosts)).toThrowError(
85+
expect(() => validateRequest(request, allowedHosts)).toThrowError(
8486
'Header "x-forwarded-port" must be a numeric value.',
8587
);
8688
});
@@ -93,7 +95,7 @@ describe('validateHeaders', () => {
9395
},
9496
});
9597

96-
expect(() => validateHeaders(request, allowedHosts)).toThrowError(
98+
expect(() => validateRequest(request, allowedHosts)).toThrowError(
9799
'Header "x-forwarded-proto" must be either "http" or "https".',
98100
);
99101
});
@@ -106,7 +108,7 @@ describe('validateHeaders', () => {
106108
},
107109
});
108110

109-
expect(() => validateHeaders(request, allowedHosts)).not.toThrow();
111+
expect(() => validateRequest(request, allowedHosts)).not.toThrow();
110112
});
111113

112114
it('should ignore port in host validation', () => {
@@ -116,7 +118,7 @@ describe('validateHeaders', () => {
116118
},
117119
});
118120

119-
expect(() => validateHeaders(request, allowedHosts)).not.toThrow();
121+
expect(() => validateRequest(request, allowedHosts)).not.toThrow();
120122
});
121123

122124
it('should throw if host header is completely malformed url', () => {
@@ -126,7 +128,7 @@ describe('validateHeaders', () => {
126128
},
127129
});
128130

129-
expect(() => validateHeaders(request, allowedHosts)).toThrowError(
131+
expect(() => validateRequest(request, allowedHosts)).toThrowError(
130132
'Header "host" contains an invalid value.',
131133
);
132134
});
@@ -141,7 +143,7 @@ describe('validateHeaders', () => {
141143
},
142144
});
143145

144-
expect(() => validateHeaders(request, wildcardHosts)).not.toThrow();
146+
expect(() => validateRequest(request, wildcardHosts)).not.toThrow();
145147
});
146148

147149
it('should match nested subdomain', () => {
@@ -151,7 +153,7 @@ describe('validateHeaders', () => {
151153
},
152154
});
153155

154-
expect(() => validateHeaders(request, wildcardHosts)).not.toThrow();
156+
expect(() => validateRequest(request, wildcardHosts)).not.toThrow();
155157
});
156158

157159
it('should not match base domain', () => {
@@ -161,7 +163,7 @@ describe('validateHeaders', () => {
161163
},
162164
});
163165

164-
expect(() => validateHeaders(request, wildcardHosts)).toThrowError(
166+
expect(() => validateRequest(request, wildcardHosts)).toThrowError(
165167
/Header "host" with value "example\.com" is not allowed/,
166168
);
167169
});
@@ -173,9 +175,26 @@ describe('validateHeaders', () => {
173175
},
174176
});
175177

176-
expect(() => validateHeaders(request, wildcardHosts)).toThrowError(
178+
expect(() => validateRequest(request, wildcardHosts)).toThrowError(
177179
/Header "host" with value "evil\.com" is not allowed/,
178180
);
179181
});
180182
});
183+
184+
it('should pass valid URL with allowed host', () => {
185+
const request = new Request('https://example.com/path');
186+
expect(() => validateRequest(request, allowedHosts)).not.toThrow();
187+
});
188+
189+
it('should pass valid URL with allowed sub-domain', () => {
190+
const request = new Request('https://sub.example.com/path');
191+
expect(() => validateRequest(request, allowedHosts)).not.toThrow();
192+
});
193+
194+
it('should throw error for disallowed host', () => {
195+
const request = new Request('https://evil.com/path');
196+
expect(() => validateRequest(request, allowedHosts)).toThrowError(
197+
/URL with hostname "evil\.com" is not allowed/,
198+
);
199+
});
181200
});

0 commit comments

Comments
 (0)