Skip to content

Fix HTML escaping in map view - #182

Merged
jleinenbach merged 1 commit into
Bugfixes-for-1.6-beta3from
codex/enhance-html-escaping-in-map_view
Oct 23, 2025
Merged

jleinenbach merged 1 commit into
Bugfixes-for-1.6-beta3from
codex/enhance-html-escaping-in-map_view

Conversation

@jleinenbach

Copy link
Copy Markdown
Owner

Summary

  • escape map view device details, query values, and marker content before embedding them in HTML or scripts
  • update the client-side map controls to percent-encode updated query parameters without dropping existing values
  • add a regression test that verifies the generated map HTML escapes potentially dangerous device metadata

Testing

  • ruff format --check
  • pytest tests/test_map_view_unique_id_resolution.py tests/test_map_view_html_escaping.py

https://chatgpt.com/codex/tasks/task_e_68fa35ed5f208329b146b6c06b3d26cc

@jleinenbach
jleinenbach merged commit 3cc72d3 into Bugfixes-for-1.6-beta3 Oct 23, 2025
4 checks passed
@jleinenbach
jleinenbach deleted the codex/enhance-html-escaping-in-map_view branch October 23, 2025 14:32
jleinenbach added a commit that referenced this pull request Jun 18, 2026
…alize GCM identity whitespace (#1116)

* fix(coordinator): only reset decrypt-failure counter on a real decrypt; normalize GCM identity whitespace

Two Codex review findings on PR #182.

1. polling.py: the consecutive-decrypt-failure counter was reset on any cycle
   without an account-wide decrypt error, including idle cycles that only saw
   empty/no-reporter responses. For intermittently reporting BLE trackers, idle
   cycles between two failing decrypt cycles perpetually cleared the budget, so
   the reauth threshold was never reached and the user stayed stuck with stale
   location data and no reauth prompt. Gate the reset on positive proof
   (cycle_had_successful_decrypt), mirroring the CryptoStatus.OK gate. Unlike
   that gate it intentionally does NOT also require not cycle_had_stale_key:
   this counter tracks the account-wide shared key, while a per-tracker outdated
   owner key never advances it, so proof the account key works must clear it
   even when one tracker still carries an outdated key.

2. shared_helpers.py: the all-whitespace credential whitelist omitted the GCM
   identity material android_id and security_token. Both are parsed via int()
   on the FCM login path, so an interior space from a line-wrapped secrets.json
   paste broke push registration despite the normalization fix. Add both keys.

Tests: a new mutation-sharp idle-cycle test proves the counter is preserved; the
existing reset test now returns a genuinely decryptable location (positive proof)
instead of an empty dict; a new GCM-identity normalization test covers both keys
plus a negative control (app_id stays untouched).

* fix(coordinator): clear shared decrypt-failure budget on background-push decrypt success

The account-wide _consecutive_decrypt_failures counter is fed from three
sources (poll cycle, FCM background-push decode, manual locate) but was only
reset-on-success by the poll cycle. A successful background decrypt never
cleared it, so for push-driven accounts with idle scheduled polls a couple of
non-consecutive background decrypt failures could accumulate to the reauth
threshold and trip a spurious re-authentication for a perfectly healthy key.

Introduce a shared note_decrypt_success() entry point on the coordinator that
mirrors note_decrypt_failure(): it clears the counter (and the stale last-error)
on proven decrypt success. The poll-cycle inline reset is refactored to call it,
and the FCM receiver gains _note_decrypt_success_for_entry(), invoked when a
background decode yields usable locations. Manual locate stays upward-only by
documented design. note_decrypt_success() deliberately does not touch
CryptoStatus, which the poll loop owns with its stale-key-aware gating.

Addresses the Codex review on this PR.

* docs(fcm): clarify source-keyed decrypt-success reset

Add an inline rationale at the background decrypt-success reset explaining
why the shared reauth budget is cleared for the source entry_id only, not
the routed fan-out target set.

The background decrypt validates exactly the source entry's cache/owner_key
and is symmetric with the failure paths, which also key on the source. Routed
targets share the source's FCM registration token (same account-wide
owner_key), so the source success already proves their key. Clearing the
routed target set instead would break the source entry's own reset invariant
and could mask a genuinely stale key on an unrelated entry.

No behavior change (comment only).

* fix(coordinator): keep stale-key diagnostic when clearing decrypt budget

note_decrypt_success() is the shared success entry point for both the poll
cycle and the FCM background-push decode. It used to clear _last_decrypt_error
unconditionally, but a per-tracker StaleOwnerKeyError records that field for the
encryption-key status sensor while deliberately leaving the account-wide counter
at zero and keeping CryptoStatus at tracker_key_outdated. On a mixed cycle (one
stale tracker, one healthy sibling) the success path then wiped the stale-key
diagnostic while the coupled status intentionally stayed outdated, so the sensor
reported an outdated tracker without the last_decrypt_error_class needed to
triage it.

Couple the diagnostic to the CryptoStatus.OK transition instead: the poll OK
gate (already gated on `cycle_had_successful_decrypt and not cycle_had_stale_key`)
now clears _last_decrypt_error together with the OK status, so both move as one
sensor surface. note_decrypt_success() now touches only the account-wide counter,
which both the poll and background paths legitimately reset on proven decrypt.
This also removes a pre-existing push-path divergence (status invalid + error
class cleared).

Add a mixed-cycle regression test asserting the StaleOwnerKeyError class survives
a sibling's successful decrypt, extend the recovery test to assert the OK gate
clears the class when no stale key is present, and align the shared-entry-point
tests with the narrowed contract. Mutation-checked both ways.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
jleinenbach added a commit that referenced this pull request Jun 18, 2026
…ecrypt budget (#1117)

* fix(coordinator): require an authenticated location before clearing decrypt budget

The shared consecutive-decrypt-failure budget (which gates re-authentication)
was cleared whenever a decode produced a non-empty result. The decode layer,
however, returns a single metadata_only sentinel row when no encrypted report
decrypts but the secrets bundle still yields key material (e.g. a phone without
a fresh fix). That row is non-empty but carries no authenticated decrypt, so:

- background push (fcm_receiver_ha): _note_decrypt_success_for_entry fired on it
- poll cycle (polling): cycle_had_successful_decrypt was set on it

Report-less metadata_only pushes/polls interleaved with real failures could keep
a stale E2EE key below the reauth threshold, leaving the account stuck with no
location updates and no reauth prompt.

Introduce a shared predicate is_real_location_record() in decrypt_locations.py
(the sentinel's producer, the single source of truth for the metadata_only
contract) and gate both success signals on it. A genuine decrypted record still
clears the budget; the metadata still flows downstream. Manual locate stays
upward-only (unchanged).

Addresses the Codex review on PR #182.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(coordinator): require decrypted coordinates to clear the decrypt budget

SEMANTIC rows skip the crypto path (decrypted_location=b"", continue before
authentication) and carry no metadata_only flag, so the previous
`metadata_only is not True` denylist wrongly treated them as proof that the
account-wide shared key still decrypts. A semantic-only push/poll interleaved
with real DecryptionError cycles could clear the reauth budget and strand a
stale E2EE key below the threshold.

Replace the denylist with an allowlist on positive proof: a record clears the
budget only if it carries authenticated coordinates (latitude and longitude
not None), the sole output of the crypto path. This rejects metadata_only
sentinels, SEMANTIC rows, and any future report-less shape across both consumer
paths (poll cycle + background push) without changing own/foreign
coordinate-report handling. manual locate stays upward-only.

Tests: predicate unit matrix extended (SEMANTIC, partial-coordinate, 0.0),
plus poll-cycle and background-push regression tests proving a SEMANTIC-only
result does not reset the counter. Mutation-checked (denylist revert and
single-axis predicate both turn the new tests red).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(coordinator): prove decrypt over the full response, not the selected record

The poll path cleared the shared decrypt-failure (reauth) budget only when the
single display-selected location record was an authenticated coordinate report.
api.async_get_device_location collapses a device's response to one record via
_select_best_location, which ranks by newest last_seen. A newer report-less
SEMANTIC/metadata row can therefore outrank and hide a sibling coordinate
report that decrypted successfully in the same response, so the poll loop missed
the proof, left the counter intact, and a later transient failure could trip a
spurious reauth.

The decrypt proof is a property of the whole response, not of one record. Add a
shared any_real_location_record(records) predicate in decrypt_locations.py and
evaluate it over the full candidate list. async_get_device_location carries the
full-list verdict to the poll loop via an internal _decrypt_proven hint (popped
before caching, like _report_hint); the gate prefers the hint and falls back to
the single record only when it is absent (legacy/mocks). The background push
path now uses the same shared helper instead of an inline any(...), keeping both
automatic paths on one full-list contract. Manual locate stays upward-only and
strips the hint at the source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(api): replace asyncio.run with async tests; drop file from guard allowlist

tests/AGENTS.md forbids asyncio.run() inside tests because Home Assistant's
pytest-asyncio harness already manages the event loop (asyncio_mode = auto);
a competing loop breaks fixtures. Codex flagged the two location-selection
tests added for the decrypt-proof fix.

Convert all four asyncio.run() call sites in tests/test_api_location_selection.py
to async def + direct await (the two newly added decrypt-proof tests plus two
pre-existing same-file tests). test_sync_wrappers_guard_when_loop_running now
runs in the managed loop, which is exactly its guarded condition, so its
assertions are unchanged. The file is now free of asyncio.run(), so remove it
from LEGACY_ALLOWLIST in tests/test_guard_asyncio_run_antipattern.py; the guard
now enforces the no-asyncio.run rule on this file going forward.

The remaining ad-hoc loop in test_sync_wrappers_use_provided_session_loop uses
asyncio.new_event_loop()/run_until_complete to capture the session-bound loop
and is intentionally left as-is (the AST guard only matches asyncio.run).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
jleinenbach added a commit that referenced this pull request Jun 18, 2026
… push (#1119)

Codex flagged on #182 that the "Encryption Key Status" diagnostic sensor can
stick on a stale shared_key_invalid / shared_key_missing status on push-only
setups whose scheduled polls stay idle, even though successful FCM background
pushes keep decrypting real locations. Previously the OK transition was owned
solely by the poll cycle, so the reauth budget healed from a push but the
sensor did not.

Add a push-specific coordinator entry point note_background_decrypt_success()
that first clears the shared reauth budget (note_decrypt_success, unchanged)
and then lifts the sensor out of the account-wide failure states
(SHARED_KEY_INVALID / SHARED_KEY_MISSING) that a proven decrypt refutes, moving
the error class with it through the single _set_crypto_status writer. The push
path (_note_decrypt_success_for_entry) now calls it, gated as before on
any_real_location_record (no metadata-only / SEMANTIC heal).

It deliberately leaves TRACKER_KEY_OUTDATED and its error class intact: a single
background decode has no cross-tracker view and cannot prove a per-tracker
outdated owner key was re-paired, so that state stays owned by the poll cycle
(preserves the round-5 diagnostic invariant). UNKNOWN is left untouched too, so
OK is never fabricated before the first real signal. The shared
note_decrypt_success keeps its contract (account-wide counter only), so the
poll path is unchanged.

Tests: parametrized heal of both account-wide failure states, an anti-widening
guard proving TRACKER_KEY_OUTDATED and its error class survive, and an UNKNOWN
no-op; fcm-receiver tests updated to the new push entry point. Mutation-checked
(neutralizing the heal gate turns the two heal tests red). ruff + mypy --strict
clean, full suite green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
jleinenbach added a commit that referenced this pull request Jul 2, 2026
feat: integrate 1.7.2 (E2EE stale-key reauth, encryption-key status sensor, quieter logs)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant