feat(web): resolve a root-relative GITNEXUS_BACKEND_URL against the page origin (#3640)

* feat(web): resolve a root-relative GITNEXUS_BACKEND_URL against the page origin

A GITNEXUS_BACKEND_URL of `/` (or `/prefix`) is injected as-is, and the
web client resolves it against the origin that served the page, so one
gitnexus-web deployment reaches its API on every hostname a reverse
proxy gives it. Protocol-relative values are left to the URL check and
rejected. Documents the two ways to route /api/* behind a proxy.

Closes #3593

* fix(web): reject a backslash-prefixed same-origin path

The URL parser reads a backslash as a slash, so `/\host` resolved against
the page origin became `http://host/`, an origin change. Both the server
check and the client resolution now treat it like `//host`.

* fix(web): keep a configured base ending in /api, and drop the unsupported direct route

normalizeServerUrl stripped a trailing /api from every URL, so a deployment
configured with GITNEXUS_BACKEND_URL=/proxy/api passed health and discovery
and then connected to /proxy/repo. The deployment's own base is now kept as
configured; the /api cleanup stays for hand-entered URLs. A regression covers
health, discovery and repository selection under /proxy/api.

The README no longer offers routing /api/* straight to `gitnexus serve` on a
public hostname: serve refuses to start with GITNEXUS_PUBLIC_ORIGIN set until
it has authentication of its own.

* fix(web): keep only a same-origin base as configured

The bypass in normalizeServerUrl matched any injected default, so an
absolute GITNEXUS_BACKEND_URL ending in /api would have produced a
doubled /api segment, and one carrying credentials would have kept them.
Only a root-relative deployment base is kept now; it is resolved from the
page origin, so it has neither. Absolute injected URLs are normalized as
before.
This commit is contained in:
samuel mbabhazi 2026-10-24 09:45:24 +02:00 • committed by GitHub
parent 69a3ab1d58
commit 0f3bad23b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 200 additions and 1 deletions

View file

@ -14,6 +14,12 @@ WEB_CONTAINER_NAME=gitnexus-web
SERVER_HOST_PORT=4747
WEB_HOST_PORT=4173
# Where the browser reaches the API. Unset: http://localhost:4747. An absolute
# URL for a server on another host; `/` behind a reverse proxy that serves the
# API on the same origin as the UI (see README, "Same-origin API behind a
# reverse proxy").
# GITNEXUS_BACKEND_URL=/
# Optional read-only mount, exposed to the server as /workspace.
# Override with the directory that contains the repos you want to index.
WORKSPACE_DIR=./

View file

@ -34,6 +34,9 @@ services:
# (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://<server-ip>:4747
# Behind a reverse proxy that routes /api/* next to the UI, set it to `/`:
# the browser then calls the API on whichever hostname served the page
# (see "Same-origin API behind a reverse proxy" in the README).
environment:
- GITNEXUS_BACKEND_URL=${GITNEXUS_BACKEND_URL:-}
depends_on:

View file

@ -78,12 +78,26 @@ function numberFromEnv(label, fallback, min = 0) {
return n;
}
// A root-relative value — `/`, or `/prefix` when the API is mounted under a
// path — names no host at all: the browser resolves it against the origin that
// served the page, so one deployment answers on every hostname a reverse proxy
// gives it (#3593). Not a URL, so it is read before validHttpUrl would warn.
// `//host` is protocol-relative, an origin change, and is left to the URL check;
// so is `/\host`, since the URL parser reads a backslash as a slash.
function sameOriginPath(value) {
if (typeof value !== 'string' || !/^\/(?![/\\])/.test(value)) return null;
// A base for `${base}/api/...`: no query, fragment, backslash, whitespace or
// control char.
if (/[?#\\\s\x00-\x1f\x7f]/.test(value)) return null;
return value.replace(/\/+$/, '') || '/';
}
// Falls back to RENDER_EXTERNAL_URL so a Render web service hands the browser
// its own public origin — same-origin API calls via the proxy below, no config.
const backendUrlVar =
process.env.GITNEXUS_BACKEND_URL !== undefined ? 'GITNEXUS_BACKEND_URL' : 'RENDER_EXTERNAL_URL';
const rawBackendUrl = process.env.GITNEXUS_BACKEND_URL ?? process.env.RENDER_EXTERNAL_URL ?? null;
const backendUrl = validHttpUrl(backendUrlVar, rawBackendUrl);
const backendUrl = sameOriginPath(rawBackendUrl) ?? validHttpUrl(backendUrlVar, rawBackendUrl);
const configScript = backendUrl
? `<script>window.__GITNEXUS_CONFIG__=${jsonForScriptTag({ backendUrl })};</script>`
: '';

View file

@ -199,6 +199,47 @@ it('injects __GITNEXUS_CONFIG__ into SPA fallback routes', async () => {
});
});
it('injects a same-origin path when GITNEXUS_BACKEND_URL is /', async () => {
await withInjectionServer({ GITNEXUS_BACKEND_URL: '/' }, async (port) => {
const res = await rawGet(port, '/');
assert.equal(res.status, 200);
assert.ok(res.body.includes('"backendUrl":"/"'), 'Expected the root path as the backend URL');
});
});
it('injects a same-origin prefix without its trailing slash', async () => {
await withInjectionServer({ GITNEXUS_BACKEND_URL: '/gitnexus/' }, async (port) => {
const res = await rawGet(port, '/');
assert.equal(res.status, 200);
assert.ok(
res.body.includes('"backendUrl":"/gitnexus"'),
'Expected the prefix as the backend URL',
);
});
});
it('does not inject a protocol-relative GITNEXUS_BACKEND_URL', async () => {
await withInjectionServer({ GITNEXUS_BACKEND_URL: '//evil.example' }, async (port) => {
const res = await rawGet(port, '/');
assert.equal(res.status, 200);
assert.ok(
!res.body.includes('__GITNEXUS_CONFIG__'),
'Expected no __GITNEXUS_CONFIG__ for a protocol-relative value',
);
});
});
it('does not inject a backslash-prefixed GITNEXUS_BACKEND_URL, which a URL parser reads as a host', async () => {
await withInjectionServer({ GITNEXUS_BACKEND_URL: '/\\evil.example' }, async (port) => {
const res = await rawGet(port, '/');
assert.equal(res.status, 200);
assert.ok(
!res.body.includes('__GITNEXUS_CONFIG__'),
'Expected no __GITNEXUS_CONFIG__ for a backslash-prefixed value',
);
});
});
it('does not inject when GITNEXUS_BACKEND_URL is not set', async () => {
await withInjectionServer({}, async (port) => {
const res = await rawGet(port, '/');

View file

@ -79,6 +79,29 @@ cp .env.example .env
docker compose --env-file .env up -d
```
<details>
<summary><strong>Same-origin API behind a reverse proxy</strong></summary>
`GITNEXUS_BACKEND_URL` is normally an absolute URL, which ties the UI to one API hostname. Set it to `/` instead and the browser calls the API on the origin that served the page — so one `gitnexus-web` container can be reached on several hostnames (an internal `*.localhost` name and a public one, say) as long as `/api/*` is served next to the UI on each of them. A path such as `/gitnexus` works the same way for an API mounted under a prefix.
`/api/*` goes through the web container's own proxy: give `gitnexus-web` `GITNEXUS_UPSTREAM_URL=http://gitnexus-server:4747` and a `GITNEXUS_SERVE_AUTH_TOKEN`; it forwards `/api/*` to the server and ends the browser origin there, so the server's CORS allowlist never sees the public hostnames. The UI asks for the token once. With Traefik, one router for the whole UI is enough:
```yaml
services:
gitnexus-web:
environment:
- GITNEXUS_BACKEND_URL=/
- GITNEXUS_UPSTREAM_URL=http://gitnexus-server:4747
- GITNEXUS_SERVE_AUTH_TOKEN=${GITNEXUS_SERVE_AUTH_TOKEN}
labels:
- traefik.http.routers.gitnexus.rule=Host(`gitnexus.example.com`) || Host(`gitnexus.localhost`)
- traefik.http.services.gitnexus.loadbalancer.server.port=4173
```
Routing `/api/*` straight to `gitnexus serve` on a public hostname is not supported: `serve` has no authentication of its own yet, and it refuses to start with `GITNEXUS_PUBLIC_ORIGIN` set until it does. Reach it through the proxy above.
</details>
Files:
- [Dockerfile.web](../Dockerfile.web) — builds `gitnexus-shared` and `gitnexus-web`, then serves the production frontend.

View file

@ -2,7 +2,30 @@
export const ERROR_RESET_DELAY_MS = 3000;
export const BACKEND_URL_DEBOUNCE_MS = 500;
/**
* A root-relative injected backend (`/`, or `/prefix`) resolved against the
* page's own origin, so the API is reached on whichever hostname served the UI
* (#3593); undefined when nothing was injected or an absolute URL was.
* `//host` and `/\host` (the URL parser reads a backslash as a slash) are
* origin changes, not paths, and are left to the URL validation to reject.
*/
function sameOriginBackendUrl(): string | undefined {
if (typeof window === 'undefined') return undefined;
const configured = window.__GITNEXUS_CONFIG__?.backendUrl;
if (!configured || !/^\/(?![/\\])/.test(configured)) return undefined;
return new URL(configured, window.location.origin).href.replace(/\/+$/, '');
}
/**
* The backend base a same-origin deployment mounted the API under, kept as
* configured by normalizeServerUrl: a prefix such as `/proxy/api` names a
* mount point, not a pasted endpoint. Undefined for an absolute injected URL,
* which is normalized like any other.
*/
export const SAME_ORIGIN_BACKEND_URL = sameOriginBackendUrl();
export const DEFAULT_BACKEND_URL =
SAME_ORIGIN_BACKEND_URL ||
(typeof window !== 'undefined' && window.__GITNEXUS_CONFIG__?.backendUrl) ||
'http://localhost:4747';
export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434';

View file

@ -9,6 +9,7 @@
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared';
import {
SAME_ORIGIN_BACKEND_URL,
AUTH_TOKEN_STORAGE_KEY,
LARGE_GRAPH_NODE_THRESHOLD,
LARGE_GRAPH_EDGE_THRESHOLD,
@ -451,6 +452,12 @@ function stripBackendUrlCredentials(url: string): string {
export function normalizeServerUrl(input: string): string {
let url = input.trim().replace(/\/+$/, '');
// A same-origin deployment's base is kept as configured: a prefix such as
// `/proxy/api` names where the API is mounted, not a pasted endpoint, so the
// `/api` cleanup below is for hand-entered and absolute URLs only (#3593).
// That base is resolved from the page origin, so it carries no credentials.
if (SAME_ORIGIN_BACKEND_URL !== undefined && url === SAME_ORIGIN_BACKEND_URL) return url;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
if (url.startsWith('localhost') || url.startsWith('127.0.0.1')) {
url = `http://${url}`;

View file

@ -2,6 +2,11 @@
interface Window {
__GITNEXUS_CONFIG__?: {
/**
* Base URL of the API server, or a root-relative path (`/`, `/prefix`)
* meaning the origin that served the page. See DEFAULT_BACKEND_URL in
* config/ui-constants.ts and issue #3593.
*/
backendUrl?: string;
/**
* Node-count above which the WebUI connects in chat-only mode by default

View file

@ -375,6 +375,83 @@ describe('DEFAULT_BACKEND_URL resolution', () => {
const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants');
expect(DEFAULT_BACKEND_URL).toBe('http://localhost:4747');
});
it('resolves a root-relative backendUrl against the origin that served the page', async () => {
window.__GITNEXUS_CONFIG__ = { backendUrl: '/' };
const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants');
expect(DEFAULT_BACKEND_URL).toBe(window.location.origin);
});
it('keeps a same-origin prefix under the page origin', async () => {
window.__GITNEXUS_CONFIG__ = { backendUrl: '/gitnexus' };
const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants');
expect(DEFAULT_BACKEND_URL).toBe(`${window.location.origin}/gitnexus`);
});
it('does not resolve a protocol-relative backendUrl as a path', async () => {
window.__GITNEXUS_CONFIG__ = { backendUrl: '//evil.example' };
const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants');
expect(DEFAULT_BACKEND_URL).toBe('//evil.example');
});
it('does not resolve a backslash-prefixed backendUrl, which the URL parser reads as a host', async () => {
window.__GITNEXUS_CONFIG__ = { backendUrl: '/\\evil.example' };
const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants');
expect(DEFAULT_BACKEND_URL).toBe('/\\evil.example');
});
it('keeps a configured prefix ending in /api through health, discovery and repository selection', async () => {
window.__GITNEXUS_CONFIG__ = { backendUrl: '/proxy/api' };
const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants');
const client = await import('../../src/services/backend-client');
const base = `${window.location.origin}/proxy/api`;
expect(DEFAULT_BACKEND_URL).toBe(base);
const json = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
const fetchMock = vi.fn((url: string) => {
if (url.includes('/api/repo?')) {
return Promise.resolve(
json({ name: 'big-repo', path: '/repos/big-repo', indexedAt: '2026-06-13T00:00:00Z' }),
);
}
return Promise.resolve(json([]));
});
vi.stubGlobal('fetch', fetchMock);
client.setBackendUrl(DEFAULT_BACKEND_URL);
expect(await client.probeBackendStatus()).toBe('ok');
expect(await client.fetchRepos()).toEqual([]);
const result = await client.connectToServer(
DEFAULT_BACKEND_URL,
undefined,
undefined,
'big-repo',
{
skipGraph: true,
},
);
expect(result.repoInfo.name).toBe('big-repo');
const requested = fetchMock.mock.calls.map(([u]) => String(u));
expect(requested).toEqual([
`${base}/api/health`,
`${base}/api/repos`,
`${base}/api/repo?repo=big-repo`,
]);
// A hand-entered endpoint URL still loses its /api suffix.
expect(client.normalizeServerUrl('http://localhost:4747/api')).toBe('http://localhost:4747');
});
it('still cleans an absolute configured URL: a trailing /api and credentials', async () => {
window.__GITNEXUS_CONFIG__ = { backendUrl: 'http://user:secret@10.0.0.1:4747/api' };
const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants');
const client = await import('../../src/services/backend-client');
expect(client.normalizeServerUrl(DEFAULT_BACKEND_URL)).toBe('http://10.0.0.1:4747');
});
});
describe('LARGE_GRAPH_NODE_THRESHOLD resolution', () => {