Skip to content

Commit 6558c9f

Browse files
committed
feat: add support for forwarding proxy headers in template requests
- Updated `fetchTemplate` to include forwarded headers when fetching templates. - Enhanced `wildcardRequestHandlerFactory` to forward template proxy headers. - Modified `filterHeaders` to allow extra headers to be forwarded based on configuration. - Introduced database migrations for new settings: `FragmentProxyHeaders` and `TemplateProxyHeaders`. - Removed deprecated `fetch-template.js` and `filter-headers.js` files, refactoring their logic into new modules.
1 parent 54f0163 commit 6558c9f

26 files changed

+522
-286
lines changed

docs/registry.md

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ Currently, Registry supports authentication only. All authenticated entities wil
1010

1111
The following authentication providers are supported:
1212

13-
- **OpenID Connect**. Turned **off** by default.
14-
- **Locally configured login/password**. Default credentials: `root` / `pwd`.
15-
- **Locally configured Bearer token** for API machine-to-machine access. Default credentials: `Bearer cm9vdF9hcGlfdG9rZW4=:dG9rZW5fc2VjcmV0` or `Bearer root_api_token:token_secret` after base64 decoding.
13+
- **OpenID Connect**. Turned **off** by default.
14+
- **Locally configured login/password**. Default credentials: `root` / `pwd`.
15+
- **Locally configured Bearer token** for API machine-to-machine access. Default credentials: `Bearer cm9vdF9hcGlfdG9rZW4=:dG9rZW5fc2VjcmV0` or `Bearer root_api_token:token_secret` after base64 decoding.
1616

1717
You can change default credentials via Registry UI, in the `Auth entities` page, or via API.
1818

@@ -56,9 +56,9 @@ links to the JS/CSS bundles in the Registry after each deployment.
5656

5757
To do this, there are the following options (at least):
5858

59-
- Manually via UI (_not recommended_)
60-
- Using Registry API (see [API](#api) section above)
61-
- **Using App Assets discovery mechanism**
59+
- Manually via UI (_not recommended_)
60+
- Using Registry API (see [API](#api) section above)
61+
- **Using App Assets discovery mechanism**
6262

6363
When registering micro frontend in the ILC Registry, it is possible to set a file for the "Assets discovery url" that will be periodically fetched
6464
by the Registry. The idea is that this file will contain actual references to JS/CSS bundles and be updated on CDN **right after** every deployment.
@@ -195,3 +195,45 @@ Applications reference shared properties via the `configSelector` field.
195195
### Domain properties
196196

197197
See [Multi-domains documentation](multi-domains.md#domain-specific-properties) for information about configuring domain-specific properties.
198+
199+
## Settings reference
200+
201+
ILC behavior can be tuned via the Settings page in Registry UI (`/settings`) or through the `PUT /api/v1/settings/:key` API.
202+
203+
### `fragmentProxyHeaders`
204+
205+
| Key | Type | Default | Scope |
206+
| ---------------------- | ---------- | ------- | ----- |
207+
| `fragmentProxyHeaders` | `string[]` | `null` | `ilc` |
208+
209+
A list of HTTP header names that ILC will forward from the incoming end-user request to **fragment SSR requests**. When ILC renders a micro-frontend server-side, the listed headers are copied from the browser request into the outgoing HTTP call to the fragment's SSR endpoint.
210+
211+
Headers are matched case-insensitively. Headers present in the list but absent from the incoming request are silently skipped.
212+
213+
**`null` (default)** — no extra headers are forwarded beyond the built-in set (see below).
214+
215+
**Built-in headers always forwarded to fragments** (regardless of `fragmentProxyHeaders`):
216+
217+
- `authorization`, `accept-language`, `referer`, `user-agent`, `cookie`
218+
- `x-request-uri`, `x-request-host`, `x-request-intl`
219+
- Any header whose name starts with `x-forwarded-`
220+
221+
### `templateProxyHeaders`
222+
223+
| Key | Type | Default | Scope |
224+
| ---------------------- | ---------- | ------- | ----- |
225+
| `templateProxyHeaders` | `string[]` | `null` | `ilc` |
226+
227+
A list of HTTP header names that ILC will forward from the incoming end-user request to **template `<include>` fetches**. When the registry renders a template containing `<include src="…" />` tags, the listed headers are copied from the ILC→Registry request into the outgoing HTTP calls to each include source.
228+
229+
Headers are matched case-insensitively. Headers present in the list but absent from the incoming request are silently skipped.
230+
231+
**`null` (default)** — no extra headers are forwarded to include sources.
232+
233+
**Example — forward tracing and routing headers to both targets:**
234+
235+
```json
236+
["x-forwarded-for", "x-real-ip", "x-request-id"]
237+
```
238+
239+
**Caching note:** template rendering results (including fetched `<include>` content) are cached by ILC keyed on the template name, domain, and the forwarded header values. Forwarded headers are therefore expected to be **static per deployment context** (e.g. set by a reverse proxy, not by individual users). Using headers with many unique values (e.g. per-user auth tokens) will create a large number of cache entries and trigger LRU eviction — a warning is logged when this occurs.

ilc/common/EvictingCacheStorage.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,27 @@ describe('EvictingCacheStorage', () => {
4848
expect(cache.getItem('d')).to.deep.equal({ data: 4, cachedAt: 1 });
4949
});
5050

51+
it('should call onEvict with the evicted key when maxSize is exceeded', () => {
52+
const evicted: string[] = [];
53+
const c = new EvictingCacheStorage({ maxSize: 2, onEvict: (key) => evicted.push(key) });
54+
55+
c.setItem('a', { data: 1, cachedAt: 1 });
56+
c.setItem('b', { data: 2, cachedAt: 1 });
57+
c.setItem('c', { data: 3, cachedAt: 1 }); // evicts 'a'
58+
59+
expect(evicted).to.deep.equal(['a']);
60+
});
61+
62+
it('should not call onEvict when maxSize is not exceeded', () => {
63+
const evicted: string[] = [];
64+
const c = new EvictingCacheStorage({ maxSize: 3, onEvict: (key) => evicted.push(key) });
65+
66+
c.setItem('a', { data: 1, cachedAt: 1 });
67+
c.setItem('b', { data: 2, cachedAt: 1 });
68+
69+
expect(evicted).to.deep.equal([]);
70+
});
71+
5172
it('should overwrite an existing key and reorder it', () => {
5273
cache.setItem('a', { data: 1, cachedAt: 1 });
5374
cache.setItem('b', { data: 2, cachedAt: 1 });

ilc/common/EvictingCacheStorage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { CacheResult, CacheStorage } from './types/CacheWrapper';
22

33
type EvictingCacheStorageOptions = {
44
maxSize: number;
5+
onEvict?: (evictedKey: string) => void;
56
};
67

78
export class EvictingCacheStorage implements CacheStorage {
@@ -34,6 +35,7 @@ export class EvictingCacheStorage implements CacheStorage {
3435
if (this.cache.size > this.options.maxSize) {
3536
const oldestKey = this.cache.keys().next().value!; // Get the first key (LRU)
3637
this.cache.delete(oldestKey);
38+
this.options.onEvict?.(oldestKey);
3739
}
3840
}
3941
}

ilc/server/registry/Registry.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,14 @@ module.exports = class Registry {
4747
return filteredData;
4848
};
4949

50-
this.getTemplate = async (templateName, { locale, forDomain, routeKey } = {}) => {
50+
this.getTemplate = async (templateName, { locale, forDomain, routeKey, forwardedHeaders } = {}) => {
5151
if (templateName === '500' && forDomain) {
5252
const routerDomains = await this.getRouterDomains();
5353
const redefined500 = routerDomains.data.find((item) => item.domainName === forDomain)?.template500;
5454
templateName = redefined500 || templateName;
5555
}
5656

57-
return await getTemplateMemoized(templateName, { locale, domain: forDomain, routeKey });
57+
return await getTemplateMemoized(templateName, { locale, domain: forDomain, routeKey, forwardedHeaders });
5858
};
5959
}
6060

@@ -115,7 +115,7 @@ module.exports = class Registry {
115115

116116
// Note: `routeKey` is intentionally unused here — it serves as a cache key differentiator
117117
// in the memoization layer (via JSON.stringify of args) to prevent cache collisions across routes.
118-
#getTemplate = async (templateName, { locale, domain, routeKey: _routeKey }) => {
118+
#getTemplate = async (templateName, { locale, domain, routeKey: _routeKey, forwardedHeaders = undefined }) => {
119119
if (!VALID_TEMPLATE_NAME.test(templateName)) {
120120
throw new ValidationRegistryError({
121121
message: `Invalid template name ${templateName}`,
@@ -143,7 +143,10 @@ module.exports = class Registry {
143143

144144
let res;
145145
try {
146-
res = await axios.get(tplUrl, { responseType: 'json' });
146+
res = await axios.get(tplUrl, {
147+
responseType: 'json',
148+
headers: forwardedHeaders,
149+
});
147150
} catch (error) {
148151
if (axios.isAxiosError(error) && error.response?.status === 404) {
149152
throw new NotFoundRegistryError({

ilc/server/registry/factory.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,20 @@ export const registryFactory = () => {
99
const logger = reportPlugin.getLogger();
1010
return new Registry(
1111
config.get('registry.address'),
12-
new DefaultCacheWrapper(new EvictingCacheStorage({ maxSize: 1000 }), logger, context),
12+
new DefaultCacheWrapper(
13+
new EvictingCacheStorage({
14+
maxSize: 1000,
15+
onEvict: (key) =>
16+
logger.warn(
17+
{ key },
18+
'ILC registry cache eviction: maxSize (1000) exceeded. ' +
19+
'This may be caused by templateProxyHeaders producing too many unique cache keys. ' +
20+
'Consider reducing the number of distinct proxy header value combinations.',
21+
),
22+
}),
23+
logger,
24+
context,
25+
),
1326
logger,
1427
);
1528
};

ilc/server/routes/wildcardRequestHandlerFactory.spec.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,7 @@ describe('wildcardRequestHandlerFactory', () => {
530530
locale: 'en-US',
531531
forDomain: 'test.com',
532532
routeKey: '/test',
533+
forwardedHeaders: undefined,
533534
});
534535
});
535536

@@ -557,6 +558,65 @@ describe('wildcardRequestHandlerFactory', () => {
557558
locale: 'fr-FR',
558559
forDomain: 'test.com',
559560
routeKey: '/test',
561+
forwardedHeaders: undefined,
562+
});
563+
});
564+
565+
it('should forward templateProxyHeaders to getTemplate when configured', async () => {
566+
mockRegistryService.getConfig.resolves({
567+
settings: {
568+
trailingSlash: 'disabled',
569+
overrideConfigTrustedOrigins: ['localhost'],
570+
i18n: {
571+
enabled: false,
572+
supported: { locale: {}, currency: {} },
573+
default: { locale: 'en-US', currency: 'USD' },
574+
},
575+
templateProxyHeaders: ['X-Forwarded-For', 'X-Real-IP'],
576+
},
577+
routes: [
578+
{
579+
routeId: 'test-route',
580+
route: '/test',
581+
next: false,
582+
slots: {},
583+
template: 'test-template',
584+
},
585+
],
586+
apps: {},
587+
} as any);
588+
589+
const mockRequest = {
590+
host: 'test.com',
591+
headers: {
592+
'x-forwarded-for': '1.2.3.4',
593+
'x-real-ip': '5.6.7.8',
594+
'x-secret': 'should-not-forward',
595+
},
596+
log: mockLogger,
597+
raw: {
598+
url: '/test',
599+
ilcState: { locale: 'en-US' },
600+
},
601+
};
602+
603+
const mockReply = {
604+
redirect: sinon.stub(),
605+
header: sinon.stub(),
606+
status: sinon.stub().returns({ send: sinon.stub() }),
607+
res: {},
608+
};
609+
610+
await wildcardRequestHandler.call({} as any, mockRequest as any, mockReply as any);
611+
612+
sinon.assert.calledWith(mockRegistryService.getTemplate, 'test-template', {
613+
locale: 'en-US',
614+
forDomain: 'test.com',
615+
routeKey: '/test',
616+
forwardedHeaders: {
617+
'x-forwarded-for': '1.2.3.4',
618+
'x-real-ip': '5.6.7.8',
619+
},
560620
});
561621
});
562622
});

ilc/server/routes/wildcardRequestHandlerFactory.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import i18n from '../i18n';
77
import CspBuilderService from '../services/CspBuilderService';
88
import tailorFactory from '../tailor/factory';
99
import { mergeConfigs, type OverrideConfig } from '../tailor/merge-configs';
10+
import { buildForwardedHeaders } from '../utils/helpers';
1011
import parseOverrideConfig from '../tailor/parse-override-config';
1112
import ServerRouter from '../tailor/server-router';
1213
import { TransitionHooksExecutor } from '../TransitionHooksExecutor';
@@ -98,10 +99,15 @@ export function wildcardRequestHandlerFactory(
9899
if (isRouteWithoutSlots) {
99100
const locale = req.raw.ilcState?.locale;
100101
const routeKey = route.route || `special:${route.specialRole}`;
102+
const forwardedHeaders = buildForwardedHeaders(
103+
finalRegistryConfig.settings.templateProxyHeaders,
104+
req.headers,
105+
);
101106
const { data } = await registryService.getTemplate(route.template, {
102107
locale,
103108
forDomain: currentDomain,
104109
routeKey,
110+
forwardedHeaders,
105111
});
106112

107113
reply.header('Content-Type', 'text/html');

ilc/server/tailor/factory.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ const _ = require('lodash');
44
const newrelic = require('newrelic');
55

66
const Tailor = require('@namecheap/tailorx');
7-
const fetchTemplate = require('./fetch-template');
8-
const filterHeaders = require('./filter-headers');
7+
const { fetchTemplate } = require('./fetch-template');
8+
const { filterHeaders } = require('./filter-headers');
99
const errorHandlerSetup = require('./error-handler');
1010
const fragmentHooks = require('./fragment-hooks');
1111
const ConfigsInjector = require('./configs-injector');

ilc/server/tailor/fetch-template.js

Lines changed: 0 additions & 37 deletions
This file was deleted.

0 commit comments

Comments
 (0)