Commit graph

32 commits

Author SHA1 Message Date
41a2828ef0
decouple the dashboard from the server binary
Move the Svelte dashboard out of internal/web into its own top-level
frontend/ so it can be built and hosted independently, and make the
subidx binary API-only: drop the embedded dist and the catch-all /
handler, so unknown paths return a plain 404.

The frontend already knew how to live apart (VITE_API_BASE, -cors-origins,
vercel.json), so only the embed glue is gone. Also wire in Vercel Web
Analytics via @vercel/analytics.
2026-08-28 23:45:25 +05:30
a5d2e56f06
Expose API headers across CORS boundary
Cross-origin dashboards could not read x-ratelimit-*, x-total-count,
x-max-seq or x-truncated, which silently disabled the budget meter and
live-feed cursor backfill on split deployments.
2026-08-26 21:29:59 +05:30
b56447f067
Add web dashboard, live feed, and deployment configs
- Embedded Svelte dashboard: search, virtualized results, filter, sort,
  dates, export, rate budget meter, streaming NDJSON search
- Live feed: SSE /v1/feed, delta polling /v1/watch, x-max-seq cursor,
  ingest hook and subscriber hub, store mutex fix so reads never starve
  behind ingest backlog
- gzip middleware, /v1/stats with cache and bounded top-k
- CORS allow-list flag for split frontend deployments
- Dockerfile + render.yaml + vercel.json, serve gains -no-drain
2026-08-26 21:24:26 +05:30
6994a924fa
Add package comments for staticcheck's ST1000
The lint job runs with checks:all, which enables ST1000 (each
package needs a doc comment). Give all six packages concise
package comments; validated locally with -checks all.
2026-08-24 01:14:43 +05:30
35c2cb1fa4
Fix staticcheck findings before enabling it in CI
Drop unused test helper asn1MarshalOctetString (U1000) and the
vacuous uint64 >= 0 comparison in the readiness check (SA4003) —
readiness is simply that Total() answers without error.
2026-08-24 01:12:24 +05:30
509fe9bab8
tailer: verify fetched entries against the signed root before ingest
Every fetched batch must now prove itself: before ingesting, the
tailer picks one cryptographically random entry, fetches its
get-proof-by-hash audit path, and verifies locally that the entry
hashes up to the currently verified STH root. Entries served outside
the signed tree — by a tampered connection or a misbehaving log —
fail the check, the batch is refused, the watermark does not advance,
and the cycle retries. Before this, entries were trusted purely on
TLS to the log endpoint; only the STH was verified.

The fake log in tests now serves a real RFC 6962 Merkle root and
audit paths, so the existing round-trip test exercises the gate, and
TestTamperedEntriesRejected proves that entries outside the signed
tree are never stored and never advance the watermark.

README updated: the provenance claim now matches what the code does.
2026-08-24 01:04:41 +05:30
dee4564656
rfc6962: implement Merkle leaf hashes and inclusion proof verification
Add LeafHash (SHA-256 of 0x00 || leaf per RFC 6962 §2), interior node
hashing, VerifyInclusion implementing the §2.1.2 audit-path algorithm,
and a ProofByHash client method for get-proof-by-hash (query properly
URL-escaped for base64 '+' and '/' bytes).

This is the machinery needed to bind fetched entries to a verified
signed tree head; the tailer wires it up in the next change.

Tests build a reference tree recursively (independent of the iterative
verifier) and cross-check every index for sizes 1..33, plus rejection
of wrong root, tampered/truncated paths, wrong index, foreign leaf.
2026-08-24 01:02:59 +05:30
9bae65d528
loglist,tailer: support the frozen log state
statePriority omitted "frozen", so CurrentState() returned "" for
frozen logs and the tailer skipped them entirely — even though frozen
logs are readable, hold full history, and expose a final_tree_head,
which is exactly what the drain feature targets. Rank frozen between
pending and readonly, and give it the same drain-to-final-tree-head
treatment as readonly. Tests cover state recognition and priority.
2026-08-24 00:44:05 +05:30
a6faae704b
rfc6962: normalize precert SANs through dedupeLower
The x509 path lowercased names, trimmed trailing dots and wildcard
prefixes, dropped control bytes, and deduplicated; the precert
(TBS) path returned raw SAN strings with none of that, leaving the
ingest filter single-layered for precerts and inconsistent casing in
stored names. Route both paths through the same normalization; test
covers mixed case, trailing dot, wildcard, and duplicate collapse.
2026-08-24 00:43:33 +05:30
7e9ca049e6
rfc6962: validate STH fields and signature algorithms
VerifySTH accepted any root-hash length and ignored the DigitallySigned
hash/signature algorithm bytes, so a signed-but-degenerate STH (empty
root) or an algorithm-confused signature verified cleanly. Require a
32-byte root hash, a non-negative tree size, sha256 as the hash
algorithm, and a signature algorithm matching the pinned key type
(ecdsa=3, rsa=1).

Tests cover the valid case plus forged signature, short root, sha1
label, rsa label on an ecdsa key, and negative tree size — the
signature-verification failure path had no coverage before.
2026-08-24 00:43:02 +05:30
c8f89f009e
server: fail closed when a limiter shard is full
evictShard deleted arbitrary live buckets once a shard hit its key
cap. An attacker minting fresh keys (spoofed XFF, IPv6 /64 rotation)
could churn a shard until their own full bucket — or a victim's — was
evicted, resetting the counter and defeating the per-IP limit
entirely. Unknown keys are now denied while the shard is full;
expired buckets are still reclaimed first. Regression test pins the
fail-closed behavior and bucket survival for existing keys.
2026-08-24 00:42:04 +05:30
0da168062c
rfc6962,loglist: harden HTTP egress against SSRF via log lists
Endpoints come from externally fetched log lists and were used as-is:
any scheme was accepted and the default HTTP client followed up to 10
redirects, so a compromised list source could aim the tailer at
internal hosts (e.g. cloud metadata) even though STH signatures would
fail. Now NewClient requires https except for loopback (local test
servers), both clients refuse redirects outright, and a redirect
response surfaces as a normal HTTP error. Tests cover scheme
rejection and redirect refusal.
2026-08-24 00:41:05 +05:30
4cc3d42a69
rfc6962: trim trailing slash from log base URLs
Log list entries joined as BaseURL+path break when the URL carries a
trailing slash: https://log.example.com/ + /ct/v1/get-sth becomes
...//ct/v1/get-sth and every request fails. Normalize once in
NewClient. Test covers the trailing-slash case end to end.
2026-08-24 00:39:53 +05:30
5d9d7dd34b
store: gofmt after sort import 2026-08-24 00:39:08 +05:30
c67b4adc45
loglist: thread context through FetchAll and fetchOne
FetchAll issued three sequential 30s-timeout HTTP fetches with no
context, so shutdown could stall up to ~90s waiting for the hourly
sync loop. runTailers already owns a cancellation context; pass it
through so cancelation aborts in-flight list fetches immediately.

First tests for the package: context cancellation and error status.
2026-08-24 00:38:54 +05:30
3ec531e117
tailer,store: fail loudly when the watermark cannot be read
A failed Watermark read (IO error, corruption, closed store) defaulted
the watermark to 0, silently re-draining the entire log from index 0.
Skip the log instead and log the failure.

Also make getRaw hold the store mutex and return errStoreClosed after
Close: pebble panics on Get-after-Close, so a tailer (or any reader)
racing shutdown crashed the process instead of receiving an error.

Test: TestWatermarkErrorSkipsLog proves the tailer never contacts the
log when the watermark read fails.
2026-08-24 00:38:26 +05:30
8f66a9f2e0
store: replace O(n²) selection sort in Top with sort.Slice
Top ran a quadratic selection sort over every apex, so 'subidx stats'
would effectively hang once the apex count reached the millions.
sort.Slice plus truncate is O(n log n); add a regression test for
ordering by count.
2026-08-24 00:36:45 +05:30
9a232a950c
server: reject unexpected Host headers to block DNS rebinding
The API accepted any Host header, so a page running a DNS rebinding
attack could point attacker.com at 127.0.0.1 and read the collected
index from the victim's browser as same-origin JavaScript. Requests
whose Host is not localhost/127.0.0.1/::1 now get 421; -allowed-hosts
extends the list for exposed deployments. Rate limiting keyed on the
victim's own IP provided no protection here.
2026-08-22 03:44:43 +05:30
ad4d81fe50
store: survive corrupt stored values
apply() sliced record values at fixed offsets and getMeta, Count and
Top called Uint64 without checking length, so a truncated or
hand-corrupted value panicked the ingest loop or the stats command.
All decoders now validate lengths; corrupt records are overwritten,
corrupt meta/count values return errors.
2026-08-21 21:47:04 +05:30
d407ac0840
loglist: error when every log list source fails
FetchAll swallowed per-source errors and always returned nil, so all
three upstreams failing was indistinguishable from an empty log list
and the hourly sync silently tailed nothing. Total failure now
returns an error; partial failure logs a warning.
2026-08-21 21:44:48 +05:30
2be376e728
apex,tailer: normalize ingest names to punycode
Certificate SANs went into the store after only lowercasing, so a
unicode name like 'buecher.example.com' was stored as raw UTF-8 and
could never be found by search, which runs IDNA. Ingest now maps
names through a permissive IDNA profile (keeping DKIM-style
underscores that the strict lookup profile rejects), enforces RFC
1035 label lengths, and stores the canonical form. Normalize also no
longer lowercases before IDNA mapping, which produced wrong punycode
for decomposed unicode input.
2026-08-21 21:42:54 +05:30
bba101bbab
tailer: account for every fetched entry and log skips
fetchRange counted an entry as consumed before attempting to decode
it, so undecodable entries were skipped permanently with no record,
and the position could overshoot the requested range if a log served
more entries than asked. The position now advances by exactly the
number of received entries (clamped to the range) and skips are
logged with their index window.
2026-08-21 21:38:52 +05:30
5edad988b2
store: run recounts inside the ingest loop
Recount mutated totalBase/pendingTotal under the store mutex while the
ingest loop read and wrote them unlocked (a data race), and left stale
per-apex count caches that corrupted subsequent increments. Recount is
now an ingest-loop operation: it flushes pending records, rescans, and
resets loop-owned state inline, so counters have a single owner.
2026-08-21 21:37:28 +05:30
89990ec41f
server: cap rate limiter memory under key-flooding
Every distinct client key allocated a bucket retained for up to 24
hours, and keys are cheap to mint at line rate (spoofed XFF values,
IPv6 /64s), so a remote attacker could grow the map until the process
ran out of memory. Shards now evict expired buckets and then arbitrary
victims once a shard exceeds 4096 buckets.
2026-08-21 21:35:29 +05:30
620ca5cac8
store,server: cap buffered results per search query
Scan materialized every record for an apex in memory, sorted it, and
the server rebuilt the full body twice more. One query for a large
apex could buffer hundreds of megabytes three times over; a few
concurrent requests exhausted RAM. Scan now keeps only the top N
results by insertion sequence via a bounded heap (default 100000,
configurable with -max-results), preserving collection order.
2026-08-21 21:34:03 +05:30
c4b0f85c42
server: validate X-Forwarded-For before using it as limiter key
The limiter indexed the XFF chain at len(trustedHops) without checking
that the trusted suffix contained valid IPs. With an over-counted
trusted-proxy-hops setting, a single garbage header entry became the
rate limit bucket and attackers could rotate it to bypass the limit.
Bogus headers now fall back to the connection address.
2026-08-21 21:31:35 +05:30
250652a3dd
tailer: require pinned log keys and stop on STH verification failure
VerifySTH silently passed when no log key was pinned or when an STH
arrived without a signature, and a failed verification only produced a
log line while ingestion continued from the unauthenticated source.
NewClient now refuses logs without keys, the tailer skips the fetch
cycle on verification failure, and it rejects list entries whose
log_id does not match SHA-256 of the key.
2026-08-21 21:30:40 +05:30
394174763d
tailer: fix nil client dereference on malformed log key
NewClient returns (nil, err) when a log list entry has an undecodable
key, but the error path called client.ShortID() on the nil client,
panicking the tailer goroutine and crashing the process.
2026-08-21 21:24:48 +05:30
6f5b4c961f
rfc6962: parse SCT lists as raw TLS bytes, not DER
Both call sites pass the extnValue OCTET STRING contents, which per
RFC 6962 section 3.3 are already the TLS-encoded SCT list. The extra
asn1.Unmarshal failed on virtually every real certificate (the first
byte is the high byte of the uint16 length), so embedded SCT
timestamps were silently dropped and first_seen fell back to the leaf
timestamp. The test masked this by double-wrapping the value.
2026-08-21 21:24:09 +05:30
f9d1e3585a
rfc6962,apex: reject control and non-printable bytes in hostnames
Certificate SANs are attacker-controlled and were copied into store
keys unfiltered. Since 0x00 is the apex/sub key separator, a SAN like
'ab\0cd.qqq' corrupted per-apex counts during recount and made scan
bounds ambiguous. Names now must be printable ASCII at ingest, and
Normalize rejects them as defense in depth.
2026-08-21 21:23:22 +05:30
8037630e85
store: persist log watermarks atomically with ingested records
The tailer fsynced the watermark immediately after pushing records into
the async ingest batch, so a crash could persist the marker without the
data it covers, losing those entries permanently. Watermark updates now
travel through the same channel as records and are written inside the
same Pebble batch, and never move backwards.
2026-08-21 21:22:14 +05:30
9374d0a191
subidx: CT log tailer and crt.name-compatible subdomain search API 2026-08-21 14:26:32 +05:30