Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ jobs:
- run: npm run lint
- run: npm run test:unit:coverage
- run: UV_USE_IO_URING=0 npm run build
env:
DEVKIT_VUE_api_host: https://api.example.com
DEVKIT_VUE_api_port: ""
- uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
Expand Down
44 changes: 38 additions & 6 deletions src/lib/helpers/configGuard.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,54 @@
const CONFIG_PLACEHOLDER = { port: 8080 };

/**
* Validate that the application config is fully loaded (not the placeholder).
* In production mode, a missing config is a fatal error because SEO plugins
* depend on real values. In development mode the placeholder is acceptable.
* Dev-only hostnames and ports that must never reach a production bundle.
* Root cause of 2026-05-10 signin-broken :3010 outage (trawl_vue#949).
*/
const DEV_HOSTS = new Set(['localhost', '127.0.0.1']);
const DEV_PORTS = new Set(['3000', '3001', '3010', '4000', '5000', '8000', '8080', '8888']);
Comment on lines +9 to +13

/**
* Validate that the application config is fully loaded (not the placeholder)
* and does not contain dev-default API coordinates in production builds.
*
* In production mode:
* - A missing/placeholder config is a fatal error (SEO plugins need real values).
* - api.host must not be localhost or 127.0.0.1.
* - api.port must not be a well-known local dev port.
* In development/test mode the function is a no-op.
*
* @param {object} config - The resolved application config object.
* @param {string} mode - Vite build mode (e.g. 'production', 'development').
* @returns {void}
* @throws {Error} When mode is 'production' and config is still the placeholder.
* @throws {Error} When mode is 'production' and config is invalid or contains dev defaults.
*/
export const assertConfigLoaded = (config, mode) => {
if (mode !== 'production') return;

const safeConfig = config || CONFIG_PLACEHOLDER;
const isPlaceholder =
safeConfig === CONFIG_PLACEHOLDER || (Object.keys(safeConfig).length === 1 && safeConfig.port === 8080 && !safeConfig.host);
if (mode === 'production' && isPlaceholder) {
if (isPlaceholder) {
throw new Error(
'Production build requires a valid config. Run `npm run generateConfig` to generate src/config/index.js before building.',
);
Comment on lines +36 to 39
}

// Guard against dev-default API host leaking into a prod bundle (trawl_vue#949).
const apiHost = String(safeConfig?.api?.host || '').trim().toLowerCase();
if (apiHost && DEV_HOSTS.has(apiHost)) {
throw new Error(
`Production build has dev-default API host/hostname "${safeConfig?.api?.host}". ` +
'Set DEVKIT_VUE_api_host to the real host/hostname before building.',
);
Comment on lines +45 to +48
}

// Guard against dev-default API port leaking into a prod bundle (trawl_vue#949).
const apiPort = String(safeConfig?.api?.port || '').trim();
if (apiPort && DEV_PORTS.has(apiPort)) {
throw new Error(
'Production build requires a valid config. Run `npm run config` to generate src/config/index.js before building.',
`Production build has dev-default API port "${apiPort}". ` +
'Set DEVKIT_VUE_api_port to "" (empty) or the real port before building.',
);
}
};
Expand Down
52 changes: 52 additions & 0 deletions src/lib/helpers/tests/configGuard.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,56 @@ describe('configGuard – assertConfigLoaded', () => {
'Production build requires a valid config',
);
});

// DEV_HOSTS / DEV_PORTS leak-detection tests (incident ref: trawl_vue #949 — signin-broken :3010 outage)

it('throws in production when api.host is localhost', () => {
expect(() =>
assertConfigLoaded({ api: { host: 'localhost', port: '' } }, 'production'),
).toThrow('dev-default API host');
});

it('throws in production when api.host is 127.0.0.1', () => {
expect(() =>
assertConfigLoaded({ api: { host: '127.0.0.1', port: '' } }, 'production'),
).toThrow('dev-default API host');
});

it('throws in production when api.port is a dev port', () => {
expect(() =>
assertConfigLoaded({ api: { host: 'api.example.com', port: '3010' } }, 'production'),
).toThrow('dev-default API port');
});

it('throws in production when api.port is 3000', () => {
expect(() =>
assertConfigLoaded({ api: { host: 'api.example.com', port: '3000' } }, 'production'),
).toThrow('dev-default API port');
});

it('does not throw in production with valid config (real host + empty port)', () => {
expect(() =>
assertConfigLoaded({ api: { host: 'api.example.com', port: '' } }, 'production'),
).not.toThrow();
});

it('does not throw in production with valid config (real host + no port key)', () => {
expect(() =>
assertConfigLoaded({ api: { host: 'api.example.com' } }, 'production'),
).not.toThrow();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

// Normalization regression tests: case and whitespace must not bypass guards.

it('throws in production when api.host is "LOCALHOST" (uppercase bypass attempt)', () => {
expect(() =>
assertConfigLoaded({ api: { host: 'LOCALHOST', port: '' } }, 'production'),
).toThrow('dev-default API host');
});

it('throws in production when api.port is " 3000 " (padded bypass attempt)', () => {
expect(() =>
assertConfigLoaded({ api: { host: 'api.example.com', port: ' 3000 ' } }, 'production'),
).toThrow('dev-default API port');
});
});
Loading