GitNexus/docker-server.test.mjs
Alaa Kaddour efcab45560
feat(web): support GITNEXUS_BACKEND_URL env var for Docker deployments (#1286)
* feat(web): support GITNEXUS_BACKEND_URL env var for Docker deployments

* fix(docker): escape inline script injection to prevent XSS and add server-level integration tests

- Add jsonForScriptTag() that escapes <, >, & after JSON.stringify to prevent </script> breakout in inline config script
- Sanitize rawBackendUrl in warning log to prevent log injection via newlines
- Replace 5 duplicated-helper injection tests with 7 server-level HTTP integration tests that spawn the real docker-server.mjs with GITNEXUS_BACKEND_URL set
- Add XSS-specific test: URL containing </script> must produce exactly 1 <script> tag
- Add empty-string backendUrl frontend test
- Improve Docker Compose Linux guidance with explicit <server-ip> example

* fix(docker): harden log sanitization, fix error leak, fix killAndWait race

- Broaden log sanitization regex from [\r\n] to [\x00-\x1f\x7f] to strip
  all C0 control characters including ANSI escape sequences
- Replace error.message leak in 500 handler with generic string; log the
  real error server-side via console.error
- Fix killAndWait TOCTOU race by registering exit listener before kill
  and adding post-kill exitCode guard

* fix(docker): handle readFile race to resolve CodeQL file-system-race alert

Wrap readFile in try/catch so the TOCTOU between stat() and readFile()
is handled gracefully — if the file vanishes between the check and the
read, return 404 instead of crashing.

* @
fix(docker): eliminate TOCTOU race and format web components

Replace the previous try/catch approach with fs.promises.open() to
get a file handle, then use handle.stat()/readFile()/createReadStream()
from the same fd — properly eliminates the CodeQL "file system race
condition" alert by removing the window between stat() and read.

Also runs prettier on the 5 web component files that were failing
the format CI check.
@

* chore(autofix): apply prettier + eslint fixes via /autofix command

* chore: trigger CI

* @
fix(docker): pass GITNEXUS_BACKEND_URL to the web container

The env var was documented but commented out, so docker-server.mjs
never received it and the config injection was dead. Uncomment
the environment block with a passthrough default so users can
set GITNEXUS_BACKEND_URL in .env or their shell for remote/custom
deployments.
@

* @
fix(docker): eliminate stat() to resolve CodeQL js/file-system-race

CodeQL pairs any stat() (FileCheck) with a subsequent open() (FileUse)
on an aliased path. The previous approach kept stat() for directory
detection, which the analyzer flagged regardless of the fd-based reads.

Replace stat() entirely with open() + handle.stat(). On Linux (Docker),
open() succeeds for directories, so handle.stat().isDirectory() detects
them without a standalone stat() call. This removes the FileCheck node
from the data-flow graph, eliminating the alert at its source.
@

* @
fix(docker): break CodeQL path alias chain between open() calls

CodeQL js/file-system-race pairs two open() calls when their path
arguments are data-flow aliased. The previous approach derived
the fallback path from the request path (resolve(initialPath,
index.html)), creating an alias chain the analyzer could trace.

Restructure so the SPA fallback uses a module-level constant
(spaFallback = resolve(root, index.html)) with zero data-flow
from the request. The two open() calls now have provably
independent path arguments, eliminating the FileCheck/FileUse pair.

Also simplifies the logic: for an SPA, all non-file requests serve
root/index.html — no directory/index.html detection needed since
the client-side router handles subroutes.
@

---------

Co-authored-by: Test <test@example.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-25 11:21:11 +01:00

265 lines
8.6 KiB
JavaScript

import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises';
import http, { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { after, before, it } from 'node:test';
import assert from 'node:assert/strict';
const __dirname = dirname(fileURLToPath(import.meta.url));
const serverScript = join(__dirname, 'docker-server.mjs');
function getFreePort() {
return new Promise((resolve) => {
const s = createServer();
s.listen(0, '127.0.0.1', () => {
const { port } = s.address();
s.close(() => resolve(port));
});
});
}
function rawGet(port, path) {
return new Promise((resolve, reject) => {
const req = http.request({ host: '127.0.0.1', port, path }, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
});
req.on('error', reject);
req.end();
});
}
async function waitForServer(port, retries = 30) {
for (let i = 0; i < retries; i++) {
try {
await rawGet(port, '/');
return;
} catch {
await new Promise((r) => setTimeout(r, 100));
}
}
throw new Error('Server did not start in time');
}
let tmpDir, serverPort, child;
before(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gitnexus-docker-test-'));
const distDir = join(tmpDir, 'dist');
const assetsDir = join(distDir, 'assets');
await mkdir(assetsDir, { recursive: true });
await writeFile(join(distDir, 'index.html'), '<html><body>spa</body></html>');
await writeFile(join(assetsDir, 'app.abc123.js'), 'console.log("app")');
serverPort = await getFreePort();
child = spawn(process.execPath, [serverScript], {
cwd: tmpDir,
env: { ...process.env, PORT: String(serverPort) },
stdio: 'pipe',
});
child.on('error', (err) => {
throw err;
});
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 () => {
await killAndWait(child);
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
});
it('serves a valid asset with immutable cache header', async () => {
const res = await rawGet(serverPort, '/assets/app.abc123.js');
assert.equal(res.status, 200);
assert.match(res.headers['cache-control'], /immutable/);
assert.equal(res.headers['cross-origin-opener-policy'], 'same-origin');
assert.equal(res.headers['cross-origin-embedder-policy'], 'require-corp');
});
it('serves SPA fallback for unknown routes', async () => {
const res = await rawGet(serverPort, '/some/unknown/route');
assert.equal(res.status, 200);
assert.match(res.body, /spa/);
assert.match(res.headers['cache-control'], /no-cache/);
});
it('rejects path traversal with 400', async () => {
const res = await rawGet(serverPort, '/../../../etc/passwd');
assert.equal(res.status, 400);
});
it('rejects percent-encoded null bytes with 400', async () => {
const res = await rawGet(serverPort, '/foo%00bar');
assert.equal(res.status, 400);
});
it('rejects percent-encoded path traversal with 400', async () => {
// %2e%2e%2f decodes to '../'. Without the path.relative inline barrier,
// a naive string check on the raw URL would let this through and only
// the lexical-decoded path.resolve would catch it. Confirm the barrier
// does its job after decodeURIComponent.
const res = await rawGet(serverPort, '/%2e%2e%2f%2e%2e%2fetc%2fpasswd');
assert.equal(res.status, 400);
});
it('rejects malformed percent-encoding with 400', async () => {
// %GG is not a valid percent-encoded sequence — decodeURIComponent throws.
// The handler's try/catch around decode must convert this to a 400 rather
// than an unhandled rejection.
const res = await rawGet(serverPort, '/foo%GGbar');
assert.equal(res.status, 400);
});
it('returns 404 when dist/index.html is missing', async () => {
await unlink(join(tmpDir, 'dist', 'index.html'));
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'),
'<!doctype html><html><head><meta charset="utf-8"></head><body>app</body></html>',
);
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 </script> in GITNEXUS_BACKEND_URL to prevent XSS', async () => {
const xssUrl = 'http://example.com/?x=</script><script>alert(1)</script>';
await withInjectionServer({ GITNEXUS_BACKEND_URL: xssUrl }, async (port) => {
const res = await rawGet(port, '/');
assert.equal(res.status, 200);
const scriptMatches = res.body.match(/<script>/gi) || [];
assert.equal(
scriptMatches.length,
1,
`Expected exactly 1 <script> tag but found ${scriptMatches.length}: XSS breakout detected`,
);
assert.ok(
!res.body.includes('</script><script>'),
'</script> 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{}');
});
});