Skip to content

fix(privacy): protect escaped feed addresses and note placeholders - #479

Merged
chhoumann merged 7 commits into
masterfrom
fix/redaction-followups
Oct 14, 2026
Merged

chhoumann merged 7 commits into
masterfrom
fix/redaction-followups

Conversation

@chhoumann

@chhoumann chhoumann commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Summary

Fixes #455.

Private feed addresses written with HTML entities or common aliases could survive in data.json and Settings exports. Match decoded addresses at the shared redaction boundary while preserving public URLs and source bytes. Preserve sentence punctuation, omit empty Resume labels in the new default template, and name unsubscribed feed placeholders only when the title is safe.

A conservative candidate filter and a cache confined to each traversal remove the save regression found by V-479. The read path skips its extra redaction only when restoration changes no JSON value. Both local snapshots and the final write guard remain. The malformed mixed-case &aMp; under-redaction regression is fixed without changing other entity names' case rules.

Verification

  • Node 22.23.2. npm run lint, npm run format:check, npm run typecheck, npm run build, npm run test, npm run test:tooling, npm run docs:build and npm run size all pass on the final merged head. There are 2,686 passing unit tests and 13 passing tooling tests.
  • CI=true npm run test:e2e passed once on a fresh vault with no filter. All 199 tests in 27 files passed on Obsidian 1.14.4, Linux x64, with no unexpected renderer errors. The data.json, Settings export and note sentinel counts are 0/0/0. The unsubscribed note contains neither the private sentinel nor an empty Resume label. The isolated instance is stopped and cleanup reports no remaining PIDs.
  • All 70 public corpus entries and nine transcript fixtures remain byte-identical. Across 15 final production Obsidian phases, the benchmark data.json, public data.json, public note and exported settings all match exactly. npm run count:writes reports three completed writes for three UI settings edits on every phase, with no failed write or extra write from a no-op save.
  • All 35 original mutants and 17 additional filter, memo-lifetime, restoration and duplicate-pass mutants fail named assertions. The complete mutation suite was repeated after fix(sync): show a synced library that Obsidian does not report as changed #480. Every mutated production file is byte-identical on final head after feat(ui): add the full player's Transcript panel #478. The export-only mutant still fails persistence while its export assertion passes.

Performance report | Logs, raw samples and rerunnable scripts

Large-library saves

The verifier's unchanged 200-episode library with one private reference, production builds, isolated Obsidian 1.14.4 on Linux. Each version has 35 retained saves across five fresh-vault phases. Values are medians and full ranges in milliseconds. Master includes the persistence fix in #480 and Transcript panel in #478; both were merged before the final comparison.

Version Save Maximum main-thread timer gap
master 8449c620 230.0 (190.4 to 346.3) 119.1 (91.9 to 178.9)
rejected 1ed75ad 505.8 (419.8 to 1340.5) 363.9 (304.9 to 914.4)
fixed 3d1a3cf8 207.5 (181.2 to 247.5) 113.0 (91.2 to 141.0)

Every save changed and read back its setting and wrote 5,585,824 bytes. The head meets the no-slower-than-master target within observed variation. Shared-host noise limits a precise speedup claim. All samples, including the slow rejected-head phase, remain in the report.

Separate instrumentation found six traversals before this fix and five afterwards. The unchanged-disk shortcut subsequently merged from #480 reduces the normal benchmark path to three. The same 3,538-character description was scanned 2,460 times before and three times on the merged head. Expensive scans fell from 219,740 to 1,722, including both changes. The cache keys the full input string and dies after the traversal. A cross-call cache was skipped because the target is met and persistent invalidation and eviction would add state.

Bundle size

Current master is 321,640 raw and 101,837 gzip bytes. The final bundle is 323,538 raw and 102,654 gzip bytes, or +1,898 raw and +817 gzip versus current master. There are 346 gzip bytes left under the 103,000-byte gate introduced by #478. The performance patch alone added 289 raw and 101 gzip bytes on the original base. The report separates that patch from the later master merges.

Each row removes one item from the final production build. These are marginal costs, not incremental costs relative to master. Compression and shared code mean they do not add up to the total.

Item Raw bytes Gzip bytes
Entity decoding and original-source offsets 596 303
feed: and podcast: aliases 51 12
HTTP alias 20 4
Scheme-less host/path 141 75
Encoded query value 152 60
Sentence-period rule 78 34
Conditional Resume line 50 16
Safe placeholder name 141 37
New text candidate filter 147 50
Traversal memo 63 19

The shared six-entity table saves 34 gzip bytes. All four requested alias classes remain. Feed and podcast links occur in subscription text, HTTP copies follow HTTPS upgrades, scheme-less addresses occur in prose, and encoded addresses occur in redirect query values. Scanners use regex literals and the filter reuses the known-host list.

Wrong alternatives

35 wrong alternatives and the named tests that reject them
Wrong alternative Test that failed
M1 no placeholder skip (rewrites #452-shaped saved placeholders) utility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > leaves a saved placeholder alone even when its encoded name holds the address
M2 scheme-less match inside a public path (slash and backslash allowed as left boundary) createPodcastNote.privacy.test.ts > an unsubscribed feed note names its safe placeholder and leaves a public feed alone
M3 scheme-less match inside another host (no left boundary) createPodcastNote.privacy.test.ts > an unsubscribed feed note names its safe placeholder and leaves a public feed alone
M4 longest-first punctuation (drops the sentence period) utility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > preserves sentence punctuation after a host-only private address
M5 malformed escape not guarded utility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > skips a malformed percent escape and keeps redacting the rest
M6 raw paths percent-decoded too utility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > keeps public path escapes and host-name suffixes distinct from a private address
M7 encoded query value decoded twice utility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > decodes an encoded query value once, so its path escapes stay significant
M8 encoded placeholder written raw into the outer query services/privateFeeds.test.ts > protectPrivateFeeds > redacts entity-escaped descriptions before persistence without changing public bytes
M9 http not folded into https utility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > redacts the HTTP alias while preserving public text byte for byte
M10 over-broad host match (any token on a known host) createPodcastNote.privacy.test.ts > a newly written note redacts a subscribed feed quoted as https://private.example.com/rss?auth=NOTE_SENTINEL
M11 entity decoding skipped (raw match only, as on master) createPodcastNote.privacy.test.ts > an unsubscribed feed note names its safe placeholder and leaves a public feed alone
M12 named entities not decoded services/privateFeeds.test.ts > protectPrivateFeeds > redacts entity-escaped descriptions before persistence without changing public bytes
M13 decoded text written back (source spans ignored) createPodcastNote.privacy.test.ts > a description that spells a subscribed feed with entities is redacted: Your feed: <https://private.example.com/rss?auth=NOTE_SENTINEL>
M14 feed: and podcast: wrappers not unwrapped utility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > redacts the feed scheme alias while preserving public text byte for byte
M15 feed title names the placeholder without the guard createPodcastNote.privacy.test.ts > an unsubscribed feed placeholder never reflects its private address from its name: https://private.example.com/rss?auth=NOTE_SENTINEL
M16 feed placeholder stays nameless createPodcastNote.privacy.test.ts > an unsubscribed feed note names its safe placeholder and leaves a public feed alone
M17 description rendered with the old unnamed context createPodcastNote.privacy.test.ts > an unsubscribed feed note names its safe placeholder and leaves a public feed alone
M18 label rendered even without a link createPodcastNote.privacy.test.ts > the default note omits an empty resume label and keeps public resume links
M19 label not escaped createPodcastNote.privacy.test.ts > a saved template keeps its resume line, and a resume label is plain text
M20 bare episodelink renders the label form createPodcastNote.privacy.test.ts > a saved template keeps its resume line, and a resume label is plain text
M21 default template keeps the old empty-label line createPodcastNote.privacy.test.ts > the default note omits an empty resume label and keeps public resume links
M22 a whole-field URL loses its own trailing punctuation persistence/podNotesData.test.ts > PodNotes data schema > keeps distinct resource URLs that share a private feed address prefix, including quoted links
M23 out-of-range character reference not clamped utility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > skips a malformed character reference and keeps redacting the rest
M24 decode only at export services/privateFeeds.test.ts > protectPrivateFeeds > redacts entity-escaped descriptions before persistence without changing public bytes; export test passed
M25 uppercase entities not decoded (the pre-fix table) utility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > redacts addresses in markup escaped with the uppercase entities HTML defines
M26 only & decoded in capitals utility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > redacts addresses in markup escaped with the uppercase entities HTML defines
M27 title checked as a whole field (the pre-fix guard) createPodcastNote.privacy.test.ts > an unsubscribed feed placeholder never reflects its private address from its name: https://private.example.com/rss?auth=NOTE_SENTINEL.
M28 title escapes not rejected createPodcastNote.privacy.test.ts > an unsubscribed feed placeholder never reflects its private address from its name: https://private.example.com/rss?auth=NOTE_SENTINEL
M29 over-broad title rule rejects every & and % createPodcastNote.privacy.test.ts > an unsubscribed feed placeholder takes the plain title Q&A as its name
M30 every scheme inside a failed address retried (the quadratic retry) utility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > parses a long run of repeated schemes a bounded number of times
M31 a failed encoded address retried from inside utility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > parses a long run of repeated encoded schemes a bounded number of times
M32 trailing punctuation loop unbounded utility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > parses a long run of repeated trailing dots a bounded number of times
M33 a failed address consumed whole (no nested encoded value) services/privateFeeds.test.ts > protectPrivateFeeds > redacts entity-escaped descriptions before persistence without changing public bytes
M34 unusual authorities skipped before parsing utility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > redacts standalone and quoted browser aliases: https:///%70rivate.example.com/rss?auth=ALIAS_SENTINEL
M35 transcript parser loses the shared entities services/publishedTranscript/parse.test.ts > parseTranscript > entities in cue text are decoded once

The runner rejects missing patterns and surviving mutants, and restores each file exactly. M24 runs separately. M31 initially survived because the probe did not reach URL parsing; the corrected fixture includes the known host in each repeated unit and fails the mutation.

17 performance-follow-up mutants and the named assertions that reject them
Wrong alternative Named assertion
F1 filter misses entity spellings (no &) redactPrivateFeedAddresses > redacts the entity in hostname alias while preserving public text byte for byte
F2 filter misses percent spellings (no %) redactPrivateFeedAddresses > redacts standalone and quoted browser aliases: https:///%70rivate.example.com/rss?auth=ALIAS_SENTINEL
F3 filter misses numeric authorities after user info (no @) candidate filtering preserves every covered address spelling > redacts numeric authority after user info and preserves its distinct public address
F4 filter misses backslash aliases candidate filtering preserves every covered address spelling > redacts numeric authority with backslashes and preserves its distinct public address
F5 filter misses IPv6 authorities (no [) redactPrivateFeedAddresses > redacts a quoted address whose authority normalizes to https://[::1]
F6 filter misses non-ASCII authorities redactPrivateFeedAddresses > redacts a quoted address whose authority normalizes to https://xn--bcher-kva.example.com
F7 filter misses numeric authorities redactPrivateFeedAddresses > redacts a quoted address whose authority normalizes to https://127.0.0.1
F8 numeric branch misses feed: and podcast: candidate filtering preserves every covered address spelling > redacts numeric feed authority and preserves its distinct public address
F9 numeric branch misses http: and https: redactPrivateFeedAddresses > redacts a quoted address whose authority normalizes to https://127.0.0.1
F10 known host matched case-sensitively PodNotes persistence integration > names a renamed private feed's episodes by its saved key and leaves its URL nowhere
F11 known host never admits a string createPodcastNote.privacy.test.ts > a newly written note redacts a subscribed feed quoted as https://private.example.com/rss?auth=NOTE_SENTINEL
F12 text memo outlives its call and address map createPodcastNote.privacy.test.ts > an unsubscribed feed placeholder never reflects its private address from its name: https://private.example.com/rss?auth=NOTE_SENTINEL
F13 malformed mixed-case amp undecoded again redactPrivateFeedAddresses > redacts addresses in markup escaped with the uppercase entities HTML defines
F14 every entity name decoded in any case candidate filtering preserves every covered address spelling > decodes amp in any case, but no other entity name HTML lacks in capitals
D1 restored entries never redacted on read PodNotes persistence integration > when a synced private feed arrives with a queue that had to be repaired > redacts the address in the entries it keeps from this device
E3 read pass always redacts again (speed only) PodNotes persistence integration > does not rescan an equal repair just because restoration copied it
E4 eager restoreDropped copies count as restored (speed only) PodNotes persistence integration > does not rescan an equal repair just because restoration copied it

Scope and release impact

No data shape, public API or URI change. Existing saved templates and saved placeholder identities remain intact. Issue #452 remains open. The isolated vault's default template matches DEFAULT_SETTINGS. The transcript parser imports its six existing common entities from the shared table, and its results are unchanged. FeedRepository, PlayerController and player UI are untouched.

The original Claude/GPT review panel found uppercase-entity and unsafe-name regressions, repeated suffix work and stale mutation patterns. Those fixes remain covered. V-479 then found the save regression, addressed by this follow-up. The coordinator's delta verification is pending. Double encoding, other wrapper schemes and the verifier's Unicode-prefixed scheme-less-host caveat remain outside this change.

Before screenshot | After screenshot | Original PRIV5 journey evidence

Docs at release

  • In templates.md, document {{episodelink:Label}} as an escaped Markdown link when a resume URI exists and empty otherwise. Keep the bare tag documented. Replace the default template's resume line with {{episodelink:Resume in PodNotes}}.
  • In notes.md, use that labelled tag in the example. Explain that the new default applies only to new installs. Saved templates are never migrated; existing users can replace their resume line to opt in, and existing notes are not rewritten.
  • In sync.md and import_export.md, describe redaction of quoted private addresses in the covered entity and alias spellings. In templates.md, explain that unsubscribed placeholders include a safe feed title and omit an unsafe title. Keep Private feed names can retain their secret inside encoded placeholders #452 explicitly separate. Explain that old backups and notes are not rewritten.

Published docs are unchanged in this PR.

Note

Harden private-feed redaction against escaped and encoded addresses

  • Reworks redactPrivateFeedAddresses in privateFeedUrl.ts into a lookup-driven recursive redactor. It now matches entity-escaped, percent-encoded, scheme-aliased, and host-alias spellings of private feed addresses in strings, arrays, and object keys, while preserving unrelated bytes and unchanged object references.
  • Adds a feedRedactionContext helper and labeled episodelink support in TemplateEngine.ts. Private feed descriptions get a title-bearing placeholder when the feed title is safe; unsafe or encoded titles never become placeholder names. The default note template in constants.ts and obsidian-e2e.config.mjs now use the labeled episodelink form.
  • Fixes the persistence adapter in main.ts to skip a second document-wide redaction pass when a repair equals the synced value, and re-redacts only when restoration actually changed data.
  • Shares the common named HTML entities in htmlEntities.ts and uses them in the transcript parser's decodeEntities.
  • Risk: redactPrivateFeedAddresses now throws when a key redaction would overwrite an existing field, and placeholders are protected from being reinterpreted as their original addresses — check consumers of these helpers if they relied on the old direct string replacement.

Macroscope summarized af71c7b.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 3d1a3cf
Status: ✅  Deploy successful!
Preview URL: https://b790c917.podnotes.pages.dev
Branch Preview URL: https://fix-redaction-followups.podnotes.pages.dev

View logs

@chhoumann
chhoumann merged commit 632e8b3 into master Oct 14, 2026
16 checks passed
@chhoumann
chhoumann deleted the fix/redaction-followups branch October 14, 2026 21:29
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.

Private feed redaction follow-ups: entity-escaped copies in data.json, remaining alias forms

1 participant