diff --git a/docker-compose.yaml b/docker-compose.yaml index 6d2176c97..3434c4416 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -30,6 +30,12 @@ services: container_name: ${WEB_CONTAINER_NAME:-gitnexus-web} ports: - '${WEB_HOST_PORT:-4173}:4173' + # Override the backend URL served to the browser. The default + # (http://localhost:4747) works when both containers run locally. + # Set GITNEXUS_BACKEND_URL in your .env or shell for remote/custom setups: + # GITNEXUS_BACKEND_URL=http://:4747 + environment: + - GITNEXUS_BACKEND_URL=${GITNEXUS_BACKEND_URL:-} depends_on: gitnexus-server: condition: service_healthy diff --git a/docker-server.mjs b/docker-server.mjs index 8143eb4b7..f6c9eb8f6 100644 --- a/docker-server.mjs +++ b/docker-server.mjs @@ -1,12 +1,39 @@ -import { createReadStream } from 'node:fs'; -import { stat } from 'node:fs/promises'; +import { open } from 'node:fs/promises'; import { createServer } from 'node:http'; -import { extname, isAbsolute, normalize, relative, resolve } from 'node:path'; +import { extname, isAbsolute, normalize, relative, resolve, sep } from 'node:path'; const host = '0.0.0.0'; const port = Number(process.env.PORT || '4173'); const root = resolve(process.cwd(), 'dist'); +function isValidUrl(value) { + try { + const u = new URL(value); + return u.protocol === 'http:' || u.protocol === 'https:'; + } catch { + return false; + } +} + +function jsonForScriptTag(obj) { + return JSON.stringify(obj) + .replace(//g, '\\u003e') + .replace(/&/g, '\\u0026'); +} + +const rawBackendUrl = process.env.GITNEXUS_BACKEND_URL ?? null; +if (rawBackendUrl && !isValidUrl(rawBackendUrl)) { + const safeRaw = rawBackendUrl.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 200); + console.warn( + `[gitnexus-web] GITNEXUS_BACKEND_URL "${safeRaw}" is not a valid http/https URL -- ignoring.`, + ); +} +const backendUrl = rawBackendUrl && isValidUrl(rawBackendUrl) ? rawBackendUrl : null; +const configScript = backendUrl + ? `` + : ''; + const contentTypes = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', @@ -22,22 +49,22 @@ const contentTypes = { // Static asset server for the gitnexus-web Docker image. // -// Path-injection containment: the request handler is intentionally a single -// inline pipeline with no helper functions on the path-data flow. Each -// filesystem sink (stat, createReadStream) is immediately preceded by the -// canonical `path.relative` containment check that CodeQL's -// `js/path-injection` query recognizes as a sanitizer barrier: +// TOCTOU prevention: every filesystem interaction uses open() to get a +// file handle; subsequent reads use handle.readFile()/createReadStream(). // -// const rel = relative(root, candidate); -// if (rel.startsWith('..') || isAbsolute(rel)) reject; -// // candidate is now proven inside `root` +// CodeQL js/file-system-race: the query pairs open() calls when their +// path arguments are data-flow aliased. This handler uses exactly two +// open() calls whose paths are provably independent: +// 1. open(requestedPath) — derived from the URL +// 2. open(spaFallback) — the constant root/index.html +// Because spaFallback has no data-flow from the request, CodeQL cannot +// pair them as a check/use on the same path. // -// Earlier iterations of this file used a helper (`resolveWithinRoot`) and a -// `startsWith(root + sep)` check. Both were semantically correct but neither -// was recognized by CodeQL: `startsWith(root + sep)` is not in the analyzer's -// barrier-pattern set, and helper-based sanitization is not followed across -// the request handler's reassignment paths in vanilla JS. The inline-at-sink -// shape below is the documented analyzer-friendly idiom. +// Path-injection containment: each open() is preceded by a +// path.relative() barrier that CodeQL recognizes as a sanitizer. + +const spaFallback = resolve(root, 'index.html'); + const server = createServer(async (req, res) => { const urlPath = req.url?.split('?')[0] || '/'; @@ -56,62 +83,90 @@ const server = createServer(async (req, res) => { } const cleanPath = normalize(decoded.replace(/^\/+/, '')); - const initialPath = resolve(root, cleanPath); + const requestedPath = resolve(root, cleanPath); - // Sanitizer barrier #1 — guards the first stat() sink. - const initialRel = relative(root, initialPath); - if (initialRel.startsWith('..') || isAbsolute(initialRel)) { + const rel = relative(root, requestedPath); + if (rel.startsWith('..') || isAbsolute(rel)) { res.writeHead(400); res.end('Bad request'); return; } + let handle; try { - const initialStat = await stat(initialPath).catch(() => null); + let servePath = requestedPath; - // Pick the path we actually serve. Note: any branch reassigns to a - // freshly-resolved path; the next sanitizer barrier re-validates. - let finalPath; - if (initialStat?.isDirectory()) { - finalPath = resolve(initialPath, 'index.html'); - } else if (!initialStat?.isFile()) { - finalPath = resolve(root, 'index.html'); + // Try to open the exact path the client asked for. + handle = await open(requestedPath, 'r').catch(() => null); + if (handle) { + const s = await handle.stat(); + if (!s.isFile()) { + // Directories and other non-files fall through to SPA fallback. + await handle.close(); + handle = null; + } + } + + // If the requested path wasn't a regular file, serve the SPA entry + // point. spaFallback is a module-level constant with no data-flow + // from the request, so this open() is independent of the one above. + if (!handle) { + servePath = spaFallback; + handle = await open(spaFallback, 'r').catch(() => null); + if (!handle) { + res.writeHead(404); + res.end('Not found'); + return; + } + const s = await handle.stat(); + if (!s.isFile()) { + res.writeHead(404); + res.end('Not found'); + return; + } + } + + const isHtml = extname(servePath) === '.html' || !extname(servePath); + const cacheControl = servePath.includes(`${sep}assets${sep}`) + ? 'public, max-age=31536000, immutable' + : 'no-cache'; + const contentType = contentTypes[extname(servePath)] || 'application/octet-stream'; + + if (isHtml && configScript) { + const raw = await handle.readFile('utf8'); + await handle.close(); + handle = null; + if (!raw.includes('')) { + console.warn('[gitnexus-web] Could not inject config: no tag found in HTML'); + } + const html = raw.includes('') ? raw.replace('', `${configScript}`) : raw; + const buf = Buffer.from(html, 'utf8'); + res.writeHead(200, { + 'Cache-Control': cacheControl, + 'Content-Type': 'text/html; charset=utf-8', + 'Content-Length': buf.length, + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + }); + res.end(buf); } else { - finalPath = initialPath; + res.writeHead(200, { + 'Cache-Control': cacheControl, + 'Content-Type': contentType, + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + }); + const stream = handle.createReadStream(); + handle = null; + stream.on('error', () => res.destroy()); + stream.pipe(res); } - - // Sanitizer barrier #2 — guards both the second stat() and the - // createReadStream() sinks. No reassignment of finalPath happens - // between this guard and either sink, so the analyzer can prove - // containment for both. - const finalRel = relative(root, finalPath); - if (finalRel.startsWith('..') || isAbsolute(finalRel)) { - res.writeHead(400); - res.end('Bad request'); - return; - } - - const finalStat = await stat(finalPath).catch(() => null); - if (!finalStat?.isFile()) { - res.writeHead(404); - res.end('Not found'); - return; - } - - res.writeHead(200, { - 'Cache-Control': finalPath.includes('/assets/') - ? 'public, max-age=31536000, immutable' - : 'no-cache', - 'Content-Type': contentTypes[extname(finalPath)] || 'application/octet-stream', - 'Cross-Origin-Opener-Policy': 'same-origin', - 'Cross-Origin-Embedder-Policy': 'require-corp', - }); - const stream = createReadStream(finalPath); - stream.on('error', () => res.destroy()); - stream.pipe(res); } catch (error) { + console.error(error); res.writeHead(500); - res.end(error instanceof Error ? error.message : 'Internal server error'); + res.end('Internal server error'); + } finally { + if (handle) await handle.close().catch(() => {}); } }); diff --git a/docker-server.test.mjs b/docker-server.test.mjs index 0fa84155b..ee3a4301a 100644 --- a/docker-server.test.mjs +++ b/docker-server.test.mjs @@ -70,8 +70,20 @@ before(async () => { await waitForServer(serverPort); }); +function killAndWait(proc) { + return new Promise((resolve) => { + if (!proc || proc.exitCode !== null) { + resolve(); + return; + } + proc.once('exit', resolve); + proc.kill(); + if (proc.exitCode !== null) resolve(); + }); +} + after(async () => { - child?.kill(); + await killAndWait(child); if (tmpDir) await rm(tmpDir, { recursive: true, force: true }); }); @@ -122,3 +134,132 @@ it('returns 404 when dist/index.html is missing', async () => { const res = await rawGet(serverPort, '/nonexistent-page'); assert.equal(res.status, 404); }); + +// -- Config injection: server-level integration tests --- + +function spawnServerWithEnv(cwd, port, env) { + const proc = spawn(process.execPath, [serverScript], { + cwd, + env: { ...process.env, PORT: String(port), ...env }, + stdio: 'pipe', + }); + proc.on('error', (err) => { + throw err; + }); + return proc; +} + +async function withInjectionServer(envOverrides, fn) { + const dir = await mkdtemp(join(tmpdir(), 'gitnexus-inject-')); + const distDir = join(dir, 'dist'); + const assetsDir = join(distDir, 'assets'); + await mkdir(assetsDir, { recursive: true }); + await writeFile( + join(distDir, 'index.html'), + 'app', + ); + await writeFile(join(assetsDir, 'style.abc.css'), 'body{}'); + + const port = await getFreePort(); + const proc = spawnServerWithEnv(dir, port, envOverrides); + try { + await waitForServer(port); + await fn(port); + } finally { + await killAndWait(proc); + await rm(dir, { recursive: true, force: true }); + } +} + +it('injects __GITNEXUS_CONFIG__ into / when GITNEXUS_BACKEND_URL is valid', async () => { + await withInjectionServer({ GITNEXUS_BACKEND_URL: 'http://10.0.0.1:4747' }, async (port) => { + const res = await rawGet(port, '/'); + assert.equal(res.status, 200); + assert.ok( + res.body.includes('window.__GITNEXUS_CONFIG__'), + 'Expected __GITNEXUS_CONFIG__ in response body', + ); + assert.ok(res.body.includes('http://10.0.0.1:4747'), 'Expected backend URL in response body'); + }); +}); + +it('injects __GITNEXUS_CONFIG__ into SPA fallback routes', async () => { + await withInjectionServer({ GITNEXUS_BACKEND_URL: 'http://10.0.0.1:4747' }, async (port) => { + const res = await rawGet(port, '/some/deep/link'); + assert.equal(res.status, 200); + assert.ok( + res.body.includes('window.__GITNEXUS_CONFIG__'), + 'Expected __GITNEXUS_CONFIG__ in SPA fallback response', + ); + assert.ok( + res.body.includes('http://10.0.0.1:4747'), + 'Expected backend URL in SPA fallback response', + ); + }); +}); + +it('does not inject when GITNEXUS_BACKEND_URL is not set', async () => { + await withInjectionServer({}, async (port) => { + const res = await rawGet(port, '/'); + assert.equal(res.status, 200); + assert.ok( + !res.body.includes('__GITNEXUS_CONFIG__'), + 'Expected no __GITNEXUS_CONFIG__ when env var is unset', + ); + }); +}); + +it('does not inject when GITNEXUS_BACKEND_URL is invalid', async () => { + await withInjectionServer({ GITNEXUS_BACKEND_URL: 'not-a-url' }, async (port) => { + const res = await rawGet(port, '/'); + assert.equal(res.status, 200); + assert.ok( + !res.body.includes('__GITNEXUS_CONFIG__'), + 'Expected no __GITNEXUS_CONFIG__ for invalid URL', + ); + }); +}); + +it('does not inject when GITNEXUS_BACKEND_URL uses a non-http protocol', async () => { + await withInjectionServer({ GITNEXUS_BACKEND_URL: 'ftp://somehost:21' }, async (port) => { + const res = await rawGet(port, '/'); + assert.equal(res.status, 200); + assert.ok( + !res.body.includes('__GITNEXUS_CONFIG__'), + 'Expected no __GITNEXUS_CONFIG__ for non-http protocol', + ); + }); +}); + +it('escapes in GITNEXUS_BACKEND_URL to prevent XSS', async () => { + const xssUrl = 'http://example.com/?x='; + await withInjectionServer({ GITNEXUS_BACKEND_URL: xssUrl }, async (port) => { + const res = await rawGet(port, '/'); + assert.equal(res.status, 200); + + const scriptMatches = res.body.match(/ must not appear unescaped -- would allow script breakout', + ); + assert.ok(res.body.includes('\\u003c'), 'Angle brackets must be escaped as \\u003c'); + }); +}); + +it('does not inject config into static assets', async () => { + await withInjectionServer({ GITNEXUS_BACKEND_URL: 'http://10.0.0.1:4747' }, async (port) => { + const res = await rawGet(port, '/assets/style.abc.css'); + assert.equal(res.status, 200); + assert.ok( + !res.body.includes('__GITNEXUS_CONFIG__'), + 'Static assets must not contain injected config', + ); + assert.equal(res.body, 'body{}'); + }); +}); diff --git a/gitnexus-web/src/config/ui-constants.ts b/gitnexus-web/src/config/ui-constants.ts index c0a2b488c..1bdb9bae1 100644 --- a/gitnexus-web/src/config/ui-constants.ts +++ b/gitnexus-web/src/config/ui-constants.ts @@ -2,7 +2,9 @@ export const ERROR_RESET_DELAY_MS = 3000; export const BACKEND_URL_DEBOUNCE_MS = 500; -export const DEFAULT_BACKEND_URL = 'http://localhost:4747'; +export const DEFAULT_BACKEND_URL = + (typeof window !== 'undefined' && window.__GITNEXUS_CONFIG__?.backendUrl) || + 'http://localhost:4747'; export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434'; export const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; diff --git a/gitnexus-web/src/vite-env.d.ts b/gitnexus-web/src/vite-env.d.ts index 11f02fe2a..4a8d41b00 100644 --- a/gitnexus-web/src/vite-env.d.ts +++ b/gitnexus-web/src/vite-env.d.ts @@ -1 +1,7 @@ /// + +interface Window { + __GITNEXUS_CONFIG__?: { + backendUrl?: string; + }; +} diff --git a/gitnexus-web/test/unit/server-connection.test.ts b/gitnexus-web/test/unit/server-connection.test.ts index e39b829a1..dc1e79e7c 100644 --- a/gitnexus-web/test/unit/server-connection.test.ts +++ b/gitnexus-web/test/unit/server-connection.test.ts @@ -172,6 +172,37 @@ describe('fetchGraph', () => { }); }); +describe('DEFAULT_BACKEND_URL resolution', () => { + afterEach(() => { + delete window.__GITNEXUS_CONFIG__; + vi.resetModules(); + }); + + it('falls back to localhost:4747 when no config is injected', async () => { + delete window.__GITNEXUS_CONFIG__; + const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants'); + expect(DEFAULT_BACKEND_URL).toBe('http://localhost:4747'); + }); + + it('uses window.__GITNEXUS_CONFIG__.backendUrl when set', async () => { + window.__GITNEXUS_CONFIG__ = { backendUrl: 'http://10.0.0.1:4747' }; + const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants'); + expect(DEFAULT_BACKEND_URL).toBe('http://10.0.0.1:4747'); + }); + + it('falls back to localhost:4747 when config object has no backendUrl', async () => { + window.__GITNEXUS_CONFIG__ = {}; + const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants'); + expect(DEFAULT_BACKEND_URL).toBe('http://localhost:4747'); + }); + + it('falls back to localhost:4747 when backendUrl is an empty string', async () => { + window.__GITNEXUS_CONFIG__ = { backendUrl: '' }; + const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants'); + expect(DEFAULT_BACKEND_URL).toBe('http://localhost:4747'); + }); +}); + describe('validateBackendUrl', () => { it('allows http:// URLs', () => { expect(() => validateBackendUrl('http://localhost:4747')).not.toThrow(); @@ -225,7 +256,6 @@ describe('setBackendUrl', () => { it('does not mutate _backendUrl when validation fails', () => { setBackendUrl('http://localhost:4747'); expect(() => setBackendUrl('javascript:alert(1)')).toThrow(); - // State must be preserved — validation must happen before the assignment expect(getBackendUrl()).toBe('http://localhost:4747'); }); });