Skip to content

Commit 704d0fb

Browse files
committed
fix(@angular/ssr): validate allowed host ports
Require non-default ports to be explicitly allowed so forwarded host headers cannot retarget SSR requests to arbitrary services on an allowlisted hostname.
1 parent deca40b commit 704d0fb

5 files changed

Lines changed: 109 additions & 35 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
*/
2424
export interface AngularAppEngineOptions {
2525
/**
26-
* A set of allowed hostnames for the server application.
26+
* A set of allowed hosts for the server application.
2727
*/
2828
allowedHosts?: readonly string[];
2929

@@ -90,7 +90,7 @@ export class AngularAppEngine {
9090
private readonly manifest = getAngularAppEngineManifest();
9191

9292
/**
93-
* A set of allowed hostnames for the server application.
93+
* A set of allowed hosts for the server application.
9494
*/
9595
private readonly allowedHosts: ReadonlySet<string>;
9696

packages/angular/ssr/src/manifest.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export interface AngularAppEngineManifest {
7575
readonly supportedLocales: Readonly<Record<string, string>>;
7676

7777
/**
78-
* A readonly array of allowed hostnames.
78+
* A readonly array of allowed hosts.
7979
*/
8080
readonly allowedHosts: Readonly<string[]>;
8181
}

packages/angular/ssr/src/utils/validation.ts

Lines changed: 47 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export function getFirstHeaderValue(
6262
* Validates a request.
6363
*
6464
* @param request - The incoming `Request` object to validate.
65-
* @param allowedHosts - A set of allowed hostnames.
65+
* @param allowedHosts - A set of allowed hosts.
6666
* @param disableHostCheck - Whether to disable the host check.
6767
* @throws Error if any of the validated headers contain invalid values.
6868
*/
@@ -71,24 +71,24 @@ export function validateRequest(
7171
allowedHosts: ReadonlySet<string>,
7272
disableHostCheck: boolean,
7373
): void {
74-
validateHeaders(request, allowedHosts, disableHostCheck);
74+
const url = new URL(request.url);
75+
validateHeaders(request, allowedHosts, disableHostCheck, url.protocol);
7576

7677
if (!disableHostCheck) {
77-
validateUrl(new URL(request.url), allowedHosts);
78+
validateUrl(url, allowedHosts);
7879
}
7980
}
8081

8182
/**
82-
* Validates that the hostname of a given URL is allowed.
83+
* Validates that the host of a given URL is allowed.
8384
*
8485
* @param url - The URL object to validate.
85-
* @param allowedHosts - A set of allowed hostnames.
86-
* @throws Error if the hostname is not in the allowlist.
86+
* @param allowedHosts - A set of allowed hosts.
87+
* @throws Error if the host is not in the allowlist.
8788
*/
8889
export function validateUrl(url: URL, allowedHosts: ReadonlySet<string>): void {
89-
const { hostname } = url;
90-
if (!isHostAllowed(hostname, allowedHosts)) {
91-
throw new Error(`URL with hostname "${hostname}" is not allowed.`);
90+
if (!isHostAllowed(url, allowedHosts)) {
91+
throw new Error(`URL with host "${url.host}" is not allowed.`);
9292
}
9393
}
9494

@@ -134,74 +134,94 @@ export function sanitizeRequestHeaders(
134134
*
135135
* @param headerName - The name of the header to validate (e.g., 'host', 'x-forwarded-host').
136136
* @param headerValue - The value of the header to validate.
137-
* @param allowedHosts - A set of allowed hostnames.
137+
* @param allowedHosts - A set of allowed hosts.
138138
* @throws Error if the header value is invalid or the hostname is not in the allowlist.
139139
*/
140140
function verifyHostAllowed(
141141
headerName: string,
142142
headerValue: string,
143143
allowedHosts: ReadonlySet<string>,
144+
protocol: string,
144145
): void {
145-
const url = `http://${headerValue}`;
146+
const url = `${protocol}//${headerValue}`;
146147
if (!URL.canParse(url)) {
147148
throw new Error(`Header "${headerName}" contains an invalid value and cannot be parsed.`);
148149
}
149150

150-
const { hostname, pathname, search, hash, username, password } = new URL(url);
151+
const parsedUrl = new URL(url);
152+
const { pathname, search, hash, username, password } = parsedUrl;
151153
if (pathname !== '/' || search || hash || username || password) {
152154
throw new Error(
153155
`Header "${headerName}" with value "${headerValue}" contains characters that are not allowed.`,
154156
);
155157
}
156158

157-
if (!isHostAllowed(hostname, allowedHosts)) {
159+
if (!isHostAllowed(parsedUrl, allowedHosts)) {
158160
throw new Error(`Header "${headerName}" with value "${headerValue}" is not allowed.`);
159161
}
160162
}
161163

162164
/**
163-
* Checks if the hostname is allowed.
164-
* @param hostname - The hostname to check.
165-
* @param allowedHosts - A set of allowed hostnames.
166-
* @returns `true` if the hostname is allowed, `false` otherwise.
165+
* Checks if the host is allowed.
166+
* @param url - The URL to check.
167+
* @param allowedHosts - A set of allowed hosts.
168+
* @returns `true` if the host is allowed, `false` otherwise.
167169
*/
168-
function isHostAllowed(hostname: string, allowedHosts: ReadonlySet<string>): boolean {
169-
if (allowedHosts.has('*') || allowedHosts.has(hostname)) {
170+
function isHostAllowed(url: URL, allowedHosts: ReadonlySet<string>): boolean {
171+
if (allowedHosts.has('*') || allowedHosts.has(url.host)) {
170172
return true;
171173
}
172174

173175
for (const allowedHost of allowedHosts) {
174-
if (!allowedHost.startsWith('*.')) {
175-
continue;
176-
}
177-
178-
const domain = allowedHost.slice(1);
179-
if (hostname.endsWith(domain)) {
176+
if (isAllowedHostMatch(url, allowedHost)) {
180177
return true;
181178
}
182179
}
183180

184181
return false;
185182
}
186183

184+
function isAllowedHostMatch(url: URL, allowedHost: string): boolean {
185+
const wildcard = allowedHost.startsWith('*.');
186+
const comparableAllowedHost = wildcard ? `placeholder${allowedHost.slice(1)}` : allowedHost;
187+
const allowedUrl = `${url.protocol}//${comparableAllowedHost}`;
188+
189+
if (!URL.canParse(allowedUrl)) {
190+
return false;
191+
}
192+
193+
const parsedAllowedUrl = new URL(allowedUrl);
194+
if (url.port !== parsedAllowedUrl.port) {
195+
return false;
196+
}
197+
198+
if (wildcard) {
199+
const domain = parsedAllowedUrl.hostname.slice('placeholder'.length);
200+
return url.hostname.endsWith(domain);
201+
}
202+
203+
return url.hostname === parsedAllowedUrl.hostname;
204+
}
205+
187206
/**
188207
* Validates the headers of an incoming request.
189208
*
190209
* @param request - The incoming `Request` object containing the headers to validate.
191-
* @param allowedHosts - A set of allowed hostnames.
210+
* @param allowedHosts - A set of allowed hosts.
192211
* @param disableHostCheck - Whether to disable the host check.
193212
* @throws Error if any of the validated headers contain invalid values.
194213
*/
195214
function validateHeaders(
196215
request: Request,
197216
allowedHosts: ReadonlySet<string>,
198217
disableHostCheck: boolean,
218+
protocol: string,
199219
): void {
200220
const headers = request.headers;
201221
for (const headerName of HOST_HEADERS_TO_VALIDATE) {
202222
const headerValue = getFirstHeaderValue(headers.get(headerName));
203223
if (headerValue && !disableHostCheck) {
204-
verifyHostAllowed(headerName, headerValue, allowedHosts);
224+
verifyHostAllowed(headerName, headerValue, allowedHosts, protocol);
205225
}
206226
}
207227

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,9 +351,9 @@ describe('AngularAppEngine', () => {
351351
const response = await appEngine.handle(request);
352352
expect(response).not.toBeNull();
353353
expect(response?.status).toBe(400);
354-
expect(await response?.text()).toContain('URL with hostname "evil.com" is not allowed.');
354+
expect(await response?.text()).toContain('URL with host "evil.com" is not allowed.');
355355
expect(consoleErrorSpy).toHaveBeenCalledWith(
356-
jasmine.stringMatching('URL with hostname "evil.com" is not allowed.'),
356+
jasmine.stringMatching('URL with host "evil.com" is not allowed.'),
357357
);
358358
});
359359

packages/angular/ssr/test/utils/validation_spec.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ describe('Validation Utils', () => {
5050

5151
it('should throw for disallowed hostname', () => {
5252
expect(() => validateUrl(new URL('http://evil.com'), allowedHosts)).toThrowError(
53-
/URL with hostname "evil.com" is not allowed/,
53+
/URL with host "evil.com" is not allowed/,
5454
);
5555
});
5656

@@ -61,7 +61,7 @@ describe('Validation Utils', () => {
6161
it('should not match base domain for wildcard (*.google.com vs google.com)', () => {
6262
// Logic: hostname.endsWith('.google.com') -> 'google.com'.endsWith('.google.com') is false
6363
expect(() => validateUrl(new URL('http://google.com'), allowedHosts)).toThrowError(
64-
/URL with hostname "google.com" is not allowed/,
64+
/URL with host "google.com" is not allowed/,
6565
);
6666
});
6767

@@ -71,6 +71,33 @@ describe('Validation Utils', () => {
7171
expect(() => validateUrl(new URL('http://google.com'), allowedHosts)).not.toThrow();
7272
expect(() => validateUrl(new URL('http://evil.com'), allowedHosts)).not.toThrow();
7373
});
74+
75+
it('should reject arbitrary ports on an allowed hostname', () => {
76+
expect(() => validateUrl(new URL('http://example.com:8080'), allowedHosts)).toThrowError(
77+
/URL with host "example.com:8080" is not allowed/,
78+
);
79+
});
80+
81+
it('should pass for default ports on an allowed hostname', () => {
82+
expect(() => validateUrl(new URL('http://example.com:80'), allowedHosts)).not.toThrow();
83+
expect(() => validateUrl(new URL('https://example.com:443'), allowedHosts)).not.toThrow();
84+
});
85+
86+
it('should pass for explicitly allowed hostname and port', () => {
87+
const allowedHosts = new Set(['example.com:8080']);
88+
expect(() => validateUrl(new URL('http://example.com:8080'), allowedHosts)).not.toThrow();
89+
expect(() => validateUrl(new URL('http://example.com:9090'), allowedHosts)).toThrowError(
90+
/URL with host "example.com:9090" is not allowed/,
91+
);
92+
});
93+
94+
it('should pass for explicitly allowed wildcard hostname and port', () => {
95+
const allowedHosts = new Set(['*.google.com:8443']);
96+
expect(() => validateUrl(new URL('https://foo.google.com:8443'), allowedHosts)).not.toThrow();
97+
expect(() => validateUrl(new URL('https://foo.google.com:9443'), allowedHosts)).toThrowError(
98+
/URL with host "foo.google.com:9443" is not allowed/,
99+
);
100+
});
74101
});
75102

76103
describe('validateRequest', () => {
@@ -97,10 +124,37 @@ describe('Validation Utils', () => {
97124
const req = new Request('http://evil.com');
98125

99126
expect(() => validateRequest(req, allowedHosts, false)).toThrowError(
100-
/URL with hostname "evil.com" is not allowed/,
127+
/URL with host "evil.com" is not allowed/,
101128
);
102129
});
103130

131+
it('should throw if URL port is not explicitly allowed', () => {
132+
const req = new Request('http://example.com:8080');
133+
134+
expect(() => validateRequest(req, allowedHosts, false)).toThrowError(
135+
/URL with host "example.com:8080" is not allowed/,
136+
);
137+
});
138+
139+
it('should throw if x-forwarded-host uses an arbitrary port on an allowed hostname', () => {
140+
const req = new Request('http://example.com:8080', {
141+
headers: { 'x-forwarded-host': 'example.com:8080' },
142+
});
143+
144+
expect(() => validateRequest(req, allowedHosts, false)).toThrowError(
145+
'Header "x-forwarded-host" with value "example.com:8080" is not allowed.',
146+
);
147+
});
148+
149+
it('should pass if x-forwarded-host uses an explicitly allowed port', () => {
150+
const allowedHosts = new Set(['example.com:8080']);
151+
const req = new Request('http://example.com:8080', {
152+
headers: { 'x-forwarded-host': 'example.com:8080' },
153+
});
154+
155+
expect(() => validateRequest(req, allowedHosts, false)).not.toThrow();
156+
});
157+
104158
it('should throw if x-forwarded-port is invalid', () => {
105159
const req = new Request('http://example.com', {
106160
headers: { 'x-forwarded-port': 'abc' },

0 commit comments

Comments
 (0)