mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
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
This commit is contained in:
parent
397488df02
commit
fe51058fe5
4 changed files with 134 additions and 46 deletions
|
|
@ -33,8 +33,14 @@ services:
|
|||
# Optional: override the backend URL served to the browser.
|
||||
# Required when the gitnexus-server is not reachable at http://localhost:4747
|
||||
# from the user's browser (e.g. remote server deployments).
|
||||
# Use http://host.docker.internal:4747 on Docker Desktop (Mac/Windows),
|
||||
# or the server's LAN IP for Linux hosts.
|
||||
#
|
||||
# Docker Desktop (Mac/Windows):
|
||||
# GITNEXUS_BACKEND_URL=http://host.docker.internal:4747
|
||||
#
|
||||
# Linux / remote server:
|
||||
# GITNEXUS_BACKEND_URL=http://<server-ip>:4747
|
||||
# (host.docker.internal requires extra_hosts on Linux Docker Engine)
|
||||
#
|
||||
# environment:
|
||||
# - GITNEXUS_BACKEND_URL=http://host.docker.internal:4747
|
||||
depends_on:
|
||||
|
|
|
|||
|
|
@ -16,15 +16,23 @@ function isValidUrl(value) {
|
|||
}
|
||||
}
|
||||
|
||||
function jsonForScriptTag(obj) {
|
||||
return JSON.stringify(obj)
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/>/g, '\\u003e')
|
||||
.replace(/&/g, '\\u0026');
|
||||
}
|
||||
|
||||
const rawBackendUrl = process.env.GITNEXUS_BACKEND_URL ?? null;
|
||||
if (rawBackendUrl && !isValidUrl(rawBackendUrl)) {
|
||||
const safeRaw = rawBackendUrl.replace(/[\r\n]/g, ' ').slice(0, 200);
|
||||
console.warn(
|
||||
`[gitnexus-web] GITNEXUS_BACKEND_URL "${rawBackendUrl}" is not a valid http/https URL — ignoring.`,
|
||||
`[gitnexus-web] GITNEXUS_BACKEND_URL "${safeRaw}" is not a valid http/https URL -- ignoring.`,
|
||||
);
|
||||
}
|
||||
const backendUrl = rawBackendUrl && isValidUrl(rawBackendUrl) ? rawBackendUrl : null;
|
||||
const configScript = backendUrl
|
||||
? `<script>window.__GITNEXUS_CONFIG__=${JSON.stringify({ backendUrl })};</script>`
|
||||
? `<script>window.__GITNEXUS_CONFIG__=${jsonForScriptTag({ backendUrl })};</script>`
|
||||
: '';
|
||||
|
||||
const contentTypes = {
|
||||
|
|
|
|||
|
|
@ -106,60 +106,128 @@ it('returns 404 when dist/index.html is missing', async () => {
|
|||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
// ── Config injection logic tests (inline, independent of server process) ─────
|
||||
// -- Config injection: server-level integration tests ---
|
||||
|
||||
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
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;
|
||||
}
|
||||
|
||||
function isValidUrl(value) {
|
||||
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 {
|
||||
const u = new URL(value);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
await waitForServer(port);
|
||||
await fn(port);
|
||||
} finally {
|
||||
proc.kill();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function makeInjectedHtml(envBackendUrl) {
|
||||
const rawHtml = '<!doctype html><html><head></head><body></body></html>';
|
||||
const backendUrl = envBackendUrl && isValidUrl(envBackendUrl) ? envBackendUrl : null;
|
||||
const configScript = backendUrl
|
||||
? `<script>window.__GITNEXUS_CONFIG__=${JSON.stringify({ backendUrl })};</script>`
|
||||
: '';
|
||||
return configScript ? rawHtml.replace('</head>', `${configScript}</head>`) : rawHtml;
|
||||
}
|
||||
|
||||
it('injects __GITNEXUS_CONFIG__ when GITNEXUS_BACKEND_URL is a valid URL', () => {
|
||||
const html = makeInjectedHtml('http://10.0.0.1:4747');
|
||||
assert.ok(
|
||||
html.includes(
|
||||
'<script>window.__GITNEXUS_CONFIG__={"backendUrl":"http://10.0.0.1:4747"};</script>',
|
||||
),
|
||||
'Expected config script to be injected into index.html',
|
||||
);
|
||||
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('does not inject when GITNEXUS_BACKEND_URL is not set', () => {
|
||||
const raw = '<!doctype html><html><head></head><body></body></html>';
|
||||
const html = makeInjectedHtml(null);
|
||||
assert.equal(html, raw, 'Expected index.html to be unchanged when no env var is set');
|
||||
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('injects config script before </head>', () => {
|
||||
const html = makeInjectedHtml('http://10.0.0.1:4747');
|
||||
const headCloseIdx = html.indexOf('</head>');
|
||||
const scriptIdx = html.indexOf('<script>window.__GITNEXUS_CONFIG__');
|
||||
assert.ok(scriptIdx !== -1, 'Script tag must be present');
|
||||
assert.ok(scriptIdx < headCloseIdx, 'Script must appear before </head>');
|
||||
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 not a valid URL', () => {
|
||||
const raw = '<!doctype html><html><head></head><body></body></html>';
|
||||
const html = makeInjectedHtml('not-a-url');
|
||||
assert.equal(html, raw, 'Expected index.html to be unchanged for an invalid URL');
|
||||
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', () => {
|
||||
const raw = '<!doctype html><html><head></head><body></body></html>';
|
||||
const html = makeInjectedHtml('ftp://somehost:21');
|
||||
assert.equal(html, raw, 'Expected index.html to be unchanged for non-http protocol');
|
||||
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('alert(1)'), 'Injected alert() must not appear unescaped in HTML');
|
||||
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{}');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -189,4 +189,10 @@ describe('DEFAULT_BACKEND_URL resolution', () => {
|
|||
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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue