Skip to content

Commit ac3599e

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 ac3599e

3 files changed

Lines changed: 97 additions & 34 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: 79 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ class TestEngineRunner {
9595
}
9696
): TestEngineRunner {
9797
const responseHeaders: { [key: string]: string } = {};
98-
const requestHeaders = params?.httpHeaders ?? { host };
98+
const requestHeaders: IncomingHttpHeaders = {
99+
host,
100+
...(params?.httpHeaders || {}),
101+
};
99102
/** used when resolving getRequestUrl() and getRequestOrigin() */
100103
const app = <Partial<Application>>{
101104
get:
@@ -109,8 +112,12 @@ class TestEngineRunner {
109112
protocol: 'https',
110113
originalUrl: url,
111114
headers: requestHeaders,
112-
get: (header: string): string | string[] | null | undefined => {
113-
return requestHeaders[header];
115+
get: (header: string): string | undefined => {
116+
const key = Object.keys(requestHeaders).find(
117+
(k) => k.toLowerCase() === header.toLowerCase()
118+
);
119+
const value = key ? requestHeaders[key] : undefined;
120+
return Array.isArray(value) ? value.join(', ') : value;
114121
},
115122
app,
116123
connection: <Partial<Socket>>{},
@@ -181,9 +188,12 @@ describe('OptimizedSsrEngine', () => {
181188
renderingStrategyResolver: () => RenderingStrategy.ALWAYS_SSR,
182189
});
183190

184-
expect(consoleLogSpy.mock.lastCall[0]).toContain(
185-
'[spartacus] SSR optimization engine initialized'
186-
);
191+
expect(consoleLogSpy.mock.lastCall).toEqual([
192+
'[spartacus] SSR optimization engine initialized',
193+
expect.objectContaining({
194+
options: expect.any(Object),
195+
}),
196+
]);
187197
});
188198
});
189199

@@ -573,7 +583,7 @@ describe('OptimizedSsrEngine', () => {
573583

574584
expect(
575585
engineRunner.optimizedSsrEngine['isConcurrencyLimitExceeded']
576-
).toHaveBeenCalledWith(`https://${host}${route}`);
586+
).toHaveBeenCalledWith(`${route}`);
577587
}));
578588

579589
it('should NOT use the x-forwarded-host header by default to prevent cache poisoning', fakeAsync(() => {
@@ -645,6 +655,35 @@ describe('OptimizedSsrEngine', () => {
645655
).toHaveBeenCalledWith(`${domain}:${route}`);
646656
}));
647657

658+
it('should use host header when x-forwarded-host is absent and host is allowlisted', fakeAsync(() => {
659+
const domain = 'allowed.com';
660+
const engineRunner = new TestEngineRunner({
661+
timeout: 200,
662+
cache: true,
663+
useHostInCacheKey: true,
664+
allowedHosts: [domain],
665+
});
666+
667+
jest.spyOn(
668+
engineRunner.optimizedSsrEngine as any,
669+
'isConcurrencyLimitExceeded'
670+
);
671+
672+
const route = 'home';
673+
674+
engineRunner.request(route, {
675+
httpHeaders: {
676+
host: domain,
677+
},
678+
});
679+
680+
tick(200);
681+
682+
expect(
683+
engineRunner.optimizedSsrEngine['isConcurrencyLimitExceeded']
684+
).toHaveBeenCalledWith(`${domain}:${route}`);
685+
}));
686+
648687
it('should use only first value from comma-separated x-forwarded-host', fakeAsync(() => {
649688
const engineRunner = new TestEngineRunner({
650689
useHostInCacheKey: true,
@@ -698,7 +737,7 @@ describe('OptimizedSsrEngine', () => {
698737
).toHaveBeenCalledWith(`${route}`);
699738
}));
700739

701-
it('should normalize hosts and support subdomains', fakeAsync(() => {
740+
it('should normalize hosts but require strict matching (no implicit subdomains)', fakeAsync(() => {
702741
const engineRunner = new TestEngineRunner({
703742
useHostInCacheKey: true,
704743
allowedHosts: ['example.com'],
@@ -710,18 +749,31 @@ describe('OptimizedSsrEngine', () => {
710749
);
711750

712751
const route = 'home';
713-
// Test case, port, trailing dot AND subdomain
752+
// Test case, port, and trailing dot with matching host
753+
engineRunner.request(route, {
754+
httpHeaders: {
755+
'x-forwarded-host': 'EXAMPLE.com:443.',
756+
},
757+
});
758+
759+
// Test non-matching subdomain
714760
engineRunner.request(route, {
715761
httpHeaders: {
716-
'x-forwarded-host': 'WWW.EXAMPLE.com:443.',
762+
'x-forwarded-host': 'www.example.com',
717763
},
718764
});
719765

720766
tick(200);
721767

768+
// First one matches because example.com is allowlisted
769+
expect(
770+
engineRunner.optimizedSsrEngine['isConcurrencyLimitExceeded']
771+
).toHaveBeenCalledWith('example.com:home');
772+
773+
// Second one falls back to path-only because subdomain is not implicitly allowed anymore
722774
expect(
723775
engineRunner.optimizedSsrEngine['isConcurrencyLimitExceeded']
724-
).toHaveBeenCalledWith('www.example.com:home');
776+
).toHaveBeenCalledWith('home');
725777
}));
726778

727779
it('should reject invalid hostnames and too long hosts', fakeAsync(() => {
@@ -749,13 +801,15 @@ describe('OptimizedSsrEngine', () => {
749801

750802
tick(200);
751803

752-
// Both should fallback to path only key
753-
expect(
754-
engineRunner.optimizedSsrEngine['isConcurrencyLimitExceeded']
755-
).toHaveBeenCalledWith(route);
756-
expect(
757-
engineRunner.optimizedSsrEngine['isConcurrencyLimitExceeded']
758-
).toHaveBeenCalledTimes(2);
804+
const calls = (
805+
engineRunner.optimizedSsrEngine[
806+
'isConcurrencyLimitExceeded'
807+
] as jest.Mock
808+
).mock.calls;
809+
810+
calls.forEach((call) => {
811+
expect(call[0]).toBe(route);
812+
});
759813
}));
760814

761815
it('should not generate different keys for many attacker-controlled hosts (prevents cache explosion)', fakeAsync(() => {
@@ -1168,7 +1222,7 @@ describe('OptimizedSsrEngine', () => {
11681222
const differentUrl = 'b';
11691223

11701224
const getRenderingKey = (requestUrlStr: string): string =>
1171-
`https://${host}${requestUrlStr}`;
1225+
`${requestUrlStr}`;
11721226
const getRenderCallbacksCount = (
11731227
engineRunner: TestEngineRunner,
11741228
requestUrlStr: string
@@ -1595,9 +1649,12 @@ describe('OptimizedSsrEngine', () => {
15951649
new TestEngineRunner({
15961650
logger: new MockExpressServerLogger() as ExpressServerLogger,
15971651
});
1598-
expect(consoleLogSpy.mock.lastCall[0]).toEqual(
1599-
'[spartacus] SSR optimization engine initialized'
1600-
);
1652+
expect(consoleLogSpy.mock.lastCall).toEqual([
1653+
'[spartacus] SSR optimization engine initialized',
1654+
expect.objectContaining({
1655+
options: expect.any(Object),
1656+
}),
1657+
]);
16011658
});
16021659
});
16031660
});

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)