Skip to content

Commit 4921c08

Browse files
committed
fix: address PR review feedback
- Fix unreachable secure logic in getRenderingKey by removing default resolver. - Correct JSDocs for host-based cache keys and getSafeHost fallback behavior. - Fix case-sensitivity in TestEngineRunner request mock. - Resolve type errors in default optimization options.
1 parent 490adcf commit 4921c08

3 files changed

Lines changed: 69 additions & 18 deletions

File tree

core-libs/setup/ssr/express-utils/express-request-safe-host.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@
77
import { Request } from 'express';
88

99
/**
10-
* Returns a safe host from the request object.
10+
* Returns a trusted host from the request object.
1111
*
12-
* It checks the `X-Forwarded-Host` header and validates it against the `allowedHosts` list.
13-
* If the header is missing, invalid, or not in the allowlist, it falls back to the `Host` header.
12+
* It first checks the `X-Forwarded-Host` header and returns it only if it is valid
13+
* and matches the `allowedHosts` list. If `X-Forwarded-Host` is missing, invalid,
14+
* or not allowlisted, it then checks the `Host` header and returns it only if it is
15+
* also valid and allowlisted.
16+
*
17+
* If neither header passes validation and allowlist checks, this function returns
18+
* `undefined`, and callers should treat that as "no trusted host".
1419
*
1520
* @param req - Express Request object
1621
* @param allowedHosts - Optional list of allowed hosts
@@ -27,11 +32,7 @@ export function getSafeHost(req: Request, allowedHosts?: string[]): string | und
2732
const normalizedAllowedHosts = allowedHosts?.map(normalize);
2833

2934
const isAllowed = (host: string, normalizedAllowed?: string[]): boolean => {
30-
return (
31-
normalizedAllowed?.some(
32-
(allowed) => host === allowed || host.endsWith(`.${allowed}`)
33-
) ?? false
34-
);
35+
return normalizedAllowed?.some((allowed) => host === allowed) ?? false;
3536
};
3637

3738
const extractFirst = (value?: string | string[]): string | undefined => {

core-libs/setup/ssr/optimized-engine/optimized-ssr-engine.spec.ts

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,11 @@ class TestEngineRunner {
109109
protocol: 'https',
110110
originalUrl: url,
111111
headers: requestHeaders,
112-
get: (header: string): string | string[] | null | undefined => {
113-
return requestHeaders[header];
112+
get: (header: string): any => {
113+
const key = Object.keys(requestHeaders).find(
114+
(k) => k.toLowerCase() === header.toLowerCase()
115+
);
116+
return key ? requestHeaders[key] : undefined;
114117
},
115118
app,
116119
connection: <Partial<Socket>>{},
@@ -645,6 +648,35 @@ describe('OptimizedSsrEngine', () => {
645648
).toHaveBeenCalledWith(`${domain}:${route}`);
646649
}));
647650

651+
it('should use host header when x-forwarded-host is absent and host is allowlisted', fakeAsync(() => {
652+
const domain = 'allowed.com';
653+
const engineRunner = new TestEngineRunner({
654+
timeout: 200,
655+
cache: true,
656+
useHostInCacheKey: true,
657+
allowedHosts: [domain],
658+
});
659+
660+
jest.spyOn(
661+
engineRunner.optimizedSsrEngine as any,
662+
'isConcurrencyLimitExceeded'
663+
);
664+
665+
const route = 'home';
666+
667+
engineRunner.request(route, {
668+
httpHeaders: {
669+
host: domain,
670+
},
671+
});
672+
673+
tick(200);
674+
675+
expect(
676+
engineRunner.optimizedSsrEngine['isConcurrencyLimitExceeded']
677+
).toHaveBeenCalledWith(`${domain}:${route}`);
678+
}));
679+
648680
it('should use only first value from comma-separated x-forwarded-host', fakeAsync(() => {
649681
const engineRunner = new TestEngineRunner({
650682
useHostInCacheKey: true,
@@ -698,7 +730,7 @@ describe('OptimizedSsrEngine', () => {
698730
).toHaveBeenCalledWith(`${route}`);
699731
}));
700732

701-
it('should normalize hosts and support subdomains', fakeAsync(() => {
733+
it('should normalize hosts but require strict matching (no implicit subdomains)', fakeAsync(() => {
702734
const engineRunner = new TestEngineRunner({
703735
useHostInCacheKey: true,
704736
allowedHosts: ['example.com'],
@@ -710,18 +742,31 @@ describe('OptimizedSsrEngine', () => {
710742
);
711743

712744
const route = 'home';
713-
// Test case, port, trailing dot AND subdomain
745+
// Test case, port, and trailing dot with matching host
714746
engineRunner.request(route, {
715747
httpHeaders: {
716-
'x-forwarded-host': 'WWW.EXAMPLE.com:443.',
748+
'x-forwarded-host': 'EXAMPLE.com:443.',
749+
},
750+
});
751+
752+
// Test non-matching subdomain
753+
engineRunner.request(route, {
754+
httpHeaders: {
755+
'x-forwarded-host': 'www.example.com',
717756
},
718757
});
719758

720759
tick(200);
721760

761+
// First one matches because example.com is allowlisted
722762
expect(
723763
engineRunner.optimizedSsrEngine['isConcurrencyLimitExceeded']
724-
).toHaveBeenCalledWith('www.example.com:home');
764+
).toHaveBeenCalledWith('example.com:home');
765+
766+
// Second one falls back to path-only because subdomain is not implicitly allowed anymore
767+
expect(
768+
engineRunner.optimizedSsrEngine['isConcurrencyLimitExceeded']
769+
).toHaveBeenCalledWith('home');
725770
}));
726771

727772
it('should reject invalid hostnames and too long hosts', fakeAsync(() => {

core-libs/setup/ssr/optimized-engine/ssr-optimization-options.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export interface SsrOptimizationOptions {
8888

8989
/**
9090
* Allows overriding default key generator for custom differentiating
91-
* between rendered pages. By default it uses the full request URL.
91+
* between rendered pages. By default, it is host-independent (originalUrl).
9292
*
9393
* @param req
9494
*/
@@ -207,7 +207,7 @@ export interface SsrOptimizationOptions {
207207
/**
208208
* Toggles using the host in the cache key.
209209
*
210-
* By default, the host is not used in the cache key as it can be easily spoofed.
210+
* By default, the cache key is host-independent (originalUrl) as the host header can be spoofed.
211211
* However, some deployments may require host-based cache keys (e.g., multi-domain setups).
212212
*/
213213
useHostInCacheKey?: boolean;
@@ -216,7 +216,7 @@ export interface SsrOptimizationOptions {
216216
* List of allowed hosts for host-based cache keys.
217217
*
218218
* If `useHostInCacheKey` is set to true, only hosts in this list will be used in the cache key.
219-
* If the host is not in this list, it will fallback to the value of the `Host` header.
219+
* Returns undefined if neither `X-Forwarded-Host` nor `Host` matching this list is found (safe fallback).
220220
*/
221221
allowedHosts?: string[];
222222
}
@@ -247,8 +247,12 @@ type DefaultSsrOptimizationOptions = Omit<
247247
Required<SsrOptimizationOptions>,
248248
| 'debug' // debug is deprecated and not used anymore
249249
| 'ttl' // ttl is required but its default value is `undefined`
250+
| 'renderKeyResolver' // renderKeyResolver is optional and defaults to undefined to allow for secure default logic in the engine
251+
| 'allowedHosts' // allowedHosts is optional and defaults to undefined
250252
> & {
251253
ttl: number | undefined; // needed, otherwise we could not set the value `ttl: undefined` value (due to the Required<...>)
254+
renderKeyResolver: ((req: Request) => string) | undefined;
255+
allowedHosts: string[] | undefined;
252256
} & DeepRequired<
253257
// all nested properties of `ssrFeatureToggles` are required too
254258
Pick<
@@ -274,9 +278,10 @@ export const defaultSsrOptimizationOptions: DefaultSsrOptimizationOptions = {
274278
),
275279
logger: new DefaultExpressServerLogger(),
276280
shouldCacheRenderingResult: ({ entry: { err } }) => !err,
277-
renderKeyResolver: getDefaultRenderKey,
281+
renderKeyResolver: undefined,
278282
ssrFeatureToggles: {
279283
limitCacheByMemory: true,
280284
},
281285
useHostInCacheKey: false,
286+
allowedHosts: undefined,
282287
};

0 commit comments

Comments
 (0)