Skip to content

fix(server): extend query-window clamp to /api/logs; cap WATCHER_MAX_QUERY_HOURS (JEF-546)#142

Merged
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-546-extend-query-window-clamp-to-apilogs-cap
Jul 27, 2026
Merged

fix(server): extend query-window clamp to /api/logs; cap WATCHER_MAX_QUERY_HOURS (JEF-546)#142
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-546-extend-query-window-clamp-to-apilogs-cap

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

Closes JEF-546.

Summary

Directly extends JEF-532 (#140): /api/logs' query_logs floored time with
($n IS NULL OR time >= $n) — unlike query_traces, the floor was skipped
entirely when from was NULL, and there was no max-lookback ceiling on an
explicit from either. Combined with the body ILIKE '%'||$n||'%' filter, an
authenticated caller searching a rare/absent term could walk the whole
logs-retention window to satisfy ORDER BY time DESC LIMIT 2000 — the same
full-scan DoS JEF-532 closed for spans, reopened here for logs.

Separately, max_lookback_hours() guarded against 0/negative env misconfig
but not an absurdly large one — WATCHER_MAX_QUERY_HOURS=999999999 resolves
now() - make_interval(hours => N) to the distant past and silently
re-opens the same full-scan.

Changes

  • query_logs: floor time with the same
    GREATEST(COALESCE($from, now()-24h), now()-make_interval(hours=>$max))
    SQL clamp query_traces uses, binding max_lookback_hours() as a new
    positional param ($9). A from within the ceiling is honored unchanged
    (no regression) — GREATEST handles that.
  • max_lookback_hours(): extracted the parse+default logic into a pure
    parse_max_lookback_hours() helper and added .clamp(1, 8760) (1h .. 1yr)
    so every caller (traces/services/logs) inherits the cap.
  • docs/architecture.md: updated the WATCHER_MAX_QUERY_HOURS row to mention
    /api/logs and the [1, 8760] clamp range.

Test plan

  • cargo fmt --check
  • cargo check
  • cargo clippy --all-targets -- -D warnings
  • cargo test --locked (with a local Postgres via docker compose up -d) — 62/62 smoke + 61/61 unit tests pass
  • Added 4 unit tests on parse_max_lookback_hours (huge value clamps to 8760,
    zero/negative clamp to 1, unset/invalid defaults to 168, an in-range value
    passes through unchanged).
  • Added logs_from_beyond_max_lookback_is_clamped, mirroring
    traces_from_beyond_max_lookback_is_clamped: lowers the ceiling via
    WATCHER_MAX_QUERY_HOURS, inserts a log inside it and one outside it, and
    asserts an explicit far-past from still excludes the outside one.
  • Updated two pre-existing log-ingest smoke tests
    (ingest_and_query_a_log, ingest_a_log_batch_persists_every_row_in_one_call)
    that queried /api/logs with no from after ingesting logs at a fixed 1970
    timestamp — the new default 24h floor now (correctly) excludes those from
    the unfiltered /api/logs response, so they now ingest at "now" instead,
    matching the pre-existing convention the trace-ingest tests already use.

Security

Ran soundcheck's /security-review:pr-review pass on the diff: no Critical or
High findings. All SQL parameters remain runtime-bound ($1..$9); no string
interpolation into query text; no new attack surface — this purely tightens
an existing DoS-prevention clamp.

🤖 Generated with Claude Code

…QUERY_HOURS (JEF-546)

query_logs floored `time` with `($n IS NULL OR time >= $n)` — unlike
query_traces (JEF-532), the floor was skipped entirely when `from` was
NULL and there was no max-lookback ceiling on an explicit `from`
either. Combined with the `body ILIKE '%'||$n||'%'` filter, an
authenticated caller searching a rare/absent term could walk the whole
logs-retention window to satisfy `ORDER BY time DESC LIMIT 2000` —
the same full-scan DoS JEF-532 closed for spans, reopened for logs.

Separately, `max_lookback_hours()` guarded against `0`/negative env
misconfig but not an absurdly large one — `WATCHER_MAX_QUERY_HOURS=999999999`
resolves `now() - make_interval(hours => N)` to the distant past and
silently re-opens the same full-scan.

Fix:
- query_logs: floor `time` with the same
  `GREATEST(COALESCE($from, now()-24h), now()-make_interval(hours=>$max))`
  SQL clamp query_traces uses, binding `max_lookback_hours()` as a new
  positional param. A `from` within the ceiling is honored unchanged.
- max_lookback_hours(): extracted the parse+default logic into a pure
  `parse_max_lookback_hours()` helper and added `.clamp(1, 8760)`
  (1h..1yr) so every caller (traces/services/logs) inherits the cap.

Tests: 4 unit tests on parse_max_lookback_hours (huge value clamps to
8760, zero/negative clamp to 1, unset/invalid defaults to 168, an
in-range value passes through) plus a smoke.rs integration test
(logs_from_beyond_max_lookback_is_clamped) mirroring
traces_from_beyond_max_lookback_is_clamped: lowers the ceiling via
WATCHER_MAX_QUERY_HOURS, inserts a log inside it and one outside it,
and asserts an explicit far-past `from` still excludes the outside one.

Also updated two pre-existing log-ingest smoke tests
(ingest_and_query_a_log, ingest_a_log_batch_persists_every_row_in_one_call)
that queried /api/logs with no `from` after ingesting logs at a fixed
1970 timestamp — the new default 24h floor now (correctly) excludes
those from the unfiltered /api/logs response, so they now ingest at
"now" instead, matching the trace tests' existing convention.

Docs: WATCHER_MAX_QUERY_HOURS entry in docs/architecture.md now
mentions /api/logs and the [1, 8760] clamp range.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
@thejefflarson
thejefflarson merged commit 1a57c29 into main Jul 27, 2026
4 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-546-extend-query-window-clamp-to-apilogs-cap branch July 27, 2026 01:57
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