Skip to content

feat(api): Basic auth + read/write roles on the management API (S-1)#20

Merged
ntt0601zcoder merged 1 commit into
mainfrom
fix/api-basic-auth
Jun 13, 2026
Merged

feat(api): Basic auth + read/write roles on the management API (S-1)#20
ntt0601zcoder merged 1 commit into
mainfrom
fix/api-basic-auth

Conversation

@ntt0601zcoder

Copy link
Copy Markdown
Owner

Problem (audit S-1, CRITICAL)

The control-plane HTTP API had no authentication — anyone who could reach the bind address could replace the global config, create/delete streams, upload files, and read secrets. This is the enabling precondition for several other findings.

Fix — admin-plane auth (the control-plane half of S-1)

HTTP Basic auth with two roles: read (GET/HEAD/OPTIONS) and write (all methods), gating every management route.

  • Users in global config (auth.api.users): username + bcrypt password_hash + role. Managed like any other config and hot-reloaded (runtime.Manager.diffServer.SetAuthConfig, atomic.Pointer swap — no restart).
  • internal/api/apiauth.go: Auth.Middleware, bcrypt verify with a dummy-hash timing-safe unknown-user path, roleAllowsMethod. All admin routes wrapped in an auth Group; /healthz, /readyz, /metrics and the media catch-all stay public.
  • Opt-in: enabled: false (default) is a pass-through, so existing deployments keep working until an operator turns it on. Fail-closed bootstrap: enabled + no users seeds admin/write from OPEN_STREAMER_API_ADMIN_PASSWORD, else every request is rejected.
  • open-streamer hashpw [password] prints a bcrypt hash so plaintext never enters the config.

Adversarial review → bypass found & fixed

A review found a routing bypass: flat admin routes let an unregistered HTTP method fall through chi's trie to the unauthenticated media catch-all. /config* and /streams are now subrouters that return 405 in-group (regression test TestAdminRouteUnmatchedMethodDoesNotBypassAuth). Confirmed no GET-triggered mutation escalation — stream restart/switch are POST-gated.

Scope / follow-ups (not in this PR)

  • Non-mutating client-IP extractor / trusted-proxy CIDR (RealIP → S-15/S-17).
  • Media/playback auth (the separate media plane).
  • Redacting password_hash/secrets from GET /config + /config/yaml for read users — folded into S-9 (comprehensive secret redaction with round-trip preservation). Note: these are bcrypt hashes (offline-crack-only), behind auth once enabled.

Test

TestAPIAuth_* (disabled pass-through, creds/401, read-cannot-write/403, bootstrap, fail-closed, invalid-user skip, hot-reload), TestRoleAllowsMethod, TestAdminRouteUnmatchedMethodDoesNotBypassAuth. go test -race ./internal/api/... green, golangci-lint 0 issues, full go build ./... green.

Usage

auth:
  api:
    enabled: true
    users:
      - username: admin
        password_hash: "$2a$10$..."   # open-streamer hashpw <pw>
        role: write
      - username: ops
        password_hash: "$2a$10$..."
        role: read

The control-plane HTTP API had no authentication: anyone who could reach the
bind address could replace the global config, create/delete streams, upload
files, and read secrets (audit S-1, CRITICAL). Add HTTP Basic auth with two
roles — read (GET/HEAD/OPTIONS) and write (all methods) — gating every
management route.

- Users live in the global config (auth.api.users) as bcrypt password_hash +
  role, managed like any other config and hot-reloaded (runtime diff →
  Server.SetAuthConfig, atomic-pointer swap; no restart).
- New internal/api/apiauth.go: Auth.Middleware, bcrypt verify with a
  dummy-hash timing-safe unknown-user path, roleAllowsMethod. All admin routes
  are wrapped in an auth Group in buildRouter; /healthz, /readyz, /metrics and
  the media catch-all stay public.
- Opt-in: enabled=false (default) is a pass-through, so existing deployments
  keep working until an operator turns it on. Fail-closed bootstrap: enabled +
  no users seeds an admin/write account from OPEN_STREAMER_API_ADMIN_PASSWORD,
  otherwise every request is rejected.
- `open-streamer hashpw [password]` prints a bcrypt hash so plaintext never
  enters the config.

Adversarial review found and this fixes a routing bypass: flat admin routes
let an unregistered HTTP method fall through chi's trie to the unauthenticated
media catch-all. /config* and /streams are now subrouters that return 405
in-group; regression test TestAdminRouteUnmatchedMethodDoesNotBypassAuth.
Stream restart/switch are POST-gated so read users can't trigger mutations via
GET.

This is the control-plane half of S-1. Out of scope (separate findings): the
non-mutating client-IP extractor (RealIP → S-15/S-17), media/playback auth,
and redacting password_hash/secrets from GET /config responses (folded into
S-9 secret redaction).

Tests: TestAPIAuth_* (disabled pass-through, creds, read-cannot-write,
bootstrap, fail-closed, invalid-user skip, hot-reload), TestRoleAllowsMethod,
TestAdminRouteUnmatchedMethodDoesNotBypassAuth.
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 76.39752% with 38 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
cmd/server/main.go 0.00% 23 Missing ⚠️
internal/api/server.go 83.58% 10 Missing and 1 partial ⚠️
internal/api/apiauth.go 97.10% 1 Missing and 1 partial ⚠️
internal/runtime/manager.go 0.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@ntt0601zcoder ntt0601zcoder merged commit 82c47cb into main Jun 13, 2026
4 checks passed
@ntt0601zcoder ntt0601zcoder deleted the fix/api-basic-auth branch June 15, 2026 04:16
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.

2 participants