diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 4b684f605..4c706c901 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -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 }} diff --git a/src/lib/helpers/configGuard.js b/src/lib/helpers/configGuard.js index 430ab11f8..b7577ab47 100644 --- a/src/lib/helpers/configGuard.js +++ b/src/lib/helpers/configGuard.js @@ -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']); + +/** + * 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.', + ); + } + + // 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.', + ); + } + + // 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.', ); } }; diff --git a/src/lib/helpers/tests/configGuard.unit.tests.js b/src/lib/helpers/tests/configGuard.unit.tests.js index b4641984b..c3f5f65c1 100644 --- a/src/lib/helpers/tests/configGuard.unit.tests.js +++ b/src/lib/helpers/tests/configGuard.unit.tests.js @@ -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(); + }); + + // 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'); + }); });