This document records the security posture of Veritas Registry: what the automated scanners report, the findings of a manual audit, and the status of each item. It is kept honest on purpose - items that are not yet fixed are listed as such, with the reason and the remediation.
- CodeQL (Go and JavaScript,
security-and-quality) runs on push, on pull requests, and weekly. At the time of writing it reports zero open alerts. Note that GitHub can silently disable scheduled scans on inactivity; check the Actions tab if results look stale. - Dependabot watches Go modules, npm, Docker base images, and GitHub Actions.
The one open advisory it reported,
react-routerGHSA-qwww-vcr4-c8h2 (RSC-mode CSRF), is fixed by upgrading toreact-routerv8 (see below). npm auditandgovulncheckare expected to be clean; run them in CI or locally before a release.
| Item | Change |
|---|---|
react-router GHSA-qwww-vcr4-c8h2 (high) |
Upgraded react-router-dom 7.x to react-router v8; react-router-dom is merged into react-router in v8, imports updated across all pages. npm audit clean. |
brace-expansion (dev, high) |
Resolved by npm audit fix. |
A manual audit covered backend auth/authorization, backend file and network handling, the frontend, container and Kubernetes posture, and the CI/CD supply chain. Each finding below was verified against the source. The single critical and all four high-severity findings are fixed and verified end to end; the most impactful medium findings are fixed; the remainder are triaged with a status and a remediation note.
| Sev | Finding | Fix |
|---|---|---|
| Critical | JWT signing key was effectively unconfigurable and defaulted to a public placeholder; docker-compose.yml also hardcoded a public key as a literal, and the documented .env override was silently ignored. Anyone could forge an admin token offline. |
Root cause was a Viper misconfiguration: nested keys (auth.secretkey) were never bound to env vars (AUTH_SECRETKEY), so every environment override was dropped. Added SetEnvKeyReplacer plus explicit BindEnv for all keys. Added a fail-closed guard that refuses to start in release mode on any known placeholder key or a key shorter than 32 bytes. docker-compose.yml now uses ${AUTH_SECRETKEY:?} so Compose aborts when it is unset. k8s/secret.yaml ships with no value and documents out-of-band creation. .env.example blanks the key. Regression tests cover the env-override path and the guard. |
| High | Default admin account admin/admin123 was auto-created on first boot; no deployment path set ADMIN_PASSWORD. |
When authentication is enabled, the server now requires ADMIN_PASSWORD and fails closed if it is unset - it never seeds a well-known password and never logs a generated one (logging a secret is itself a leak, flagged by CodeQL go/clear-text-logging). When authentication is disabled (development), a random unlogged password is seeded since it gates nothing. |
| High | A committed key in k8s/secret.yaml was applied by the documented deploy command. |
Value removed; the manifest documents kubectl create secret from a generated value. |
| High | Unauthenticated mirror-protocol routes spawned one unbounded background upstream download per request (disk/bandwidth amplification DoS). | Background caching is now bounded by a semaphore (max 3 concurrent) and deduped by an in-flight set, launched via tryStartBackgroundCache. A burst degrades to "cached on a later request" instead of spawning unbounded goroutines. The routes stay unauthenticated so the Terraform network mirror keeps working. |
| Medium | The role claim was never enforced: RequireRole was applied to zero routes, so any authenticated principal was fully privileged. |
Write/management routes are now behind an admin group (AuthMiddleware + RequireRole("admin")). /auth/me stays at authenticated-only. Verified: a valid non-admin token gets 403 on writes. |
| Medium | JWTs passed in the URL query string (SSE, export) were written verbatim to access logs by gin's default logger. | The engine now uses a custom logger that redacts sensitive query parameters (token) before logging. Verified: the token no longer appears in logs. |
| Medium | GET /api/v1/settings is public and returned proxy_url verbatim, disclosing outbound proxy credentials. |
The public response now strips userinfo (user:pass@) from proxy_url; the admin path still returns the full value. Verified. |
| Medium | Wildcard CORS with Access-Control-Allow-Credentials: true (an invalid, risky combination). |
CORS is now configurable via CORS_ALLOWED_ORIGINS. The API authenticates with a Bearer token, not cookies, so credentials are never emitted; a wildcard origin no longer carries credentials. |
| Medium | No rate limiting on POST /api/v1/auth/login - unlimited credential guessing and a cheap bcrypt CPU-exhaustion vector. |
Added a per-IP fixed-window limiter (10/min). Verified: the 11th attempt returns 429. |
| Medium | SSRF: an attacker-chosen outbound proxy via the proxy_url query parameter. |
The mirror stream handler no longer accepts a client-supplied proxy; it always uses the server-configured proxy. |
| Medium | Stored XSS: cached/uploaded provider files were served inline from the SPA origin with an attacker-influenced name. | The file-serving path now sets Content-Type: application/octet-stream, X-Content-Type-Options: nosniff, and Content-Disposition: attachment. |
| Medium | Both nginx configs sent no security headers. | Added X-Content-Type-Options, X-Frame-Options, Referrer-Policy, a Content-Security-Policy, and HSTS (on the TLS listener). |
| Medium | Backend was published in cleartext on 0.0.0.0:8080, bypassing the TLS terminator. |
Compose now binds the backend to 127.0.0.1:8080; the frontend reaches it over the internal Docker network. |
| Medium | Containers ran as root with default capabilities and a writable root filesystem (Kubernetes). | The backend deployment now runs as non-root (uid 1000), read-only root filesystem, all capabilities dropped, seccompProfile: RuntimeDefault, and no auto-mounted service-account token. The frontend gets the subset compatible with the stock nginx image (see deferred). |
| Low | Gin ran in debug mode, dumping the route table and verbose logs. | The server sets the Gin mode from SERVER_MODE (release by default). |
| Low | JWT verification did not pin the signing algorithm and dereferenced ExpiresAt without a nil check. |
Verify now pins HS256 via WithValidMethods and rejects a token with no exp. |
| Low | A negative limit query parameter disabled the SQL LIMIT and dumped whole tables. |
clampPagination bounds page and limit. |
| Low | Kubernetes service-account token auto-mounted into the backend pod. | automountServiceAccountToken: false on both deployments. |
| Low | Dev compose pinned a Go toolchain older than go.mod requires, so make dev-start could not build. |
Bumped the dev image to golang:1.26-alpine. |
These are real but were not changed in this pass, either because a correct fix needs its own design and tests, or because it is an operational/infra decision rather than a code change. They are listed so nothing is lost.
| Sev | Finding | Why deferred and how to fix |
|---|---|---|
| Medium | Downloaded provider binaries are cached with no verification against upstream SHASUMS; the registry publishes its own computed hash as authoritative. |
This is a feature, not a one-line fix: verifying integrity means fetching and checking the upstream SHA256SUMS (and ideally its signature) before publishing. Track as a dedicated change with tests. |
| Medium | Concurrent downloads can race on a shared .tmp path, producing a corrupt archive with a hash the registry treats as verified. |
Fix by writing to a unique temp file per download and renaming atomically. Needs a focused change in internal/proxy with a concurrency test. |
| Medium | ImportProvider accepts unbounded uploads and can amplify a zip bomb across duplicated manifest entries. |
Add a decompression size/ratio cap and a per-request body limit. Needs a bounded reader and tests. |
| Medium | Arbitrary file read/delete via attacker-supplied platforms[].file_path in CreateProvider (mass assignment). |
Now admin-gated, which drops the severity, but the real fix is a request DTO that never binds file_path, plus confining any path to the storage root. Needs a DTO refactor and tests. |
| Medium | The locally generated root CA private key is bind-mounted into the nginx container world-readable while users are told to trust that CA. | Operational: tighten file permissions on certs/, or provision certificates from a real CA / cert-manager. |
| Low | Frontend container runs as root; full non-root needs the unprivileged nginx image. | Switch the frontend image to nginxinc/nginx-unprivileged (listens on 8080) and update the port mappings, then enable runAsNonRoot, readOnlyRootFilesystem, and capability drop. |
| Low | GitHub Actions are not pinned to commit SHAs; main has no branch protection; the publish job runs on push with no gate. |
Pin every uses: to a full commit SHA (Dependabot can then bump them), and enable branch protection / required checks on main. |
| Low | Published images disable provenance and SBOM. | These were disabled to work around an unknown/unknown architecture artifact; revisit once resolved and re-enable attestations. |
| Low | Deployments and manifests pull mutable :latest with imagePullPolicy: Always; base images are unpinned. |
Pin images to a digest (@sha256:...) for reproducible, tamper-evident deploys. |
| Low | Mirror-protocol archive URLs are built from client-controlled X-Forwarded-Host / X-Forwarded-Proto. |
Configure gin's trusted proxies and derive the external URL from configuration rather than untrusted headers. |
| Low | Login leaks whether a username exists via response timing (bcrypt runs only for existing users). | Run a dummy bcrypt comparison for unknown users to equalize timing. |
| Low | GET /api/v1/sync/schedules is public though the UI shows it only to signed-in users. |
Move it into the authenticated group if the schedule inventory is considered sensitive. |
Nine reported items were investigated and refuted (for example, a claim that
the JWT key was still unconfigurable - already fixed here; and a claim that
frontend/.gitignore fails to ignore .env - it does). They are not tracked.
- Always set a strong, unique
AUTH_SECRETKEY(openssl rand -base64 32). - Set
ADMIN_PASSWORD, or capture the generated password from the first-boot logs and rotate it after first login. - Terminate TLS in front of the API; do not expose the backend port publicly.
- Restrict
CORS_ALLOWED_ORIGINSto known origins in production. - Keep dependencies current; let Dependabot and CodeQL run on schedule.