Skip to content

feat(server): serve UI and API under a configurable URL path prefix (--ui-base-path)#254

Open
basnijholt wants to merge 2 commits into
Infisical:mainfrom
basnijholt:feat/ui-base-path
Open

feat(server): serve UI and API under a configurable URL path prefix (--ui-base-path)#254
basnijholt wants to merge 2 commits into
Infisical:mainfrom
basnijholt:feat/ui-base-path

Conversation

@basnijholt

@basnijholt basnijholt commented Jun 11, 2026

Copy link
Copy Markdown

Closes #253

Summary

Adds first-class support for serving the web UI (and /v1 API) under a URL path prefix behind a reverse proxy — agent-vault server --ui-base-path /vault (env AGENT_VAULT_UI_BASE_PATH). Today this requires a content-rewriting proxy (sub_filter on the minified bundle, hand-injected router basepath, manual cache-busting) that silently breaks on every rebuild; see #253 for the full pain.

Design — path passthrough, runtime injection:

  • The server mounts its entire surface under the prefix (outer mux + http.StripPrefix); the proxy forwards the path unmodified. X-Forwarded-Prefix is deliberately not honored — the prefix is part of the real request path. GET / redirects to the prefix; GET /health stays at the root for platform probes (and --detach's readiness poll).
  • Vite builds with relative asset URLs (base: "./"); index.html carries a <base href="/" /> placeholder that the server rewrites once at startup (internal/server/basepath.go). The SPA reads the <base> tag at boot to set TanStack Router's basepath and prefix all /v1 calls — no inline script, so the script-src 'self' CSP is untouched, and one published image works for any prefix.
  • Only the unhashed index.html ever varies; content-hashed assets are served byte-for-byte, keeping immutable caching valid. With the option unset, index.html is served byte-identical to the build output (regression-tested).
  • Session cookies are scoped to Path={prefix}/; s.baseURL gets the prefix appended (no double-append) so invite emails, proposal approval links, OAuth redirect/redirect_uri, and the av_addr handed to agents all include it. The CLI already joins paths onto --address, so --address https://host/vault works as-is. The MITM ingress (separate listener) is unaffected.

Drive-by fix: the mux never routed /fonts/ or /favicon.* — webfonts and favicons 404ed even at the domain root. Now served.

Most of the diff is tests, docs, and package-lock.json churn from adding vitest; the runtime change is ~130 lines of Go and ~40 lines of TS. Happy to split the vitest setup or the static-file fix into separate PRs if preferred.

Type of change

  • New feature
  • Bug fix (/fonts/, /favicon.* 404)
  • Documentation

Test plan

  • Existing tests pass (make test — full suite with -race -count=1 and CI's stubbed-webdist setup; golangci-lint v2.11 clean for the changed code; go mod tidy no-op; npm ci && npm run build green)
  • Added/updated tests for new behavior
    • Go (internal/server/basepath_test.go): NormalizeBasePath validation, index.html injection (incl. root-mode byte-identity), full-stack routing under the prefix (API + SPA + redirects + unprefixed-404 + root /health), cookie Path on login/logout, baseURL prefix-append; --ui-base-path flag smoke test in cmd/cmd_test.go
    • Web: vitest unit tests for base-href parsing and API URL joining (npm test in web/)
  • Manual testing (describe below)

Ran the built binary with --ui-base-path /vault in a throwaway $HOME and verified with curl/CLI: index and deep SPA routes serve <base href="/vault/">; hashed JS/CSS, favicons, and fonts return 200 under the prefix; / → 302 /vault/; unprefixed /v1/status → 404; register → login sets Path=/vault/; vault list, credential set/list/delete, and scoped-token minting all work; agent-mode CLI works with AGENT_VAULT_ADDR=http://…/vault (vault discover); av_addr comes back with the prefix. Root-mode and Vite dev server regression-checked the same way. Docs include a rewrite-free nginx location block (docs/self-hosting/path-prefix.mdx) and a deployment verification checklist.

Note for operators: OAuth apps registered against a previously root-mounted instance need their redirect URI updated to include the prefix, since redirect_uri derives from the (now prefixed) base URL.

Security checklist

  • No secrets or credentials in code
  • No new unauthenticated endpoints (the prefix remounts existing routes; the only additions are two redirects and root /health, which already existed)
  • Input validation on new API surfaces (NormalizeBasePath rejects empty/./.. segments and ?/#/whitespace; the value is operator-supplied config, not request data)
  • Checked for OWASP top 10 — the injected <base href> is built from the validated operator flag only, never from request input, so no header/URL-driven base-tag injection; CSP unchanged (script-src 'self', no inline script); cookie scoping tightened to the prefix

AI disclaimer

I used Claude Fable 5 for help with the development.

Add --ui-base-path (env AGENT_VAULT_UI_BASE_PATH) so the server mounts its
entire surface under a prefix (e.g. /vault) behind a reverse proxy that
passes the path through unmodified — no sub_filter or path rewriting.

The Vite build now uses relative asset URLs (base "./") and index.html
carries a <base href="/" /> placeholder that the server rewrites once at
startup; the SPA derives its TanStack Router basepath and /v1 API prefix
from that tag at runtime, so one published image works for any prefix.
Hashed assets are never rewritten, keeping immutable caching valid. With
the option unset, index.html is served byte-for-byte as built.

Session cookies are scoped to the prefix, generated URLs (invites,
approval links, OAuth redirects, av_addr) include it, GET / redirects to
the prefix, and GET /health stays at the root for platform probes.

Also fixes /fonts/ and /favicon.* never being routed (webfonts and
favicons 404ed even at the root).

Web tests run under vitest (npm test).
@greptile-apps

greptile-apps Bot commented Jun 11, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds first-class support for mounting the Agent Vault UI and /v1 API under a configurable URL path prefix (--ui-base-path / AGENT_VAULT_UI_BASE_PATH), solving the longstanding reverse-proxy pain of requiring content-rewriting proxies. The mechanism is clean: Vite builds with relative asset URLs, the Go server rewrites only the unhashed index.html's <base href> at startup, and the SPA derives its router basepath and API prefix from that tag at runtime.

  • NormalizeBasePath in basepath.go validates the operator-supplied prefix; injectBasePath rewrites the single index.html placeholder; the outer mux strips the prefix before delegating to the existing inner mux, preserving all existing route handling while keeping /health at the domain root.
  • apiFetch in api.ts prepends basePath to all root-relative calls, and the remaining raw fetch() calls in VaultLayout and Navbar are migrated to go through it.
  • A drive-by bug fix correctly registers /fonts/ and /favicon.* routes that previously returned 404 even at the domain root.

Confidence Score: 4/5

Safe to merge after addressing the missing HTML-unsafe character rejection in NormalizeBasePath; all other paths are well-tested.

The NormalizeBasePath validator does not reject double-quote, less-than, or greater-than characters, and injectBasePath places the value directly into an HTML attribute string. A quote character would silently break the injected base tag and cause the SPA to fail loading under the prefix. The rest of the change is correct and well-covered by tests.

internal/server/basepath.go — the character allowlist in NormalizeBasePath should be extended to cover HTML-attribute-unsafe characters.

Security Review

  • Base-tag injection via unvalidated characters: NormalizeBasePath does not reject \" (double-quote), <, or >. The injected value is placed directly into an HTML attribute (<base href=\"{prefix}/\" />). A path containing \" would break the attribute and silently redirect all SPA assets to the wrong path. The prefix is operator-supplied config, but defense-in-depth warrants rejecting these characters.

Important Files Changed

Filename Overview
internal/server/basepath.go New file implementing base-path normalization and index.html injection; solid validation but doesn't reject HTML-unsafe characters like <, >, ".
internal/server/server.go Core routing changes mount the full surface under the prefix and overwrite httpServer.Handler; the strings.HasSuffix double-append guard for baseURL is fragile.
internal/server/handle_auth.go All sessionCookie call sites updated to the new method-receiver form; cookie Path now scoped to the prefix.
internal/server/handle_spa.go SPA handler now reads the pre-templated indexHTML field set at startup instead of re-reading the embedded file on every request.
web/src/lib/basePath.ts New utility reads <base href> from the DOM at module load and exports basePath and joinBasePath helpers.
web/src/lib/api.ts Central apiFetch now prepends the base path to all root-relative API URLs.
cmd/server.go New --ui-base-path flag wired through the foreground, detached, and re-exec paths; flag value normalized before use.
web/src/router.tsx TanStack Router now receives the runtime-derived basepath, so in-app navigation and redirects are prefix-aware.
web/index.html Adds the <base href="/" /> placeholder tag and switches favicon links to relative URLs.
internal/server/basepath_test.go Comprehensive tests for normalization, injection, routing, cookie scoping, and baseURL construction.
docs/self-hosting/path-prefix.mdx New doc with nginx/Caddy examples, CLI usage, and a verification checklist.

Reviews (1): Last reviewed commit: "feat(server): serve UI and API under a c..." | Re-trigger Greptile

Comment thread internal/server/basepath.go Outdated
Comment on lines +27 to +29
if strings.ContainsAny(p, " ?#") {
return "", fmt.Errorf("invalid UI base path %q: must not contain spaces, '?' or '#'", p)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security NormalizeBasePath rejects spaces, ?, and #, but not " (double-quote). Because injectBasePath concatenates basePath directly into an HTML attribute value without escaping, a path like /my"app would produce <base href="/my"app/" /> — the browser terminates the href attribute at the second ", so the injected base tag points to /my instead of the intended path and the SPA silently fails to load under the prefix.

Suggested change
if strings.ContainsAny(p, " ?#") {
return "", fmt.Errorf("invalid UI base path %q: must not contain spaces, '?' or '#'", p)
}
if strings.ContainsAny(p, " ?#\"<>") {
return "", fmt.Errorf("invalid UI base path %q: must not contain spaces, '?', '#', '\"', '<', or '>'", p)
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in a7445ba. NormalizeBasePath now allowlists unreserved URL characters plus / instead of blacklisting, which rejects ", <, %, etc. Added test cases for them. (The value is operator-supplied config rather than request data, but the allowlist removes the splice concern entirely.)

Comment thread internal/server/server.go
Comment on lines +693 to +696
baseURL = strings.TrimRight(baseURL, "/")
if uiBasePath != "" && !strings.HasSuffix(baseURL, uiBasePath) {
baseURL += uiBasePath
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fragile double-append guard may silently misconfigure baseURL

strings.HasSuffix does a raw byte comparison, not a path-segment comparison. The guard is technically correct today — after strings.TrimRight(baseURL, "/") the only way baseURL ends with uiBasePath is when the prefix is already present — but it operates on the full URL string rather than on a parsed path component, making the intent opaque and brittle for future callers. Comparing via a parsed *url.URL path would be more explicit and robust.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guard is byte-wise, but uiBasePath always starts with / (enforced by NormalizeBasePath) and baseURL is right-trimmed of slashes first, so a suffix match can only occur at a path-segment boundary — https://example.com/myvault does not end with /vault. Documented this on the guard in a7445ba.

…ters

The previous blacklist (space, '?', '#') missed HTML-attribute-special
characters like '"' and '<'; the value is spliced into the <base href>
attribute, cookie paths, and redirect targets. Allowlist unreserved URL
characters plus '/' instead. Also document why the baseURL HasSuffix
guard can only match at a path-segment boundary.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support serving the web UI under a URL path prefix (e.g. /agent-vault/) behind a reverse proxy

1 participant