mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
* 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>
261 lines
8.4 KiB
TypeScript
261 lines
8.4 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
fetchGraph,
|
|
getBackendUrl,
|
|
normalizeServerUrl,
|
|
setBackendUrl,
|
|
validateBackendUrl,
|
|
} from '../../src/services/backend-client';
|
|
|
|
describe('normalizeServerUrl', () => {
|
|
it('adds http:// to localhost', () => {
|
|
expect(normalizeServerUrl('localhost:4747')).toBe('http://localhost:4747');
|
|
});
|
|
|
|
it('adds http:// to 127.0.0.1', () => {
|
|
expect(normalizeServerUrl('127.0.0.1:4747')).toBe('http://127.0.0.1:4747');
|
|
});
|
|
|
|
it('adds https:// to non-local hosts', () => {
|
|
expect(normalizeServerUrl('example.com')).toBe('https://example.com');
|
|
});
|
|
|
|
it('strips trailing slashes', () => {
|
|
expect(normalizeServerUrl('http://localhost:4747/')).toBe('http://localhost:4747');
|
|
expect(normalizeServerUrl('http://localhost:4747///')).toBe('http://localhost:4747');
|
|
});
|
|
|
|
it('strips /api suffix (base URL only)', () => {
|
|
expect(normalizeServerUrl('http://localhost:4747/api')).toBe('http://localhost:4747');
|
|
});
|
|
|
|
it('trims whitespace', () => {
|
|
expect(normalizeServerUrl(' localhost:4747 ')).toBe('http://localhost:4747');
|
|
});
|
|
|
|
it('preserves existing https://', () => {
|
|
expect(normalizeServerUrl('https://gitnexus.example.com')).toBe('https://gitnexus.example.com');
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('fetchGraph', () => {
|
|
it('requests streamed graph responses from the backend', async () => {
|
|
setBackendUrl('http://localhost:4747');
|
|
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response('{"nodes":[],"relationships":[]}', {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
}),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await fetchGraph('big-repo');
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
expect.stringContaining('/api/graph?repo=big-repo&stream=true'),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('parses NDJSON graph streams incrementally', async () => {
|
|
setBackendUrl('http://localhost:4747');
|
|
|
|
const encoder = new TextEncoder();
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
[
|
|
'{"type":"node","data":{"id":"File:src/app.ts","label":"File","properties":{"name":"app.ts","filePath":"src/app.ts"}}}\n',
|
|
'{"type":"relationship","data":{"id":"File:src/app.ts_CONTAINS_Function:src/app.ts:main","type":"CONTAINS","sourceId":"File:src/app.ts","targetId":"Function:src/app.ts:main"}}\n',
|
|
].join(''),
|
|
),
|
|
);
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(
|
|
new Response(stream, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'application/x-ndjson',
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
|
|
const progress = vi.fn();
|
|
const result = await fetchGraph('big-repo', { onProgress: progress });
|
|
|
|
expect(result.nodes).toHaveLength(1);
|
|
expect(result.relationships).toHaveLength(1);
|
|
expect(result.nodes[0].id).toBe('File:src/app.ts');
|
|
expect(result.relationships[0].type).toBe('CONTAINS');
|
|
expect(progress).toHaveBeenCalled();
|
|
});
|
|
|
|
it('parses NDJSON graph lines split across chunks', async () => {
|
|
setBackendUrl('http://localhost:4747');
|
|
|
|
const encoder = new TextEncoder();
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
'{"type":"node","data":{"id":"File:src/app.ts","label":"File","properties":{"name":"app.ts"',
|
|
),
|
|
);
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
',"filePath":"src/app.ts"}}}\n{"type":"relationship","data":{"id":"File:src/app.ts_CONTAINS_Function:src/app.ts:main","type":"CONTAINS","sourceId":"File:src/app.ts","targetId":"Function:src/app.ts:main"}}\n',
|
|
),
|
|
);
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(
|
|
new Response(stream, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'application/x-ndjson',
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
|
|
const result = await fetchGraph('big-repo');
|
|
|
|
expect(result.nodes).toHaveLength(1);
|
|
expect(result.relationships).toHaveLength(1);
|
|
expect(result.nodes[0].properties.filePath).toBe('src/app.ts');
|
|
});
|
|
|
|
it('throws backend errors emitted in the NDJSON stream', async () => {
|
|
setBackendUrl('http://localhost:4747');
|
|
|
|
const encoder = new TextEncoder();
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode('{"type":"error","error":"stream failed"}\n'));
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(
|
|
new Response(stream, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'application/x-ndjson',
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
|
|
await expect(fetchGraph('big-repo')).rejects.toMatchObject({
|
|
message: 'stream failed',
|
|
});
|
|
});
|
|
});
|
|
|
|
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();
|
|
expect(() => validateBackendUrl('http://127.0.0.1:4747')).not.toThrow();
|
|
});
|
|
|
|
it('allows https:// URLs', () => {
|
|
expect(() => validateBackendUrl('https://gitnexus.example.com')).not.toThrow();
|
|
expect(() => validateBackendUrl('https://my-server.internal:4747')).not.toThrow();
|
|
});
|
|
|
|
it('rejects non-http schemes', () => {
|
|
expect(() => validateBackendUrl('javascript:alert(1)')).toThrow('must use http:// or https://');
|
|
expect(() => validateBackendUrl('file:///etc/passwd')).toThrow('must use http:// or https://');
|
|
expect(() => validateBackendUrl('data:text/plain,evil')).toThrow(
|
|
'must use http:// or https://',
|
|
);
|
|
});
|
|
|
|
it('rejects malformed URLs', () => {
|
|
expect(() => validateBackendUrl('not-a-url')).toThrow('Invalid backend URL');
|
|
});
|
|
|
|
it('does not include the raw URL in error messages (credential hygiene)', () => {
|
|
const urlWithCreds = 'javascript:alert("sk-secret")';
|
|
let msg = '';
|
|
try {
|
|
validateBackendUrl(urlWithCreds);
|
|
} catch (e) {
|
|
msg = (e as Error).message;
|
|
}
|
|
expect(msg).not.toContain('sk-secret');
|
|
expect(msg).not.toContain(urlWithCreds);
|
|
});
|
|
});
|
|
|
|
describe('setBackendUrl', () => {
|
|
it('accepts valid http URLs', () => {
|
|
expect(() => setBackendUrl('http://localhost:4747')).not.toThrow();
|
|
});
|
|
|
|
it('accepts valid https URLs', () => {
|
|
expect(() => setBackendUrl('https://my-server.example.com')).not.toThrow();
|
|
});
|
|
|
|
it('rejects non-http/https schemes', () => {
|
|
expect(() => setBackendUrl('javascript:alert(1)')).toThrow('must use http:// or https://');
|
|
expect(() => setBackendUrl('file:///etc/passwd')).toThrow('must use http:// or https://');
|
|
});
|
|
|
|
it('does not mutate _backendUrl when validation fails', () => {
|
|
setBackendUrl('http://localhost:4747');
|
|
expect(() => setBackendUrl('javascript:alert(1)')).toThrow();
|
|
expect(getBackendUrl()).toBe('http://localhost:4747');
|
|
});
|
|
});
|