diff --git a/.env.example b/.env.example index 31c8b0883e..3ce1260f97 100644 --- a/.env.example +++ b/.env.example @@ -167,7 +167,21 @@ GITTENSORY_REVIEW_DRAFT=false # # EXACT public URL operators browse to (scheme + host [+ port]). # # Deriving it from the request Host header would let an attacker # # redirect the App-creation callback, so it must be set explicitly. -# # Not needed once the App credentials are configured. +# # Stays load-bearing after setup too: this same origin is embedded +# # as the for every visual-capture screenshot posted to a +# # PR comment (see PUBLIC_SITE_ORIGIN below), so it must be +# # reachable from GitHub's own servers, not just this host or your +# # tailnet. See PUBLIC_ORIGIN_ACKNOWLEDGED below. +# PUBLIC_SITE_ORIGIN=https://your-fork.example.com # "before" screenshots in the visual-capture PR comment +# # table render THIS origin's production pages (vs. PUBLIC_API_ORIGIN, +# # which serves the screenshot images themselves). Unset ⇒ every +# # "Before (production)" cell is blank, not an error — visual capture +# # degrades to after-only, never fails the review. +# PUBLIC_ORIGIN_ACKNOWLEDGED=false # silences the boot-time warning when PUBLIC_API_ORIGIN/ +# # PUBLIC_SITE_ORIGIN look like a private/internal hostname (e.g. a +# # bare Tailscale MagicDNS `*.ts.net` address) once you've confirmed +# # it's genuinely public (Funnel enabled, a reverse proxy in front of +# # it, etc.). Default false (warning shown). # SELFHOST_SETUP_TOKEN=change-this-long-random-value # REQUIRED to unlock the first-run /setup wizard. Without it # # /setup returns 400; with it, enter the token in the browser form # # or send an x-setup-token / Bearer header. Never put this token in diff --git a/.env.selfhost.example b/.env.selfhost.example index 09e3120c42..95220217a4 100644 --- a/.env.selfhost.example +++ b/.env.selfhost.example @@ -27,8 +27,12 @@ GITTENSOR_REGISTRY_URL=https://example.invalid/registry.json # GITTENSORY_MCP_TOKEN= # shared MCP bearer token # INTERNAL_JOB_TOKEN= # gates internal-only routes -# REQUIRED once, for the first-run /setup wizard that creates the GitHub App manifest. Not needed -# after the App credentials above are filled in. +# REQUIRED for the first-run /setup wizard that creates the GitHub App manifest, AND required ongoing: +# this exact origin is embedded as the for every visual-capture screenshot posted to a PR +# comment, so it MUST be reachable from GitHub's own servers, not just from this machine or your +# tailnet — a private/internal hostname here (e.g. a Tailscale MagicDNS `*.ts.net` address with no +# Funnel) makes every screenshot render as a broken image with no other warning besides this instance's +# own boot log (see PUBLIC_ORIGIN_ACKNOWLEDGED below). PUBLIC_API_ORIGIN=https://reviews.example.com # SELFHOST_SETUP_TOKEN= # generate a real random value the same way as above before setting this diff --git a/apps/gittensory-ui/src/lib/selfhost-env-reference.ts b/apps/gittensory-ui/src/lib/selfhost-env-reference.ts index 179ae5dce9..482340970b 100644 --- a/apps/gittensory-ui/src/lib/selfhost-env-reference.ts +++ b/apps/gittensory-ui/src/lib/selfhost-env-reference.ts @@ -325,6 +325,14 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ name: "PUBLIC_API_ORIGIN", firstReference: "src/selfhost/preflight.ts", }, + { + name: "PUBLIC_ORIGIN_ACKNOWLEDGED", + firstReference: "src/server.ts", + }, + { + name: "PUBLIC_SITE_ORIGIN", + firstReference: "src/server.ts", + }, { name: "QDRANT_API_KEY", firstReference: "src/selfhost/qdrant-vectorize.ts", @@ -486,6 +494,8 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `PGVECTOR_ENABLED` | `src/server.ts` |", "| `PORT` | `src/server.ts` |", "| `PUBLIC_API_ORIGIN` | `src/selfhost/preflight.ts` |", + "| `PUBLIC_ORIGIN_ACKNOWLEDGED` | `src/server.ts` |", + "| `PUBLIC_SITE_ORIGIN` | `src/server.ts` |", "| `QDRANT_API_KEY` | `src/selfhost/qdrant-vectorize.ts` |", "| `QDRANT_DIM` | `src/selfhost/qdrant-vectorize.ts` |", "| `QDRANT_URL` | `src/server.ts` |", diff --git a/src/selfhost/health.ts b/src/selfhost/health.ts index b02b6b7b8e..b9daaf0e04 100644 --- a/src/selfhost/health.ts +++ b/src/selfhost/health.ts @@ -155,3 +155,78 @@ export function sqliteBackupAdvisory(opts: { usingSqlite: boolean; backupAcknowl export function backupAcknowledgedGaugeValue(opts: { usingSqlite: boolean; backupAcknowledged: boolean }): 0 | 1 { return sqliteBackupAdvisory(opts) === null ? 1 : 0; } + +// Hostnames that are NEVER reachable from the public internet, regardless of deployment — loopback, RFC1918 +// private ranges, and the mDNS/internal-DNS conventions no public resolver honors. Deliberately excludes +// Tailscale's own *.ts.net MagicDNS suffix from this "never" list: a node with Funnel explicitly enabled DOES +// serve that exact hostname over public HTTPS, so *.ts.net gets its own softer check below rather than being +// treated as definitely non-public. +function isDefinitelyPrivateHostname(hostname: string): boolean { + const host = hostname.toLowerCase(); + // URL.hostname keeps the brackets on an IPv6 literal ("[::1]", not "::1") — compare against the bracketed + // form, not the bare address. + if (host === "localhost" || host === "0.0.0.0" || host === "[::1]") return true; + if (host.endsWith(".local") || host.endsWith(".internal")) return true; + const ipv4 = /^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/.exec(host); + if (ipv4) { + const first = Number(ipv4[1]); + const second = Number(ipv4[2]); + if (first === 127 || first === 10) return true; + if (first === 192 && second === 168) return true; + if (first === 172 && second >= 16 && second <= 31) return true; + } + return false; +} + +/** True when `origin` parses as a URL whose hostname is a well-known non-public pattern (loopback, RFC1918, + * mDNS/`.internal`), OR a bare Tailscale MagicDNS hostname (`*.ts.net`) — publicly reachable ONLY when the + * operator has explicitly enabled Tailscale Funnel for that node, which this check has no way to observe. + * An unparseable value is this function's job to reject as "not a URL at all", not "looks non-public" — but + * that is a distinct, well-formedness problem this advisory doesn't cover (PUBLIC_API_ORIGIN's own + * first-run-setup preflight check already validates that shape; nothing else currently reads + * PUBLIC_SITE_ORIGIN at boot), so it's silently false here rather than double-reported. */ +function looksNonPublic(origin: string): boolean { + try { + const { hostname } = new URL(origin); + return isDefinitelyPrivateHostname(hostname) || hostname.toLowerCase().endsWith(".ts.net"); + } catch { + return false; + } +} + +/** Boot-time advisory (JSONbored/gittensory PR #4180's live bug): `PUBLIC_API_ORIGIN`/`PUBLIC_SITE_ORIGIN` get + * embedded VERBATIM as `` in the public "Visual preview" PR comment table (see + * `src/review/visual/capture.ts`) — a value GitHub's own servers, not this instance, must be able to fetch. + * `PUBLIC_API_ORIGIN`'s existing preflight check (see `isBareHttpsOrigin` above) only confirms it's a + * WELL-FORMED bare https origin — a private tailnet hostname like `https://node.example.ts.net` passes that + * check fine while still being completely unfetchable by GitHub, so every screenshot silently renders as a + * broken image with no operator-visible signal until a human notices. This never hard-fails boot (unlike + * `assertSelfHostPreflight`'s checks): a false positive here (e.g. a legitimately Funnel-exposed `*.ts.net` + * node) would only be a degraded review feature, not a security or data-loss risk, so — mirroring + * {@link sqliteBackupAdvisory}'s own acknowledgment escape hatch — it warns rather than blocks, and the + * operator can silence it with `PUBLIC_ORIGIN_ACKNOWLEDGED=true` once they've confirmed the origin really is + * public (Funnel enabled, a reverse proxy in front of it, etc.). Neither var set is left alone: visual + * capture degrades to dash cells in that case (see capture.ts), not a broken image, so there's nothing to warn + * about until an operator sets one of these to something that looks wrong. */ +export function publicOriginReachabilityAdvisory(opts: { + publicApiOrigin: string | undefined; + publicSiteOrigin: string | undefined; + acknowledged: boolean; +}): string | null { + if (opts.acknowledged) return null; + const suspect = [opts.publicApiOrigin, opts.publicSiteOrigin] + .filter((value): value is string => Boolean(value?.trim())) + .find((value) => looksNonPublic(value)); + if (!suspect) return null; + return `PUBLIC_API_ORIGIN/PUBLIC_SITE_ORIGIN includes "${suspect}", which looks like a private/internal hostname — GitHub's servers cannot fetch it, so visual-capture screenshots embedded in PR comments will render as broken images. Set it to a real publicly-reachable origin (or, if this host has Tailscale Funnel enabled and IS genuinely public, set PUBLIC_ORIGIN_ACKNOWLEDGED=true to silence this warning).`; +} + +/** Prometheus gauge value mirroring {@link publicOriginReachabilityAdvisory}: 1 when no suspect origin is + * configured (or the operator acknowledged it), 0 when the advisory would fire. */ +export function publicOriginAcknowledgedGaugeValue(opts: { + publicApiOrigin: string | undefined; + publicSiteOrigin: string | undefined; + acknowledged: boolean; +}): 0 | 1 { + return publicOriginReachabilityAdvisory(opts) === null ? 1 : 0; +} diff --git a/src/server.ts b/src/server.ts index 44198a8f90..efedbc5e03 100644 --- a/src/server.ts +++ b/src/server.ts @@ -47,6 +47,8 @@ import { buildHealthBody, codexAuthReadinessProbe, githubAppReadinessProbe, + publicOriginAcknowledgedGaugeValue, + publicOriginReachabilityAdvisory, readiness, sqliteBackupAdvisory, type ReadinessProbe, @@ -392,6 +394,24 @@ async function main(): Promise { }), ); + // Public-origin advisory (JSONbored/gittensory#4180): warn LOUDLY at boot if PUBLIC_API_ORIGIN/ + // PUBLIC_SITE_ORIGIN look like a private/internal hostname, so an operator doesn't run for weeks with every + // visual-capture screenshot silently rendering as a broken image in public PR comments. + const publicOriginOpts = { + publicApiOrigin: process.env.PUBLIC_API_ORIGIN, + publicSiteOrigin: process.env.PUBLIC_SITE_ORIGIN, + acknowledged: process.env.PUBLIC_ORIGIN_ACKNOWLEDGED === "true", + }; + const publicOriginAdvisory = publicOriginReachabilityAdvisory(publicOriginOpts); + if (publicOriginAdvisory) + console.warn( + JSON.stringify({ + level: "warn", + event: "selfhost_public_origin_advisory", + message: publicOriginAdvisory, + }), + ); + const applied = await runSelfHostMigrations( backend.db, process.env.MIGRATIONS_DIR ?? "migrations", @@ -701,6 +721,7 @@ async function main(): Promise { Math.floor((Date.now() - startedAt) / 1000), ); gauge("gittensory_backup_acknowledged", () => backupAcknowledgedGaugeValue(sqliteBackupOpts)); + gauge("gittensory_public_origin_acknowledged", () => publicOriginAcknowledgedGaugeValue(publicOriginOpts)); // Pre-initialize job counters to 0 so they appear in the first Prometheus scrape (lazy counters // created on first use would otherwise cause "No data" in Grafana until the first job event). for (const c of [ diff --git a/test/unit/selfhost-health.test.ts b/test/unit/selfhost-health.test.ts index 65a386a483..9f8a394feb 100644 --- a/test/unit/selfhost-health.test.ts +++ b/test/unit/selfhost-health.test.ts @@ -9,6 +9,8 @@ import { buildHealthBody, codexAuthReadinessProbe, githubAppReadinessProbe, + publicOriginAcknowledgedGaugeValue, + publicOriginReachabilityAdvisory, readiness, sqliteBackupAdvisory, } from "../../src/selfhost/health"; @@ -219,6 +221,91 @@ describe("backupAcknowledgedGaugeValue (#2089)", () => { }); }); +describe("publicOriginReachabilityAdvisory (#4180)", () => { + it("is silent when acknowledged, regardless of how bad the origin looks", () => { + expect( + publicOriginReachabilityAdvisory({ + publicApiOrigin: "https://node.raccoon-bushi.ts.net", + publicSiteOrigin: undefined, + acknowledged: true, + }), + ).toBeNull(); + }); + + it("is silent when neither origin is set, or both are ordinary public https origins", () => { + expect(publicOriginReachabilityAdvisory({ publicApiOrigin: undefined, publicSiteOrigin: undefined, acknowledged: false })).toBeNull(); + expect(publicOriginReachabilityAdvisory({ publicApiOrigin: " ", publicSiteOrigin: undefined, acknowledged: false })).toBeNull(); + expect( + publicOriginReachabilityAdvisory({ + publicApiOrigin: "https://reviews.example.com", + publicSiteOrigin: "https://example.com", + acknowledged: false, + }), + ).toBeNull(); + }); + + it("is silent on an unparseable value — a well-formedness problem, not this advisory's job", () => { + expect( + publicOriginReachabilityAdvisory({ publicApiOrigin: "not a url", publicSiteOrigin: undefined, acknowledged: false }), + ).toBeNull(); + }); + + it("regression: warns on the exact PR #4180 shape — a bare Tailscale MagicDNS origin", () => { + const message = publicOriginReachabilityAdvisory({ + publicApiOrigin: "https://edge-us-01.raccoon-bushi.ts.net", + publicSiteOrigin: undefined, + acknowledged: false, + }); + expect(message).toMatch(/edge-us-01\.raccoon-bushi\.ts\.net/); + expect(message).toMatch(/PUBLIC_ORIGIN_ACKNOWLEDGED/); + }); + + it("checks PUBLIC_SITE_ORIGIN too, not just PUBLIC_API_ORIGIN", () => { + expect( + publicOriginReachabilityAdvisory({ + publicApiOrigin: "https://reviews.example.com", + publicSiteOrigin: "http://localhost:3000", + acknowledged: false, + }), + ).toMatch(/localhost/); + }); + + it("flags loopback, RFC1918 ranges, mDNS, and .internal — but not a normal public IP or domain", () => { + const bad = [ + "http://localhost", + "http://0.0.0.0", + "http://[::1]", + "https://node.local", + "https://svc.internal", + "http://127.0.0.1", + "http://10.0.0.5", + "http://192.168.1.1", + "http://172.20.0.5", + ]; + for (const publicApiOrigin of bad) { + expect(publicOriginReachabilityAdvisory({ publicApiOrigin, publicSiteOrigin: undefined, acknowledged: false })).not.toBeNull(); + } + const fine = ["https://8.8.8.8", "http://172.15.0.5", "http://172.32.0.5", "https://reviews.example.com"]; + for (const publicApiOrigin of fine) { + expect(publicOriginReachabilityAdvisory({ publicApiOrigin, publicSiteOrigin: undefined, acknowledged: false })).toBeNull(); + } + }); +}); + +describe("publicOriginAcknowledgedGaugeValue (#4180)", () => { + it("mirrors the advisory: 0 only when a suspect origin is unacknowledged", () => { + expect( + publicOriginAcknowledgedGaugeValue({ publicApiOrigin: "https://node.ts.net", publicSiteOrigin: undefined, acknowledged: false }), + ).toBe(0); + expect( + publicOriginAcknowledgedGaugeValue({ publicApiOrigin: "https://node.ts.net", publicSiteOrigin: undefined, acknowledged: true }), + ).toBe(1); + expect( + publicOriginAcknowledgedGaugeValue({ publicApiOrigin: "https://reviews.example.com", publicSiteOrigin: undefined, acknowledged: false }), + ).toBe(1); + }); +}); + describe("readiness (#982)", () => { afterEach(() => { vi.restoreAllMocks();