Skip to content

fix: dashboard gates, body caps, store ordering, lifecycle#37

Merged
doganarif merged 1 commit into
mainfrom
fix/dashboard-and-store-hardening
May 21, 2026
Merged

fix: dashboard gates, body caps, store ordering, lifecycle#37
doganarif merged 1 commit into
mainfrom
fix/dashboard-and-store-hardening

Conversation

@doganarif
Copy link
Copy Markdown
Owner

Sweep through issues that piled up after the React dashboard PR and the
OTEL refactor. Three buckets.

Dashboard surface

Replay was a generic SSRF primitive: anything reachable to the dashboard
could make the host fetch arbitrary URLs (including 169.254.169.254 on
cloud). It is now off by default, and when enabled the target IP is
validated inside a DialContext so the LookupIP-then-dial TOCTOU is
closed. IPv4-mapped IPv6 is normalized so ::ffff:10.0.0.1 doesn't slip
past IsPrivate.

system-info is also off by default. The previous substring denylist
(API, KEY, SECRET, ...) failed open on anything it didn't
recognize — DATABASE_URL, SLACK_WEBHOOK_URL, etc. Replaced with an
explicit allowlist:

govisual.Wrap(h,
    govisual.WithSystemInfo("APP_VERSION", "REGION"),
)

Auth is pluggable: WithDashboardAuth(fn) for any scheme, plus a
WithBasicAuth(user, pass) helper that uses subtle.ConstantTimeCompare.
WithLocalhostOnly() for the common ""just debugging"" case.

Capture path

  • request/response body capture is capped at 1 MiB by default;
    WithMaxBodyBytes(n) to change or disable
  • Authorization, Cookie, Set-Cookie, X-Api-Key, X-Auth-Token,
    X-Csrf-Token and Proxy-Authorization are scrubbed before storage.
    Raw bearer tokens in the dashboard wasn't great.
  • responseWriter forwards Flusher, Hijacker, Pusher so SSE,
    WebSocket upgrades and HTTP/2 push survive the wrap

Storage and lifecycle

  • request IDs use crypto/rand (was timestamp-based; collided under
    load)
  • in-memory store now returns newest-first to match the SQL/Mongo/Redis
    backends; ring-buffer eviction is real FIFO instead of random map
    iteration
  • Mongo gained bson tags; the timestamp sort was previously hitting a
    field that didn't exist
  • SQL backends validate table names against
    [A-Za-z_][A-Za-z0-9_]*; cleanup is batched every 32 inserts
  • Store.Add returns an error

The package-level SIGTERM/os.Exit handler from #28 is removed. A
library has no business killing the host process. Lifetime is now
opt-in:

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
govisual.Wrap(h, govisual.WithShutdownContext(ctx))

Testing

  • go test -race ./... clean
  • go vet ./... clean
  • net -4 golangci-lint findings vs main; no new ones introduced
  • exercised the dashboard locally against the in-memory and SQLite
    stores

Compatibility

Store.Add returning an error is a breaking change for anyone
implementing the Store interface directly. The public API
(govisual.Wrap + the option functions) is unaffected.

Dashboard: gate /api/replay and /api/system-info behind opt-in flags
and add optional auth/loopback checks. Replay validates the target IP
inside DialContext to close the LookupIP-then-dial TOCTOU and
normalizes IPv4-mapped IPv6. Switch system-info env exposure from a
substring denylist to an explicit allowlist.

Middleware: cap captured request/response bodies at 1 MiB by default,
scrub Authorization/Cookie/Set-Cookie/X-Api-Key from stored headers,
and forward Flusher/Hijacker/Pusher through the writer.

Storage: use crypto/rand for request IDs (timestamp-based IDs collided
under load), align in-memory ordering with SQL/Mongo/Redis (newest
first), add bson tags so Mongo timestamp sort hits the right field,
validate SQL table names, batch cleanup every 32 inserts, and let
Store.Add return an error.

Drop the package-level signal handler that called os.Exit. Lifetime
is now opt-in via WithShutdownContext. Reverts the centralized
approach from #28.
@doganarif doganarif merged commit 80c81e9 into main May 21, 2026
1 check passed
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.

1 participant