Skip to content

Commit 2f2d6a7

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

File tree

5 files changed

+134
-17
lines changed

5 files changed

+134
-17
lines changed

packages/angular/ssr/BUILD.bazel

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ ts_project(
2020
),
2121
args = [
2222
"--lib",
23-
"dom,es2022",
23+
"dom.iterable,dom,es2022",
2424
],
2525
data = [
2626
"//packages/angular/ssr/third_party/beasties:beasties_bundled",

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export class CommonEngine {
9999
'Please provide a list of allowed hosts in the "allowedHosts" option in the "CommonEngine" constructor.',
100100
isAllowedHostConfigured
101101
? ''
102-
: '\nFallbacking to client side rendering. This will become a 400 Bad Request in a future major version.',
102+
: '\nFalling back to client side rendering. This will become a 400 Bad Request in a future major version.',
103103
);
104104

105105
if (!isAllowedHostConfigured) {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ export class AngularAppEngine {
270270
errorMessage +
271271
(isAllowedHostConfigured
272272
? ''
273-
: '\nFallbacking to client side rendering. This will become a 400 Bad Request in a future major version.') +
273+
: '\nFalling back to client side rendering. This will become a 400 Bad Request in a future major version.') +
274274
'\n\nFor more information, see https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf',
275275
);
276276

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

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -93,32 +93,88 @@ export function cloneRequestAndPatchHeaders(
9393
});
9494

9595
const headers = clonedReq.headers;
96-
const originalHeadersGet = headers.get;
9796

97+
const originalGet = headers.get;
9898
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
99-
(headers.get as typeof originalHeadersGet) = (name: string): string | null => {
100-
const value = originalHeadersGet.call(headers, name);
99+
(headers.get as typeof originalGet) = function (name) {
100+
const value = originalGet.call(headers, name);
101101
if (!value) {
102102
return value;
103103
}
104104

105-
const key = name.toLowerCase();
106-
if (HOST_HEADERS_TO_VALIDATE.has(key)) {
107-
try {
108-
verifyHostAllowed(key, value, allowedHosts);
109-
} catch (error) {
110-
onError(error as Error);
105+
validateHeader(name, value, allowedHosts, onError);
111106

112-
return null;
113-
}
107+
return value;
108+
};
109+
110+
const originalValues = headers.values;
111+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
112+
(headers.values as typeof originalValues) = function () {
113+
for (const name of HOST_HEADERS_TO_VALIDATE) {
114+
validateHeader(name, originalGet.call(headers, name), allowedHosts, onError);
114115
}
115116

116-
return value;
117+
return originalValues.call(headers);
118+
};
119+
120+
const originalEntries = headers.entries;
121+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
122+
(headers.entries as typeof originalEntries) = function () {
123+
const iterator = originalEntries.call(headers);
124+
125+
return {
126+
next() {
127+
const result = iterator.next();
128+
if (!result.done) {
129+
const [key, value] = result.value;
130+
validateHeader(key, value, allowedHosts, onError);
131+
}
132+
133+
return result;
134+
},
135+
[Symbol.iterator]() {
136+
return this;
137+
},
138+
};
117139
};
118140

141+
// Ensure for...of loops use the new patched entries
142+
(headers[Symbol.iterator] as typeof originalEntries) = headers.entries;
143+
119144
return { request: clonedReq, onError: onErrorPromise };
120145
}
121146

147+
/**
148+
* Validates a specific header value against the allowed hosts.
149+
* @param name - The name of the header to validate.
150+
* @param value - The value of the header to validate.
151+
* @param allowedHosts - A set of allowed hostnames.
152+
* @param onError - A callback function to call if the header value is invalid.
153+
* @throws Error if the header value is invalid.
154+
*/
155+
function validateHeader(
156+
name: string,
157+
value: string | null,
158+
allowedHosts: ReadonlySet<string>,
159+
onError: (value: Error) => void,
160+
): void {
161+
if (!value) {
162+
return;
163+
}
164+
165+
if (!HOST_HEADERS_TO_VALIDATE.has(name.toLowerCase())) {
166+
return;
167+
}
168+
169+
try {
170+
verifyHostAllowed(name, value, allowedHosts);
171+
} catch (error) {
172+
onError(error as Error);
173+
174+
throw error;
175+
}
176+
}
177+
122178
/**
123179
* Validates a specific host header value against the allowed hosts.
124180
*

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

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ describe('Validation Utils', () => {
135135
});
136136
const { request: secured, onError } = cloneRequestAndPatchHeaders(req, allowedHosts);
137137

138-
expect(secured.headers.get('host')).toBeNull();
138+
expect(() => secured.headers.get('host')).toThrowError(
139+
'Header "host" with value "evil.com" is not allowed.',
140+
);
139141
await expectAsync(onError).toBeResolvedTo(
140142
jasmine.objectContaining({
141143
message: jasmine.stringMatching('Header "host" with value "evil.com" is not allowed'),
@@ -157,7 +159,9 @@ describe('Validation Utils', () => {
157159
});
158160
const { request: secured, onError } = cloneRequestAndPatchHeaders(req, allowedHosts);
159161

160-
expect(secured.headers.get('x-forwarded-host')).toBeNull();
162+
expect(() => secured.headers.get('x-forwarded-host')).toThrowError(
163+
'Header "x-forwarded-host" with value "evil.com" is not allowed.',
164+
);
161165
await expectAsync(onError).toBeResolvedTo(
162166
jasmine.objectContaining({
163167
message: jasmine.stringMatching(
@@ -175,5 +179,62 @@ describe('Validation Utils', () => {
175179

176180
expect(secured.headers.get('accept')).toBe('application/json');
177181
});
182+
183+
it('should validate headers when iterating with entries()', async () => {
184+
const req = new Request('http://example.com', {
185+
headers: { 'host': 'evil.com' },
186+
});
187+
const { request: secured, onError } = cloneRequestAndPatchHeaders(req, allowedHosts);
188+
189+
expect(() => {
190+
for (const _ of secured.headers.entries()) {
191+
// access the header to trigger the validation
192+
}
193+
}).toThrowError('Header "host" with value "evil.com" is not allowed.');
194+
195+
await expectAsync(onError).toBeResolvedTo(
196+
jasmine.objectContaining({
197+
message: jasmine.stringMatching('Header "host" with value "evil.com" is not allowed.'),
198+
}),
199+
);
200+
});
201+
202+
it('should validate headers when iterating with values()', async () => {
203+
const req = new Request('http://example.com', {
204+
headers: { 'host': 'evil.com' },
205+
});
206+
const { request: secured, onError } = cloneRequestAndPatchHeaders(req, allowedHosts);
207+
208+
expect(() => {
209+
for (const _ of secured.headers.values()) {
210+
// access the header to trigger the validation
211+
}
212+
}).toThrowError('Header "host" with value "evil.com" is not allowed.');
213+
214+
await expectAsync(onError).toBeResolvedTo(
215+
jasmine.objectContaining({
216+
message: jasmine.stringMatching('Header "host" with value "evil.com" is not allowed.'),
217+
}),
218+
);
219+
});
220+
221+
it('should validate headers when iterating with for...of', async () => {
222+
const req = new Request('http://example.com', {
223+
headers: { 'host': 'evil.com' },
224+
});
225+
const { request: secured, onError } = cloneRequestAndPatchHeaders(req, allowedHosts);
226+
227+
expect(() => {
228+
for (const _ of secured.headers) {
229+
// access the header to trigger the validation
230+
}
231+
}).toThrowError('Header "host" with value "evil.com" is not allowed.');
232+
233+
await expectAsync(onError).toBeResolvedTo(
234+
jasmine.objectContaining({
235+
message: jasmine.stringMatching('Header "host" with value "evil.com" is not allowed.'),
236+
}),
237+
);
238+
});
178239
});
179240
});

0 commit comments

Comments
 (0)