Skip to content

feat(auth)!: secure defaults for authentication, notifications, request limits, and reports - #971

Merged
Starosdev merged 16 commits into
betafrom
feat/965-security-defaults
Oct 13, 2026
Merged

Starosdev merged 16 commits into
betafrom
feat/965-security-defaults

Conversation

@Starosdev

Copy link
Copy Markdown
Owner

Summary

WP-B (security defaults) for the 2.0.0 release. API authentication is on by default, script:// notification URLs are config-only, request bodies are size-limited, client IPs from X-Forwarded-For are trusted only from configured proxies, and the PDF report directory is config-only. Adds a shared helper that generates and persists secrets in the config directory, which the InfluxDB credential work reuses.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactoring (no functional changes)
  • CI/CD or infrastructure change

Related Issues

Related to #965

Changes Made

  • pkg/secrets: EnsureSecret(dir, name) (secret, created, err) creates a 64-hex-character secret with mode 0600 (directory 0700), reuses an existing one, and replaces an empty file; Read(dir, name).
  • web.auth.enabled defaults to true (breaking). Startup (auth.EnsureStartupAuth, first step of server.Start):
    • Omnibus (local collector present): generates a token at /opt/scrutiny/config/.auth-token if none is configured; the local collector reads it as its lowest-precedence api.token default, so cron, one-shot, and API-triggered runs work without extra config. The token value is never logged.
    • Hub/spoke web without a token: exits at startup naming web.auth.token / SCRUTINY_WEB_AUTH_TOKEN. Collectors need the same value in COLLECTOR_API_TOKEN.
    • web.auth.enabled: false remains an explicit opt-out and logs a warning. /api/health stays public.
  • JWT secret persisted to .jwt-secret so sessions survive restarts; an unwritable config dir falls back to a per-process secret with a warning.
  • Request bodies over web.listen.max_body_bytes (default 16 MiB) return 413 (breaking for oversized clients).
  • X-Forwarded-For is no longer trusted by default; set web.listen.trusted_proxies behind a reverse proxy (breaking for proxied rate limiting and logging). Login rate-limiter entries are pruned.
  • script:// notification URLs are rejected on the UI/API path; existing database rows with script:// are skipped with a warning (rows kept), and notify.Send also drops database-sourced script URLs. Script URLs remain allowed in notify.urls in the config file (breaking).
  • The report_pdf_path dashboard setting is removed (use web.reports.path); /api/reports/history no longer returns absolute paths. A stored 1.x value logs a one-time warning (breaking).
  • Deprecated notify.filter_attributes / notify.level no longer error; they are ignored.
  • Docs: docs/AUTH.md, docs/UPGRADE_2.0.md (auth, script URLs, report path sections), README.md config tables, docs/openapi.yaml, example.scrutiny.yaml.

Testing

  • I have tested this locally
  • I have added/updated tests that prove my fix/feature works
  • All existing tests pass

go build ./..., go vet ./..., go test ./... (40 packages) pass; GOOS=linux golangci-lint run --new-from-rev=origin/beta ./... (v2.14.0) 0 issues. New tests: config defaults and removed keys; startup auth (hub/spoke failure, omnibus generation with 0600 and no token in logs, disabled warning, unwritable dir); 413 body limit; trusted proxies; script URL rejection on API and skip on load and send; reports history without paths; rate-limiter pruning; PDF directory source; pkg/secrets.

Checklist

  • My code follows the project's code style
  • I have performed a self-review of my code
  • I have commented my code where necessary (particularly complex areas)
  • I have updated the documentation if needed
  • My changes generate no new warnings

Notes

  • Every breaking commit carries a BREAKING CHANGE: footer.
  • Frontend follow-ups (not in this PR): the existing login page handles auth, but omnibus users must read the generated token (for example docker exec <container> cat /opt/scrutiny/config/.auth-token) on first load; remove report_pdf_path from app.config.ts and dashboard-settings.component.ts (the backend ignores the unknown field, so saves keep working); surface the 400 when saving a script:// URL.
  • Body limit: a request with Content-Length over the limit gets 413; a chunked body that exceeds it fails binding with 400.
  • Docker examples and the Unraid template still describe the token as optional; they are updated in the images and bootstrap work package. /opt/scrutiny/config must stay writable.
  • Rollout: merging to beta publishes the :beta images. Omnibus deployments keep collecting with the generated token, and the web UI then requires that token. Hub/spoke deployments need SCRUTINY_WEB_AUTH_TOKEN and COLLECTOR_API_TOKEN before upgrading.

Review: required (alters a security and access boundary)

…ig dir

pkg/secrets.EnsureSecret returns the secret stored in <dir>/<name>, creating a
random 64 character hex value with mode 0600 when the file is missing or empty.
Used for the generated API token and JWT secret.
web.auth.enabled now defaults to true.

- Omnibus (collectors installed next to the server): when web.auth.token is
  empty the server generates a token at first start, saves it to
  <web.secrets.dir>/.auth-token with mode 0600, and logs the file location
  (never the value). The collector config reads that file as the lowest
  precedence api.token default, so in-container collectors authenticate
  without configuration.
- Hub/spoke: the server refuses to start with an error naming
  web.auth.token unless web.auth.enabled is explicitly false.
- web.auth.enabled=false stays available and logs a warning at start.
- A missing web.auth.jwt_secret is generated once and saved to
  <web.secrets.dir>/.jwt-secret so sessions survive restarts; an unwritable
  directory falls back to a per-process secret with a warning.
- The "token required when auth enabled" check moves from config validation
  to server startup, where the omnibus case can generate one.
- Remove the hard errors for the long-deprecated notify.filter_attributes and
  notify.level keys; they are now ignored.
- Add config keys web.secrets.dir, web.listen.trusted_proxies,
  web.listen.max_body_bytes and web.reports.path (used by the following
  commits).

BREAKING CHANGE: API authentication is enabled by default. Hub/spoke web
containers exit at startup unless SCRUTINY_WEB_AUTH_TOKEN (web.auth.token) is
set, and every collector must send the same value as COLLECTOR_API_TOKEN. The
omnibus image generates a token at first start and saves it to
/opt/scrutiny/config/.auth-token; the config volume must be writable. Set
SCRUTINY_WEB_AUTH_ENABLED=false to keep running without authentication, or stay
on the :1 image tag. See docs/UPGRADE_2.0.md.

BREAKING CHANGE: notify.filter_attributes and notify.level no longer fail
config validation. They are silently ignored; remove them from scrutiny.yaml.
- Add web.listen.max_body_bytes (default 16 MiB). A request declaring a larger
  Content-Length gets HTTP 413 before any handler runs; bodies without a
  declared length are wrapped in http.MaxBytesReader. A smartctl report is
  tens of KiB and a FARM-merged report a few hundred KiB.
- Add web.listen.trusted_proxies (default none) and call SetTrustedProxies, so
  X-Forwarded-For is honoured only from listed proxies. Invalid entries stop
  startup with a config error.
- Prune expired login rate limiter records for every IP on each recorded
  failure so the map cannot grow without bound.

BREAKING CHANGE: the server no longer trusts X-Forwarded-For from any client.
Behind a reverse proxy, set web.listen.trusted_proxies (SCRUTINY_WEB_LISTEN_TRUSTED_PROXIES,
space-separated) to the proxy address or CIDR; until then logs and the login
rate limiter see the proxy IP. Requests larger than 16 MiB are rejected unless
web.listen.max_body_bytes is raised.
script:// targets execute a program on the host. Previously any UI or API caller
could store one and trigger it with the per-URL test endpoint.

- POST /api/settings/notify-urls returns 400 for script:// URLs (case and
  whitespace insensitive).
- POST /api/settings/notify-urls/:id/test refuses a stored script:// URL.
- LoadDatabaseUrls and LoadHeartbeatDatabaseUrls skip database-sourced
  script:// URLs with a warning, and Send drops them from the merged list, so
  rows stored by older releases and queued outbox entries never execute. The
  rows stay in the database.
- script:// in notify.urls (config file or SCRUTINY_NOTIFY_URLS) keeps working.

BREAKING CHANGE: script:// notification URLs can no longer be added through the
UI or API, and ones already stored in the database are skipped with a warning
instead of running. Move them to notify.urls in scrutiny.yaml and delete the
stored entry.
The report_pdf_path dashboard setting let an API caller choose where the server
writes PDFs and which directory the history endpoint lists.

- Remove report_pdf_path from the settings model. The scheduler writes to
  web.reports.path (default /opt/scrutiny/reports).
- Log one warning when a stored report_pdf_path differs from web.reports.path.
- GET /api/reports/history reads web.reports.path, no longer returns the
  server filesystem path, and no longer echoes filesystem error text.
- Update docs/openapi.yaml for both changes.

BREAKING CHANGE: the report_pdf_path setting is removed from the settings API
and ignored if stored. Set web.reports.path (SCRUTINY_WEB_REPORTS_PATH) in the
config file to change the PDF directory. GET /api/reports/history no longer
returns a path field.
Update AUTH.md, UPGRADE_2.0.md, example.scrutiny.yaml and the README config
tables for default-on authentication, generated secrets, trusted proxies, the
request body limit, config-only script:// URLs and web.reports.path.
@Starosdev Starosdev added security Security vulnerabilities and fixes backend Backend/Go related labels Oct 13, 2026
Pass -- before the apprise target so it is never parsed as a flag, reject
apprise+ targets starting with - on save and the test endpoint, skip them when
loaded from the database, and return a generic error from the per-URL test
endpoint (details stay in the server log) so credentials and apprise output are
not echoed to the caller.
…ETS_DIR in collectors

EnsureSecret writes a temp file and publishes it with a hard link so concurrent
callers converge on one value, re-reads the file, and refuses symlinked or
non-regular targets. The collector reads the generated token from
SCRUTINY_WEB_SECRETS_DIR when set, matching the web server.
… auth docs

Limit /api/auth/login bodies to 64 KiB, log a one-time warning when
X-Forwarded-For arrives from a peer outside web.listen.trusted_proxies, and
document proxy lockout risk, JWT secret rotation, and HEAD /api/health.
…symlinks

EnsureSecret writes a 0600 temp file, syncs it, and publishes it with a hard
link so concurrent callers converge on one value. It falls back to an O_EXCL
create on filesystems without hard links, refuses symlinked or non-regular
targets, re-reads the file after writing, and always removes the temp file.
…arded-For

Requests to /api/auth/* get a 64 KiB body cap in addition to the global limit.
ForwardedForWarningMiddleware logs one warning, naming only the peer IP, when a
request carries X-Forwarded-For from a peer outside web.listen.trusted_proxies.
The header is never trusted by it.
…vailable

Replace the O_EXCL-then-write fallback with a <name>.lock exclusive lock (stale
after 30s): under the lock the target is re-checked and a synced temp file is
renamed over it, so readers never see an empty or partial secret. An empty
leftover is replaced only under the lock, and EnsureSecret retries or errors
instead of returning an empty value.
@Starosdev
Starosdev merged commit 9f66e7c into beta Oct 13, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Backend/Go related security Security vulnerabilities and fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant