Skip to content

fix: security & reliability hardening, agent/proxy config, and coverage gates#10

Open
memetics19 wants to merge 4 commits into
mainfrom
fix/security-reliability-hotpath
Open

fix: security & reliability hardening, agent/proxy config, and coverage gates#10
memetics19 wants to merge 4 commits into
mainfrom
fix/security-reliability-hotpath

Conversation

@memetics19

@memetics19 memetics19 commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Summary

Hardens the API and worker against a set of security and correctness bugs found in review (SSRF across all monitor types, a public /api/status data leak, stored XSS, a silently-dying worker, WAL that was never actually enabled), adds agent-token and reverse-proxy hardening, reworks the admin UI's destructive-action UX, and raises test coverage across all three Go modules to CI-enforced gates.

Type of change

  • feat — new feature
  • fix — bug fix
  • refactor — no behaviour change
  • docs
  • ci / build — pipeline or tooling
  • test
  • chore
  • Breaking change (! / BREAKING CHANGE: footer)

Changes

api

  • SSRF guard now covers every monitor type (tcp/ssl/ping/dns), not just HTTP, and all types are validated at the API boundary.
  • GET /api/status is scoped to the request's status page and returns a sanitized DTO instead of raw monitor models (see Breaking changes).
  • Theme custom_css and logo/footer URLs are sanitized on the public page (stored XSS).
  • Uptime endpoint no longer 500s (REAL/NULL was scanned into int64); long-range uptime is computed over the full window instead of the newest 10k rows.
  • Monitor create/update use a pointer DTO so omitted is_active/thresholds get schema defaults; unchecked/infra monitors render as "no data", not "up".
  • Enable WAL + foreign keys for real (the old DSN silently ran in delete journal mode) and split the SQLite pool; add PULSE_TRUSTED_PROXIES for the login rate limiter.

worker

  • Retry the monitoring worker with backoff; /healthz returns 503 when the worker goes stale.
  • Reuse one shared HTTP transport across checks (fixes an FD/goroutine leak and handshake-inflated latency).

agent

  • Resolve the bearer token from PULSE_AGENT_TOKEN/--token-file instead of argv; fix the Dockerfile (it referenced a non-existent worker/ module and could never build).

ui

  • Shared, accessible confirm dialog with type-to-confirm for irreversible actions; ESLint now bans alert/confirm/prompt.

ci / build

  • Multi-module CI (api/agent/cli) with -race, a coverage gate excluding generated code, HTML report artifacts, job summaries, and README badges; agent image is now built in CI.

docs

  • Component + check-lifecycle Mermaid diagrams in the README and docs/architecture.md.

test

  • ~35 new test files; coverage: api 37.5→85%, agent 52.7→85%, cli 82.8→91% (excluding generated).

Breaking changes

GET /api/status previously returned the raw monitor model (target URLs, thresholds, source, external ID) for all monitors, unscoped. It now returns a page-scoped, reduced shape — group and monitor name + status only. Any consumer reading monitor URLs/thresholds/IDs from this public endpoint must switch to the authenticated /api/monitors API. No database migration is required.

Test plan

  • cd api && go test ./... -count=1 (also passes -race)
  • cd agent && go test ./... -count=1
  • cd cli && go test ./... -count=1
  • cd ui && npx tsc --noEmit (verified via npm run build, which type-checks and succeeds)
  • Coverage gates pass: bash scripts/coverage.sh api 85, … agent 85, … cli 90
  • Manual check: run the daemon, create tcp/ssl monitors pointed at loopback (expect rejection), confirm /api/status on a scoped domain hides other pages' monitors, and exercise a destructive admin action to see the confirm dialog.

Checklist

  • Commits follow Conventional Commits
  • No Co-Authored-By / AI attribution (enforced by the commit-msg hook)
  • gofmt-clean and go vet ./... passes
  • Docs updated (docs/, README.md) where relevant
  • No secrets or .env files committed

Deployment notes

  • New optional env vars: PULSE_TRUSTED_PROXIES (CIDRs of trusted reverse proxies for login rate limiting) and PULSE_AGENT_TOKEN / --token-file for the agent. All default to previous behaviour if unset.
  • No database migrations. WAL/foreign-keys/busy_timeout are applied automatically on DB open — existing SQLite files upgrade in place on next start.
  • /api/status response shape changed (see Breaking changes) — update any external scripts before rollout.

@memetics19 memetics19 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Decision: request changes before merge. The race suite and UI build pass, but this review reproduced three release-blocking security/correctness regressions: enumerable public detail routes still bypass the new sanitized status DTO, ping validation is vulnerable to DNS rebinding, and a custom page with zero selected groups widens to all groups. I also reproduced pulse-agent --help exiting 2, infra monitor creation rejecting a private label despite never dialing it, and normal HTTP response bodies preventing connection reuse. The current golangci-lint v2.12.2 run reports 38 errcheck findings. Inline comments contain focused fixes and regression-test expectations.


// Public read-only (status page client-side fetches)
r.Get("/api/monitors/{monitorID}/checks", handlers.NewCheckResults(q).List)
r.Get("/api/monitors/{monitorID}/checks/uptime", handlers.NewCheckResults(q).Uptime)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[blocker] These public ID routes bypass the new page-scoped DTO. Any unauthenticated caller can enumerate monitor IDs here (and incident IDs on line 44) without resolving a Host/page or checking membership. CheckResults.List returns generated check rows, so errors can also disclose the monitored target. Please authenticate these routes or replace them with Host-resolved, page-scoped, reduced DTOs, then add cross-page enumeration tests for monitor checks, uptime, and incident updates.

if err := netguard.ValidateTarget(target, c.allowPrivate); err != nil {
return Result{Status: "down", ErrorMessage: err.Error(), CheckedAt: time.Now()}
}
pinger, err := probing.NewPinger(target)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[blocker] Validation and use resolve the hostname separately. ValidateTarget checks one DNS answer, then probing.NewPinger(target) resolves the name again; an attacker can rebind between those lookups and reach a private address. Resolve once, reject every disallowed answer, and configure the pinger with the selected validated IP (while retaining the display hostname). An injected-resolver test should change the second answer to loopback and prove it is never used.

return
}

allGroups := rp.GroupIDs == nil

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[blocker] An empty custom page silently widens to every group. ResolvePage returns nil when a custom page has zero page_groups, and this treats nil as the default page's all-groups sentinel. I reproduced /api/status returning a monitor from an unrelated group for a custom page with group_ids: []. Represent page scope explicitly (all, empty, or selected IDs) and keep empty non-nil through HTML, JSON, and feed paths; add an exact cross-page regression test.

if err := netguard.ValidateURL(url, m.allowPrivate); err != nil {
return err.Error()
}
} else {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[major] This applies network-target validation to infra, which has no checker and never dials its URL. I reproduced creating an infra monitor with 127.0.0.1 returning 400 when private targets are disabled. Select validation by monitor kind instead of using a catch-all else; infra should skip SSRF address validation, while each network kind should retain its own parser/policy.

Comment thread agent/cmd/agent/main.go
token := fs.String("token", "", "Bearer token (INSECURE: visible in ps/proc; prefer PULSE_AGENT_TOKEN or --token-file)")
tokenFile := fs.String("token-file", "", "File to read the bearer token from")
interval := fs.Int("interval", 30, "Push interval in seconds (default 30)")
if err := fs.Parse(args); err != nil {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[major] --help is reported as a usage error. flag.FlagSet.Parse returns flag.ErrHelp after printing help, and this maps it to exit code 2. A fresh binary run confirms pulse-agent --help exits 2. Handle errors.Is(err, flag.ErrHelp) as success (0), preserve 2 for malformed flags, and cover both exit codes in table-driven CLI tests.

func (c *httpChecker) attempt(ctx context.Context, client *http.Client, target string) Result {
func (c *httpChecker) attempt(ctx context.Context, client *http.Client, target string, timeout time.Duration) Result {
// The per-attempt deadline replaces the old per-client Timeout, which can't
// live on the shared client. Cancel fires after the body is read below.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[major] Closing without consuming the ordinary response body defeats the new shared transport. On the no-keyword success path the body never reaches EOF, so Go cannot reuse the HTTP/1.x connection. The existing reuse test uses an empty body and misses this; an overlay test with a non-empty body reproduced a new connection on the second check. Drain/consume the body with an explicit size policy before close, and make the reuse regression return representative content.

if total > 0 {
uptime = fmt.Sprintf("%.2f%%", float64(upCount)/float64(total)*100)
if len(rows) > 0 {
if pct, err := p.q.UptimePercent(ctx, generated.UptimePercentParams{MonitorID: monitorID, CheckedAt: since}); err == nil {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[major] This adds a second history query per visible monitor on an unauthenticated page. monitorSeries already calls ListCheckResults, and now calls UptimePercent, producing at least 2N history queries per render. Public pages are an easy load amplifier as monitor count grows. Batch series/uptime reads for all visible monitor IDs behind one public-status projection, and assert a bounded query count in a many-monitor test.

q := generated.New(testutil.FailAfter(db, 1))
rr := do(handlers.NewPages(q).Create, "POST", "/api/pages",
map[string]any{"domain": "a.com", "title": "A", "group_ids": []int64{g.ID}})
assert.Equal(t, http.StatusInternalServerError, rr.Code)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[major] This test codifies the 500 but misses the broken invariant: the page insert has already committed. When AddPageGroup fails, a partial status page remains persisted (and updates can similarly leave partial associations). The handler needs one transaction for page fields plus all group mutations; extend this fault test to query the database and assert the entire mutation rolled back.

parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
for i := len(parts) - 1; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if ip != "" && !ipInAny(ip, trusted) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[major] The selected X-Forwarded-For token is returned without parsing or canonicalization. A client behind a configured trusted proxy can vary invalid strings (or alternate textual IP forms) to mint rate-limit buckets and grow the limiter map. Parse each hop with net.ParseIP, ignore invalid entries, and return ip.String() so one address has one stable key; add malformed and IPv6 canonicalization cases.

Comment thread .github/workflows/ci.yml
- uses: golangci/golangci-lint-action@v6
with:
version: latest
working-directory: api

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[major] The new lint job covers only api, unlike the Go test matrix. agent and cli can accumulate unchecked errors and other lint regressions unnoticed; this PR already changes both. Matrix lint across all three modules (with per-module working directories/config if needed), pin the linter version for reproducibility, and make it blocking once the 38 current errcheck findings are resolved.

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