Skip to content

Commit 10c3f17

Browse files
feat(configGuard): block dev-host/port leak into production config (#4235)
* feat(configGuard): promote DEV_HOSTS/DEV_PORTS leak detection from trawl Adds DEV_HOSTS (localhost, 127.0.0.1) and DEV_PORTS (3000-8888 range) sets to assertConfigLoaded so any downstream that misconfigures api.host or api.port will get a hard build-time error instead of a silent prod outage. Guard is a no-op outside production mode. Adds 6 new unit tests covering localhost/127.0.0.1 host rejection, dev-port rejection (3010, 3000), and valid-config pass-through. Incident reference: trawl_vue #949 (signin-broken :3010 outage). Closes #4234. Partially closes pierreb-projects/infra#38 (T11). * ci: set DEVKIT_VUE_api_host on build step so configGuard passes in devkit CI Devkit Vue doesn't deploy, so no real API host is set. The new configGuard rejects a localhost default at production build time — add a placeholder value so the guard is satisfied without weakening the downstream check. * ci: also clear DEVKIT_VUE_api_port for configGuard build configGuard rejects the dev-default port "3000" too. Set it to empty so the production-build guard passes in devkit CI (no real deploy target). * fix(configGuard): normalize host/port before DEV set membership check - Trim + lowercase api.host before DEV_HOSTS.has() — "LOCALHOST" no longer bypasses the guard - Trim api.port string before DEV_PORTS.has() — " 3000 " no longer bypasses - Fix error message: npm run config → npm run generateConfig (matches package.json) - Fix issue ref: #949 → trawl_vue#949 (cross-repo clarity) - Fix error copy: "real API URL" → "host/hostname" (api.host is hostname, not full URL) - Add regression tests for LOCALHOST (uppercase) and " 3000 " (padded) bypass attempts
1 parent a00caa5 commit 10c3f17

3 files changed

Lines changed: 93 additions & 6 deletions

File tree

.github/workflows/CI.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ jobs:
3737
- run: npm run lint
3838
- run: npm run test:unit:coverage
3939
- run: UV_USE_IO_URING=0 npm run build
40+
env:
41+
DEVKIT_VUE_api_host: https://api.example.com
42+
DEVKIT_VUE_api_port: ""
4043
- uses: codecov/codecov-action@v5
4144
with:
4245
token: ${{ secrets.CODECOV_TOKEN }}

src/lib/helpers/configGuard.js

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,54 @@
66
const CONFIG_PLACEHOLDER = { port: 8080 };
77

88
/**
9-
* Validate that the application config is fully loaded (not the placeholder).
10-
* In production mode, a missing config is a fatal error because SEO plugins
11-
* depend on real values. In development mode the placeholder is acceptable.
9+
* Dev-only hostnames and ports that must never reach a production bundle.
10+
* Root cause of 2026-05-10 signin-broken :3010 outage (trawl_vue#949).
11+
*/
12+
const DEV_HOSTS = new Set(['localhost', '127.0.0.1']);
13+
const DEV_PORTS = new Set(['3000', '3001', '3010', '4000', '5000', '8000', '8080', '8888']);
14+
15+
/**
16+
* Validate that the application config is fully loaded (not the placeholder)
17+
* and does not contain dev-default API coordinates in production builds.
18+
*
19+
* In production mode:
20+
* - A missing/placeholder config is a fatal error (SEO plugins need real values).
21+
* - api.host must not be localhost or 127.0.0.1.
22+
* - api.port must not be a well-known local dev port.
23+
* In development/test mode the function is a no-op.
1224
*
1325
* @param {object} config - The resolved application config object.
1426
* @param {string} mode - Vite build mode (e.g. 'production', 'development').
1527
* @returns {void}
16-
* @throws {Error} When mode is 'production' and config is still the placeholder.
28+
* @throws {Error} When mode is 'production' and config is invalid or contains dev defaults.
1729
*/
1830
export const assertConfigLoaded = (config, mode) => {
31+
if (mode !== 'production') return;
32+
1933
const safeConfig = config || CONFIG_PLACEHOLDER;
2034
const isPlaceholder =
2135
safeConfig === CONFIG_PLACEHOLDER || (Object.keys(safeConfig).length === 1 && safeConfig.port === 8080 && !safeConfig.host);
22-
if (mode === 'production' && isPlaceholder) {
36+
if (isPlaceholder) {
37+
throw new Error(
38+
'Production build requires a valid config. Run `npm run generateConfig` to generate src/config/index.js before building.',
39+
);
40+
}
41+
42+
// Guard against dev-default API host leaking into a prod bundle (trawl_vue#949).
43+
const apiHost = String(safeConfig?.api?.host || '').trim().toLowerCase();
44+
if (apiHost && DEV_HOSTS.has(apiHost)) {
45+
throw new Error(
46+
`Production build has dev-default API host/hostname "${safeConfig?.api?.host}". ` +
47+
'Set DEVKIT_VUE_api_host to the real host/hostname before building.',
48+
);
49+
}
50+
51+
// Guard against dev-default API port leaking into a prod bundle (trawl_vue#949).
52+
const apiPort = String(safeConfig?.api?.port || '').trim();
53+
if (apiPort && DEV_PORTS.has(apiPort)) {
2354
throw new Error(
24-
'Production build requires a valid config. Run `npm run config` to generate src/config/index.js before building.',
55+
`Production build has dev-default API port "${apiPort}". ` +
56+
'Set DEVKIT_VUE_api_port to "" (empty) or the real port before building.',
2557
);
2658
}
2759
};

src/lib/helpers/tests/configGuard.unit.tests.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,56 @@ describe('configGuard – assertConfigLoaded', () => {
4040
'Production build requires a valid config',
4141
);
4242
});
43+
44+
// DEV_HOSTS / DEV_PORTS leak-detection tests (incident ref: trawl_vue #949 — signin-broken :3010 outage)
45+
46+
it('throws in production when api.host is localhost', () => {
47+
expect(() =>
48+
assertConfigLoaded({ api: { host: 'localhost', port: '' } }, 'production'),
49+
).toThrow('dev-default API host');
50+
});
51+
52+
it('throws in production when api.host is 127.0.0.1', () => {
53+
expect(() =>
54+
assertConfigLoaded({ api: { host: '127.0.0.1', port: '' } }, 'production'),
55+
).toThrow('dev-default API host');
56+
});
57+
58+
it('throws in production when api.port is a dev port', () => {
59+
expect(() =>
60+
assertConfigLoaded({ api: { host: 'api.example.com', port: '3010' } }, 'production'),
61+
).toThrow('dev-default API port');
62+
});
63+
64+
it('throws in production when api.port is 3000', () => {
65+
expect(() =>
66+
assertConfigLoaded({ api: { host: 'api.example.com', port: '3000' } }, 'production'),
67+
).toThrow('dev-default API port');
68+
});
69+
70+
it('does not throw in production with valid config (real host + empty port)', () => {
71+
expect(() =>
72+
assertConfigLoaded({ api: { host: 'api.example.com', port: '' } }, 'production'),
73+
).not.toThrow();
74+
});
75+
76+
it('does not throw in production with valid config (real host + no port key)', () => {
77+
expect(() =>
78+
assertConfigLoaded({ api: { host: 'api.example.com' } }, 'production'),
79+
).not.toThrow();
80+
});
81+
82+
// Normalization regression tests: case and whitespace must not bypass guards.
83+
84+
it('throws in production when api.host is "LOCALHOST" (uppercase bypass attempt)', () => {
85+
expect(() =>
86+
assertConfigLoaded({ api: { host: 'LOCALHOST', port: '' } }, 'production'),
87+
).toThrow('dev-default API host');
88+
});
89+
90+
it('throws in production when api.port is " 3000 " (padded bypass attempt)', () => {
91+
expect(() =>
92+
assertConfigLoaded({ api: { host: 'api.example.com', port: ' 3000 ' } }, 'production'),
93+
).toThrow('dev-default API port');
94+
});
4395
});

0 commit comments

Comments
 (0)