Skip to content

Commit cd81d57

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 cd81d57

3 files changed

Lines changed: 79 additions & 22 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: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,12 @@ 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): string | undefined => {
113+
const key = Object.keys(requestHeaders).find(
114+
(k) => k.toLowerCase() === header.toLowerCase()
115+
);
116+
const value = key ? requestHeaders[key] : undefined;
117+
return Array.isArray(value) ? value.join(', ') : value;
114118
},
115119
app,
116120
connection: <Partial<Socket>>{},
@@ -181,9 +185,14 @@ describe('OptimizedSsrEngine', () => {
181185
renderingStrategyResolver: () => RenderingStrategy.ALWAYS_SSR,
182186
});
183187

184-
expect(consoleLogSpy.mock.lastCall[0]).toContain(
185-
'[spartacus] SSR optimization engine initialized'
186-
);
188+
expect(consoleLogSpy.mock.lastCall).toEqual([
189+
expect.stringContaining(
190+
'[spartacus] SSR optimization engine initialized'
191+
),
192+
expect.objectContaining({
193+
options: expect.any(Object),
194+
}),
195+
]);
187196
});
188197
});
189198

@@ -645,6 +654,35 @@ describe('OptimizedSsrEngine', () => {
645654
).toHaveBeenCalledWith(`${domain}:${route}`);
646655
}));
647656

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

701-
it('should normalize hosts and support subdomains', fakeAsync(() => {
739+
it('should normalize hosts but require strict matching (no implicit subdomains)', fakeAsync(() => {
702740
const engineRunner = new TestEngineRunner({
703741
useHostInCacheKey: true,
704742
allowedHosts: ['example.com'],
@@ -710,18 +748,31 @@ describe('OptimizedSsrEngine', () => {
710748
);
711749

712750
const route = 'home';
713-
// Test case, port, trailing dot AND subdomain
751+
// Test case, port, and trailing dot with matching host
752+
engineRunner.request(route, {
753+
httpHeaders: {
754+
'x-forwarded-host': 'EXAMPLE.com:443.',
755+
},
756+
});
757+
758+
// Test non-matching subdomain
714759
engineRunner.request(route, {
715760
httpHeaders: {
716-
'x-forwarded-host': 'WWW.EXAMPLE.com:443.',
761+
'x-forwarded-host': 'www.example.com',
717762
},
718763
});
719764

720765
tick(200);
721766

767+
// First one matches because example.com is allowlisted
722768
expect(
723769
engineRunner.optimizedSsrEngine['isConcurrencyLimitExceeded']
724-
).toHaveBeenCalledWith('www.example.com:home');
770+
).toHaveBeenCalledWith('example.com:home');
771+
772+
// Second one falls back to path-only because subdomain is not implicitly allowed anymore
773+
expect(
774+
engineRunner.optimizedSsrEngine['isConcurrencyLimitExceeded']
775+
).toHaveBeenCalledWith('home');
725776
}));
726777

727778
it('should reject invalid hostnames and too long hosts', fakeAsync(() => {
@@ -1168,7 +1219,7 @@ describe('OptimizedSsrEngine', () => {
11681219
const differentUrl = 'b';
11691220

11701221
const getRenderingKey = (requestUrlStr: string): string =>
1171-
`https://${host}${requestUrlStr}`;
1222+
`${requestUrlStr}`;
11721223
const getRenderCallbacksCount = (
11731224
engineRunner: TestEngineRunner,
11741225
requestUrlStr: string

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)