Task · SFL-0041

security: scrub inline URL credentials from the startup banner and doctor endpoint output

Status
To Do
Labels
followup, phase-1
Milestone
Security & deployment hardening
Updated
2026-08-14

Description

What

sink.loki.url is logged verbatim at INFO on every process start, and inline user:token@ credentials in that URL are fully functional, so a working configuration can write a Grafana Cloud write-scope token to stdout on every restart.

Chain:

The inline form works, so nothing signals the mistake. loki_http is constructed with no auth= (src/sf2loki/app.py:921), and httpx 0.28.1 falls back to URL userinfo when no explicit auth object is set (httpx/_client.py:466-471: BasicAuth(username=request.url.username, password=request.url.password)). BasicAuth.auth_flow then assigns request.headers["Authorization"], overwriting the header that LokiSink._build_headers built from tenant_id/auth_token (src/sf2loki/sinks/loki/sink.py:120-133) before the POST at src/sf2loki/sinks/loki/sink.py:203-207.

Verified against this repo’s pinned httpx (0.28.1) with a MockTransport: POSTing to https://123456:glc_TOKEN@logs-prod-006.grafana.net/loki/api/v1/push while also passing the sink’s own Authorization: Basic ... header produced Authorization: Basic MTIzNDU2OmdsY19UT0tFTg==, i.e. the URL credentials both authenticate and silently win over the configured tenant_id/auth_token.

Same unscrubbed-endpoint pattern in doctor: service.telemetry.endpoint is interpolated into the check detail in every branch — src/sf2loki/doctor.py:455 (unreachable), :464 (401/403), :473 (non-success), :477 (PASS). TelemetryConfig.endpoint is likewise an unvalidated str (src/sf2loki/config.py:1209). sf2loki doctor output is routinely pasted into tickets and chat.

Existing partial mitigation, for context: httpx logs request.url at INFO per request (httpx/_client.py:1740), which would leak the URL on every push, but the httpx logger is floored to WARNING by _CHATTY_LOGGERS / _THIRD_PARTY_LOG_FLOOR (src/sf2loki/obs/logging.py:35-44, 105-107). That floor does not cover sf2loki’s own banner call.

Repo docs consistently show the split form (README.md:290, docs/getting-started.md:60, docs/configuration/index.md:26), but the combined https://<user>:<token>@logs-prod-XX.grafana.net/loki/api/v1/push form is the shape Grafana Cloud hands out for promtail/alloy push configs, so an operator pasting it is the expected failure mode — and it works, so nothing prompts a correction.

Why it matters

An operator sets sink.loki.url (or SF2LOKI_SINK__LOKI__URL / ${GC_LOKI}) to the combined Grafana Cloud push URL. Pushes authenticate, doctor passes, the deployment is healthy. Every container start then writes sink=https://123456:glc_...@logs-prod-006.grafana.net/loki/api/v1/push to stdout at INFO, which container platforms ship into a log aggregation system readable by a much wider audience than the secret store the token was meant to live in. The exposed credential has write scope on the Grafana Cloud stack and must be rotated once discovered. Restart loops multiply the copies.

Second-order: because URL userinfo overrides the Authorization header, an operator who sets both sees tenant_id/auth_token appear to be in effect while the URL credentials are what actually authenticate — auth changes made via the documented fields silently do nothing.

Proposed approach

  1. Add a redaction helper in src/sf2loki/obs/logging.py (imported by both app.py and doctor.py):

    def redact_url_userinfo(url: str) -> str:
        """Return *url* with any inline user:password@ stripped (never raises)."""

    Implementation: str(httpx.URL(url).copy_with(userinfo=b"")) — verified against httpx 0.28.1, turns https://123456:glc_TOKEN@logs-prod-006.grafana.net/loki/api/v1/push into https://logs-prod-006.grafana.net/loki/api/v1/push. Wrap in a try/except returning a hard-coded "<unparseable url>" so a malformed URL can never crash the banner or doctor.

  2. Apply it at both call sites: _StartupInfo(sink_url=redact_url_userinfo(cfg.sink.loki.url)) (src/sf2loki/app.py:1098) and every telemetry.endpoint interpolation in _check_telemetry (src/sf2loki/doctor.py:455, 464, 473, 477). Redacting at capture time (in _StartupInfo) is preferable to redacting at log time — the dataclass then never holds the secret.

  3. Warn loudly when userinfo is present, mirroring the existing advisory-warning precedent (unsalted_hash_warnings in src/sf2loki/transforms.py:166-190, surfaced from both the app startup path and doctor). Add url_userinfo_warnings(cfg) -> list[str] returning one message per offending field:

    • sink.loki.url contains inline credentials; they override sink.loki.tenant_id/auth_token and are not the supported configuration — move them to tenant_id + auth_token(_file).
    • service.telemetry.endpoint contains inline credentials; move them to service.telemetry.basic_auth_user + basic_auth_token(_file).

    Emit at WARNING from the app startup path and as a doctor WARN result. Do not hard-reject in config.py load: the form currently works, so raising ConfigError breaks running deployments on upgrade. A WARN plus scrubbed logs closes the leak; a future breaking change can escalate to rejection.

  4. Docs: add one line to docs/configuration/index.md and the Loki sink reference stating that credentials belong in tenant_id/auth_token(_file), that inline URL credentials override those fields, and that they are stripped from logs but should not be used. config.example.yaml/docs/config-reference.md are generated — if any Field(description=...) text changes, run just gen-config or the drift gate fails (tests/test_config_artifacts_drift.py).


Imported from GitHub issue #125 on 2026-08-14, when this repo migrated from GitHub Issues to Backlog.md. The original issue has been deleted; its verbatim body, labels and comments are preserved in archive/issues-dump.json (jq '.[] | select(.number == 125)' archive/issues-dump.json).

Filed from the 2026-07-30 full-repo audit (11 finder lanes + adversarial verification per finding).

Acceptance Criteria

Definition of Done

References

View the source file on GitHub