From 356032e2f6166eaeba6aaf30e1770bf088a530ea Mon Sep 17 00:00:00 2001 From: Shifra Williams Date: Wed, 5 Aug 2026 15:08:20 -0700 Subject: [PATCH] feat(render): private-service topology, docs, and a CI check on render.yaml (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(render): private-service topology, docs, and a CI check on render.yaml Rewrites the Blueprint around the topology Abhigyan called for: `serve` on a private service, the public web service reverse-proxying /api/* to it, and the generated token gating that edge. `gitnexus-server` becomes a `pserv`, so it has no public URL. `gitnexus-web` gets `GITNEXUS_UPSTREAM_URL` from `fromService … property: hostport`, `GITNEXUS_SERVE_AUTH_TOKEN` with `generateValue: true`, and `GITNEXUS_PROXY_TRUST_XFF: 1` for Render's load balancer. `autoDeploy: true` becomes `autoDeployTrigger: off` so a push cannot restart an indexing server mid-analyze. Four variables come out: - `GITNEXUS_PUBLIC_ORIGIN`: under the proxy the browser is same-origin with the web service and Origin is stripped, so it has no job — and since #2820 `assertServeAuthForPublicOrigin` refuses to start when it is set without serve-native auth. The old topology no longer boots. - `GITNEXUS_BACKEND_URL`: docker-server.mjs falls back to RENDER_EXTERNAL_URL, which is this service's own origin. - `AZURE_DEVOPS_URL` / `AZURE_DEVOPS_PAT`: per Abhigyan, "a stranger can POST /api/analyze an Azure URL, have the server spend the operator's credential, then read the private repo back through the ungated /api/file and /api/grep." The edge token closes the stranger path, but the knob still should not ship in a one-click prompt. Operators who want Azure DevOps set both in the dashboard, on a deploy they have already secured. README drops "with no API keys required" — it described missing authentication as a convenience, and the deploy now generates a token — and replaces it with the token-retrieval flow. It also corrects `plan` versus `sizeGB`: `plan` sets RAM, `sizeGB` only matters when the disk fills. Both the README and a new SECURITY.md section state plainly that the proxy strips Origin, so the CSRF guard does nothing for proxied traffic and the token is the only control — not a second layer behind the guard. SECURITY.md enumerates the routes a token holder reaches with no origin guard, and notes that `POST /api/mcp` has no `authToken` of its own as a pre-existing gap in `serve`. Nothing in CI parsed render.yaml, so the couplings the file documents were unenforced. `gitnexus/test/unit/render-blueprint.test.ts` asserts them: mountPath equals GITNEXUS_HOME, PORT matches Dockerfile.cli's CMD, the server stays a `pserv`, GITNEXUS_PUBLIC_ORIGIN appears in no YAML, the token is generated, no AZURE_DEVOPS_* key, and the schema pragma is intact. Each was confirmed to fail when its invariant is deliberately broken. Co-Authored-By: Claude Opus 5 * docs(readme): state what the Render deploy costs The Deploy to Render section described the topology and the token but not the bill. At the Blueprint's defaults it is ~$35/month: $25 for the server's `standard` instance, $7 for the web service's `starter`, $2.50 for the 10 GB disk. Neither service scales to zero, which matters more than the number — an idle deploy costs the same as a busy one. Also trims the section's em dashes and drops "access token" to "token" where the antecedent is one sentence away. Co-Authored-By: Claude Opus 5 * chore: update readme --------- Co-authored-by: Claude Opus 5 --- README.md | 16 ++- SECURITY.md | 13 ++ gitnexus/test/unit/render-blueprint.test.ts | 144 ++++++++++++++++++++ render.yaml | 84 +++++++----- 4 files changed, 219 insertions(+), 38 deletions(-) create mode 100644 gitnexus/test/unit/render-blueprint.test.ts diff --git a/README.md b/README.md index b63c98145..d6534f658 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,21 @@ Deploy GitNexus in one click: [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/abhigyanpatwari/GitNexus) -Render provisions the API server, a persistent disk for indexes and cloned repos, and the web UI pointed at it, with no API keys required. +The Blueprint creates two services. `gitnexus-server` runs `gitnexus serve` as a private service: no public URL, reachable only over Render's private network, with a persistent disk for indexes and cloned repos. `gitnexus-web` is the public one. It serves the UI and reverse-proxies `/api/*` to the server, so the browser talks to a single origin. -Indexing is memory-bound, so if `gitnexus-server` runs out of memory on a large repo, raise its `plan` and `sizeGB`. +At the Blueprint's defaults this runs about **$35/month**: $25 for the server's `standard` instance, $7 for the web service's `starter` instance, and $2.50 for the 10 GB disk. See [Render's pricing](https://render.com/pricing) for other plans. + +The deploy generates an access token, and the UI asks for it on first use: + +1. Open the `gitnexus-web` service in your [Render dashboard](https://dashboard.render.com/). +2. Copy `GITNEXUS_SERVE_AUTH_TOKEN` from its **Environment** tab. +3. Load the site and paste the token into the prompt (or the settings panel). + +Every `/api/*` request carries that token as a header, and the proxy answers `401` without it. The browser keeps it in `sessionStorage`, so a new tab asks again. To rotate it, edit the environment variable and redeploy. + +The proxy strips `Origin` before forwarding, so the server's CSRF guard does nothing for proxied traffic; it passes `Origin`-less requests through by design. The token is the only control on this deploy, not a second layer behind the guard. Anyone holding it can read every indexed repo. See [SECURITY.md](SECURITY.md#hosted-deploys-on-render). + +Indexing is memory-bound. If `gitnexus-server` runs out of memory on a large repo, raise its `plan`, which sets available RAM: `standard` is 2 GB, `pro` is 4 GB. Raise `sizeGB` only if the disk fills with clones and indexes. ## Two Ways to Use GitNexus diff --git a/SECURITY.md b/SECURITY.md index 79ef97f6b..d1fbcd051 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -51,6 +51,19 @@ If you fork GitNexus or self-host it, we recommend enabling the following in you - **Secret scanning** and **Push protection** — blocks pushes that introduce known secret patterns. Defense-in-depth on top of the in-CI Gitleaks scan documented below. - **Code scanning** — surfaces SARIF results from CodeQL, Trivy, Scorecard, and zizmor in one place. +### Hosted Deploys on Render + +The `render.yaml` Blueprint (see the README's **Deploy to Render**) puts `gitnexus serve` on a **private service** with no public URL, and a public web service in front of it that reverse-proxies `/api/*`. What that does and does not protect: + +- **The web service is public and its URL is discoverable.** `onrender.com` hostnames appear in certificate transparency logs. Treat the URL as known rather than secret. +- **The generated `GITNEXUS_SERVE_AUTH_TOKEN` is the only access control.** The proxy rejects any `/api/*` request without it with a `401` before forwarding. Rotate it by editing the environment variable on the `gitnexus-web` service and redeploying. +- **The CSRF guard is inert on this path.** The proxy strips `Origin` before forwarding, so the server's write-origin guard does nothing for proxied traffic — it passes `Origin`-less requests through by design. The token is not a second layer behind the guard. +- **Anyone holding the token can read every indexed repo's source.** These routes carry no origin guard, and the first three carry no rate limiter either: `GET /api/repos`, `GET /api/graph`, `POST /api/query`, `GET /api/file`, `GET /api/grep`. Whoever has the token can also index and delete repositories. +- **`POST /api/mcp` rides the same path.** `serve` mounts the MCP handler via `mountMCPEndpoints`, and `createStreamableHttpHandler` is called with no `authToken` — a **pre-existing** gap in `serve` itself, not something this deploy introduces. On Render it is closed only by the edge token and the private network. A `serve` bound directly to a public interface has no such cover. +- **Rate limits bound cost, not access.** They cap what a token holder can spend; they do not decide who gets in. + +Do not hand the URL out as a public demo. A token holder has read access to everything the deploy has indexed. + ## Automated Scans Running in CI This repository runs the following scans automatically. Findings appear under the repository's **Security → Code scanning** tab. diff --git a/gitnexus/test/unit/render-blueprint.test.ts b/gitnexus/test/unit/render-blueprint.test.ts new file mode 100644 index 000000000..d464160a3 --- /dev/null +++ b/gitnexus/test/unit/render-blueprint.test.ts @@ -0,0 +1,144 @@ +/** + * Invariants of the repo-root `render.yaml`, which nothing else in CI parses. + * + * Two kinds: couplings the Blueprint documents but cannot enforce + * (`disk.mountPath` = `GITNEXUS_HOME`, `PORT` = the port `Dockerfile.cli` binds), + * and the choices that keep the deploy closed (the API server is private, the + * public proxy always has a token). Without these, a plausible-looking edit puts + * the API back on the internet with every other test green. + * + * Full schema conformance is `render blueprints validate render.yaml`, which + * needs network and an account; checked here is only the pragma pointing at it. + */ + +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { load } from 'js-yaml'; + +const monorepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const blueprintSource = readFileSync(path.join(monorepoRoot, 'render.yaml'), 'utf8'); +const dockerfileSource = readFileSync(path.join(monorepoRoot, 'Dockerfile.cli'), 'utf8'); + +const SCHEMA_PRAGMA = '# yaml-language-server: $schema=https://render.com/schema/render.yaml.json'; + +interface EnvVar { + key: string; + value?: string | number; + generateValue?: boolean; + fromService?: { type?: string; name?: string; property?: string }; +} + +interface Service { + type: string; + name: string; + envVars?: EnvVar[]; + disk?: { mountPath?: string }; +} + +interface Blueprint { + projects?: { environments?: { services?: Service[] }[] }[]; + services?: Service[]; +} + +const blueprint = load(blueprintSource) as Blueprint; + +/** Services sit under projects → environments, or at the top level. */ +const services: Service[] = [ + ...(blueprint.services ?? []), + ...(blueprint.projects ?? []).flatMap((project) => + (project.environments ?? []).flatMap((environment) => environment.services ?? []), + ), +]; + +/** + * Every key and value in the document, comments excluded by construction — + * parsing drops them. The Blueprint documents in comments what it deliberately + * does NOT set, and those comments are what stop the next editor re-adding it, + * so a mention in prose is the documentation and a mention here is the bug. + * Covers shapes the interfaces above don't model (envVarGroups, dockerCommand). + */ +const blueprintData = JSON.stringify(blueprint); + +const envVarKeys = services.flatMap((service) => (service.envVars ?? []).map((entry) => entry.key)); + +const SERVER = 'gitnexus-server'; +const WEB = 'gitnexus-web'; + +/** Looked up per test, so a rename fails each assertion with its own message. */ +function serviceNamed(name: string): Service { + const match = services.find((service) => service.name === name); + if (!match) { + throw new Error( + `render.yaml has no service named "${name}". If the rename is intentional, ` + + 'update this test — the invariants below are about that service.', + ); + } + return match; +} + +function envVar(service: Service, key: string): EnvVar | undefined { + return (service.envVars ?? []).find((entry) => entry.key === key); +} + +/** Absent as an env var name, and absent anywhere else in the document. */ +function expectNeverSet(pattern: RegExp): void { + expect(envVarKeys.filter((key) => pattern.test(key))).toEqual([]); + expect(blueprintData).not.toMatch(pattern); +} + +describe('render.yaml ↔ Dockerfile.cli couplings', () => { + it('mounts the disk at GITNEXUS_HOME', () => { + // One key of a multi-line `ENV`. + const home = /^\s*(?:ENV\s+)?GITNEXUS_HOME=(\S+)/m.exec(dockerfileSource)?.[1]; + expect(home, 'Dockerfile.cli must set GITNEXUS_HOME').toBeTruthy(); + expect(serviceNamed(SERVER).disk?.mountPath).toBe(home); + }); + + it('routes to the port the image binds', () => { + // ... --port \"${PORT:-4747}\" — the backslashes are literal in the CMD. + const dockerfilePort = /--port\s+\\?"?\$\{PORT:-(\d+)\}/.exec(dockerfileSource)?.[1]; + expect(dockerfilePort, "Dockerfile.cli's CMD must bind a default port").toBeTruthy(); + const port = envVar(serviceNamed(SERVER), 'PORT')?.value; + expect(port, 'render.yaml must set PORT on gitnexus-server').toBeDefined(); + expect(String(port)).toBe(dockerfilePort); + }); +}); + +describe('render.yaml topology', () => { + it('keeps the API server off the internet', () => { + // `web` would give `serve` a public URL, and `serve` has no auth of its own. + expect(serviceNamed(SERVER).type).toBe('pserv'); + }); + + it('never sets GITNEXUS_PUBLIC_ORIGIN', () => { + // `serve` refuses to start with it set and no serve-native auth + // (assertServeAuthForPublicOrigin), and behind the proxy it has no job. + expectNeverSet(/GITNEXUS_PUBLIC_ORIGIN/); + }); + + it('ships no Azure DevOps credential knob', () => { + // A token holder could POST /api/analyze an Azure URL, spend the operator's + // PAT, then read the private repo back through /api/file and /api/grep. + expectNeverSet(/AZURE_DEVOPS_/); + }); + + it('points the proxy at the private server over the private network', () => { + expect(envVar(serviceNamed(WEB), 'GITNEXUS_UPSTREAM_URL')?.fromService).toMatchObject({ + type: 'pserv', + name: SERVER, + property: 'hostport', + }); + }); + + it('always generates an edge token for the public proxy', () => { + // docker-server.mjs refuses to start with an upstream and no token, so this + // line is what makes the one-click deploy authenticated rather than broken. + expect(envVar(serviceNamed(WEB), 'GITNEXUS_SERVE_AUTH_TOKEN')?.generateValue).toBe(true); + }); + + it('declares the schema so editors and `render blueprints validate` find it', () => { + expect(blueprintSource.split('\n')[0]).toBe(SCHEMA_PRAGMA); + }); +}); diff --git a/render.yaml b/render.yaml index c0f8dbdb8..4e5e8e60e 100644 --- a/render.yaml +++ b/render.yaml @@ -1,61 +1,62 @@ # yaml-language-server: $schema=https://render.com/schema/render.yaml.json # -# One-click deploy of GitNexus: an API server and the web UI that calls it. -# fromService gives each the other's public URL, so there is nothing to paste. +# One-click GitNexus deploy: a private API server, plus the public web UI that +# proxies /api/* to it over Render's private network. # -# SECURITY: this deploy is UNAUTHENTICATED, and onrender.com hostnames appear in -# certificate transparency logs. Anyone who finds the URL can index and delete -# repos. Rate limits bound the cost; they are not access control. +# SECURITY: the web service's URL is discoverable (onrender.com names appear in +# CT logs). The generated GITNEXUS_SERVE_AUTH_TOKEN is the only access control — +# the proxy strips Origin, so the server's CSRF guard sees no proxied traffic, +# and rate limits bound cost, not access. Anyone holding the token can read +# every indexed repo. See SECURITY.md § Hosted Deploys on Render. previews: - generation: off + generation: 'off' projects: - name: gitnexus environments: - name: production services: - - type: web + # Private: no public URL. `serve` has no authentication of its own. + - type: pserv name: gitnexus-server runtime: docker + # Private networking needs one shared region. region: oregon - # Indexing OOMs on the 512MB starter. standard (2GB) covers - # small/mid repos; bump to pro (4GB) or larger for a big one. + # plan sets RAM: standard 2GB, pro 4GB. Indexing is memory-bound. plan: standard dockerfilePath: ./Dockerfile.cli dockerContext: . - # No dockerCommand — the image's CMD already binds the port below, - # and Render re-wraps dockerCommand in a shell, so `sh -c` exits 127. - healthCheckPath: /api/health - autoDeploy: true + # No dockerCommand — the image's CMD already binds $PORT, and Render + # re-wraps dockerCommand in a shell, so `sh -c` exits 127. + # No healthCheckPath either: Render rejects one on a pserv. The + # proxy's connect-retry covers the restart window instead. + # + # Don't restart an indexing server on a push. + autoDeployTrigger: 'off' # mountPath must equal GITNEXUS_HOME in Dockerfile.cli. disk: name: gitnexus-data mountPath: /data/gitnexus sizeGB: 10 envVars: - # Render routes to this port; Dockerfile.cli's CMD binds 4747. + # Dockerfile.cli's CMD binds this. - key: PORT value: 4747 - # Required. The container binds 0.0.0.0, which has no host - # identity, so without this the UI's writes fail the same-host - # origin check. - - key: GITNEXUS_PUBLIC_ORIGIN - fromService: - type: web - name: gitnexus-web - envVarKey: RENDER_EXTERNAL_URL - # 1, not true: Render's LB is one hop, and true would trust a - # spoofed X-Forwarded-For and hand the rate limiter a fake IP. + # One hop: gitnexus-web's proxy, which writes X-Forwarded-For + # itself. A count rather than the default ranges (loopback, + # linklocal, uniquelocal), which assume Render's private network + # is RFC1918. `true` is rejected outright. - key: GITNEXUS_TRUST_PROXY value: 1 - # Optional Azure DevOps integration; leave unset to skip. Any - # visitor can use this PAT (see SECURITY above). - - key: AZURE_DEVOPS_URL - sync: false - - key: AZURE_DEVOPS_PAT - sync: false + # Deliberately unset: GITNEXUS_PUBLIC_ORIGIN, which has no job + # behind the proxy and makes `serve` refuse to start + # (assertServeAuthForPublicOrigin); AZURE_DEVOPS_URL / + # AZURE_DEVOPS_PAT, since /api/analyze would spend the PAT and + # /api/file could read the private repo back. Set the Azure pair + # in the dashboard if you want it. + # Public: the UI, plus a token-gated same-origin proxy to the server. - type: web name: gitnexus-web runtime: docker @@ -64,12 +65,23 @@ projects: dockerfilePath: ./Dockerfile.web dockerContext: . healthCheckPath: / - autoDeploy: true + autoDeployTrigger: 'off' envVars: - # Injected into the page as window.__GITNEXUS_CONFIG__.backendUrl, - # so a first-time visitor's UI targets this deploy, not localhost. - - key: GITNEXUS_BACKEND_URL + # host:port, scheme-less; the proxy prefixes http://. Set without + # the token below, docker-server.mjs refuses to start. + - key: GITNEXUS_UPSTREAM_URL fromService: - type: web + type: pserv name: gitnexus-server - envVarKey: RENDER_EXTERNAL_URL + property: hostport + # Bearer token for every /api/* request. Copy it from this + # service's Environment tab into the UI's settings panel; rotate by + # editing it here and redeploying. + - key: GITNEXUS_SERVE_AUTH_TOKEN + generateValue: true + # Render's load balancer is the one hop in front and appends the + # real peer. The proxy ignores inbound XFF by default. + - key: GITNEXUS_PROXY_TRUST_XFF + value: 1 + # Deliberately unset: GITNEXUS_BACKEND_URL. docker-server.mjs falls + # back to RENDER_EXTERNAL_URL, this service's own origin.