Skip to content

Make the fingerprint log mean something - #705

Merged
ddon merged 1 commit into
BeamLabEU:mainfrom
alexdont:quiet-fingerprint-log
Aug 11, 2026
Merged

Make the fingerprint log mean something#705
ddon merged 1 commit into
BeamLabEU:mainfrom
alexdont:quiet-fingerprint-log

Conversation

@alexdont

Copy link
Copy Markdown
Contributor

From the integration report, item #5.

What the log looked like

One app carried 765user_agent_mismatch warnings in a single log, plus
[error] ... possible hijacking attempt lines after which — in non-strict mode
— the request was served anyway.

Per-request, unthrottled, naming neither the user nor the session. And
doubled: verify_fingerprint/3 logged, then PhoenixKitWeb.Users.Auth
logged again with a line that said only "for token" and named no token.

That is worse than logging nothing. An error that takes no action and
identifies no one teaches people the log is noise — and the lines that would
have mattered go out with it.

Three changes, all about making the level and the content honest

A user-agent change with the same IP is :info. Browsers rewrite their UA
on every update, roughly monthly, for every user. At warning level this was the
bulk of the volume, for something that is almost never an attack and is never
acted on. A normally-configured app stops seeing it entirely.

Both-changed picks its level from what actually happens:error when
strict mode denies the request, :warning when the request is served. Logging
"possible hijacking attempt" at :error and then serving the request is
precisely what trains people that :error means nothing here.

Every line names its session — a truncated SHA-256 of the token. The raw
token is a bearer credential and must never reach a log; eight hex characters
is enough to group a handful of lines and useless to anyone who steals the
file.

The duplicate log in the web caller is gone. The surviving line already says
what changed, for which session, at the right level.

Testing

Seven tests on what actually reaches the log, including the two cases the
report is about: a UA-only change must not be a warning, and both-changed must
not be an :error when the request is allowed anyway.

Both mutation-tested: restoring the UA warning fails the suite; always logging
:error fails it twice.

Worth noting the suite has to lower Logger.level itself — it runs at
:warning, which is the whole point of the demotion, and from the outside
"logged at info" and "not logged" are the same thing. That's in the setup with
a restore.

256 fingerprint + auth tests pass, credo --strict clean, compiles with
--warnings-as-errors.

Not done

The report's fourth suggestion — surfacing genuine both-changed mismatches as
an admin-visible security event in the sessions UI — is a feature, not a
logging fix, and wants its own design. Left for that.

No version bump or CHANGELOG entry.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NTj7hm3fpCTcFvKLRtgppW

One reporting app carried 765 `user_agent_mismatch` warnings in a single
log, plus `[error] ... possible hijacking attempt` lines after which — in
non-strict mode — the request was served anyway. Per-request, unthrottled,
naming neither the user nor the session, and doubled: `verify_fingerprint/3`
logged, then the web caller logged again with a line that said only "for
token" and named no token.
That is worse than logging nothing. An error that takes no action and
identifies no one teaches people the log is noise, and the lines that would
have mattered go with it.
Three changes, all about making the level and the content honest:
* A user-agent change with the same IP is `:info`, not `:warning`.
Browsers rewrite their UA on every update — roughly monthly, for every
user — so at warning level this was the bulk of the volume, for
something that is almost never an attack and is never acted on. A
normally-configured app stops seeing it entirely.
* Both-changed picks its level from what actually happens: `:error` when
strict mode denies the request, `:warning` when the request is served.
"Possible hijacking attempt" at `:error` followed by serving the
request is exactly what trains people to ignore `:error`.
* Every line names its session — a truncated SHA-256 of the token, since
the raw token is a bearer credential and must never reach a log. Eight
hex characters is enough to group lines and useless to anyone who
steals the file.
The duplicate log in `PhoenixKitWeb.Users.Auth` is gone; the surviving line
already says what changed, for which session, at the right level.
Two mutations checked: restoring the UA warning fails the suite, and always
logging `:error` for both-changed fails it twice.
@ddon
ddon merged commit dbdc125 into BeamLabEU:mainAug 11, 2026
ddon pushed a commit that referenced this pull request Aug 11, 2026
Review docs for all three PRs in dev_docs/pull_requests/2026/.
#704 — two bugs on the read-then-write race in `upsert_inapp/3`:
* The `update_all` filtered on `uuid` alone, so the unseen/undismissed rule
held only in the read. A row dismissed or read in between was refreshed
anyway — the update reported success and the event ended up recorded only
on a row that will never be shown again. The guard now appears in the
write too, which also makes the fallback reachable for the reason it was
written.
* The fallback posted the replacement row without its dedupe key, so it
could never be found again: every later event for that key opened a new
row. One lost race turned collapsing off permanently.
#705 — the level-follows-consequence rule was applied to one branch of three.
Strict mode refuses every mismatch (`PhoenixKitWeb.Users.Auth` answers
`{:warning, _}` exactly as it answers `{:error, _}`), so a strict-mode host
logging someone out over a browser update recorded it at `:info` — below the
default threshold, i.e. not at all. Extracted `log_mismatch/2`; non-strict
behaviour is unchanged in all three branches, which the untouched existing
tests demonstrate.
#703 — the warning was emitted with `IO.warn/1`, which registers a compiler
diagnostic and so failed the build on any host using `--warnings-as-errors`,
on upgrade, over a mix.exs condition that is not a regression in their code.
That contradicted the PR's own guarantee and the rescue guards written to
back it up. Now written straight to stderr; pinned by a test asserting no
diagnostic is registered.
Also updates deps (phoenix 1.8.10, hackney 4.7.3) and corrects the
`version/0` doc example, which still claimed "1.3.3".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ddon pushed a commit that referenced this pull request Aug 14, 2026
An adversarial review of the notifications-upsert, fingerprint-logging
and js-compiler-warning work (PRs #702-#705) confirmed ten findings.
All ten are fixed here; the two that needed schema support ride a new
migration.
Notifications (V170 + code):
The dedupe lookup and the unseen-first ordering had no index support —
recent_for_user runs on every bell mount and had gone from
index-served to fetch-everything-and-sort. V170 adds two indexes: a
partial UNIQUE on (recipient_uuid, metadata->>'dedupe_key') over
undismissed unseen keyed rows, which serves find_collapsible's exact
predicate AND closes the find-then-insert race — two workers inserting
the same absent key now trip the constraint, and insert_collapsible
retries the find and folds. Pre-existing duplicate unseen rows are
folded (all but the newest per key marked dismissed) under the same
table lock that creates the index, so nothing slips into the gap. The
second index matches order_unseen_first's expression term-for-term.
The anticipated constraint trip is not logged as a failure; every
other insert error still is.
upsert_inapp now honors the notifications_enabled kill switch like
create/1 — it is a host-facing entry point, and "off" that quietly did
not apply to the newest creation path was not off. Caller metadata is
merged FIRST and the reserved keys stamped on top, so a passed-through
metadata map can no longer clobber the dedupe_key (silently disabling
collapsing) or the display keys; caller keys are normalized to strings
so %{notification_text: ...} cannot coexist with the string key and
win adapter-order-dependently. find_collapsible gains the catch :exit
the project's soft-failure rule requires (a dead pool EXITS, bypassing
rescue and crashing the caller the comment promised it would not), and
both rescue and catch now log, so a permanent query bug degrading
upsert into insert-always is diagnosable. find_collapsible also
tie-breaks on uuid below inserted_at's second granularity. The inbox's
handle_info whitelist gains :notification_updated — the bell had it,
the inbox did not, so an open inbox showed stale rows exactly when
upsert refreshed one; a scrape test now holds the whitelist to every
event the library broadcasts.
Fingerprint logging:
The dedup had only landed in fetch_phoenix_kit_current_user;
fetch_phoenix_kit_current_scope still carried the old "(scope)"
warning and the ":error possible hijacking" line for requests that
were then served — and the shipped :phoenix_kit_admin_only pipeline
runs BOTH plugs, so a mismatch logged three lines. Verification now
runs at most once per request (verdict cached in conn.private, both
plugs share it) with no extra logging on any branch. session_label is
now THE SAME derivation the sessions UI shows as its token preview
(hex of the raw token's first 4 bytes) — the truncated-sha256 label
could never match it, so the correlation the comment promised failed
every time an operator tried it.
JS-compiler warning:
warn_missing_js_compiler read Mix.Project.config() of whatever project
was compiling, so a module package running its own test suite (its
router expands phoenix_kit_routes(), discovery finds its own beam) got
the fix-your-mix.exs warning on every compile for a configuration that
was correct. The warning is now suppressed when any warned module was
compiled from the current project's own source tree — excluding
deps/, which sits UNDER cwd; the first cut missed that and the test
caught every hex dep reading as locally compiled. And
modules_declaring_js_sources gains a catch clause: a throw or exit
from a discovered module's js_sources/0 escaped the rescue and failed
the host's compile, precisely what the adjacent guarantee promises
cannot happen.
The expected-schema manifest carries the two V170 indexes hand-declared
with definitions and opclasses captured from pg_get_indexdef on a live
database (not hand-derived), and chain_hash was restamped over the 36
shipped files — the same treatment the V165..V167 objects received,
with the same caveat recorded: verify.exs s7/s8 against a real database
is what proves the manifest body, and still requires a pre-squash
checkout.
Full suite: 3536 tests, 0 failures, database reachable (integration
tests ran). The V169 lock-guard and version-headings tests caught two
omissions in the first cut of V170 — both fixed, both now covered.
Sign up for freeto 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.

2 participants

@alexdont@ddon