Repository navigation
fix(privacy): protect escaped feed addresses and note placeholders - #479
Merged
Merged
Conversation
Deploying podnotes with
|
| Latest commit: |
3d1a3cf
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://b790c917.podnotes.pages.dev |
| Branch Preview URL: | https://fix-redaction-followups.podnotes.pages.dev |
This was referenced Oct 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
npm run lint,npm run format:check,npm run typecheck,npm run build,npm run test,npm run test:tooling,npm run docs:buildandnpm run sizeall pass on the final merged head. There are 2,686 passing unit tests and 13 passing tooling tests.CI=true npm run test:e2epassed 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.npm run count:writesreports three completed writes for three UI settings edits on every phase, with no failed write or extra write from a no-op save.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.
8449c6201ed75ad3d1a3cf8Every 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.
feed:andpodcast:aliasesThe 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
utility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > leaves a saved placeholder alone even when its encoded name holds the addresscreatePodcastNote.privacy.test.ts > an unsubscribed feed note names its safe placeholder and leaves a public feed alonecreatePodcastNote.privacy.test.ts > an unsubscribed feed note names its safe placeholder and leaves a public feed aloneutility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > preserves sentence punctuation after a host-only private addressutility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > skips a malformed percent escape and keeps redacting the restutility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > keeps public path escapes and host-name suffixes distinct from a private addressutility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > decodes an encoded query value once, so its path escapes stay significantservices/privateFeeds.test.ts > protectPrivateFeeds > redacts entity-escaped descriptions before persistence without changing public bytesutility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > redacts the HTTP alias while preserving public text byte for bytecreatePodcastNote.privacy.test.ts > a newly written note redacts a subscribed feed quoted as https://private.example.com/rss?auth=NOTE_SENTINELcreatePodcastNote.privacy.test.ts > an unsubscribed feed note names its safe placeholder and leaves a public feed aloneservices/privateFeeds.test.ts > protectPrivateFeeds > redacts entity-escaped descriptions before persistence without changing public bytescreatePodcastNote.privacy.test.ts > a description that spells a subscribed feed with entities is redacted: Your feed: <https://private.example.com/rss?auth=NOTE_SENTINEL>utility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > redacts the feed scheme alias while preserving public text byte for bytecreatePodcastNote.privacy.test.ts > an unsubscribed feed placeholder never reflects its private address from its name: https://private.example.com/rss?auth=NOTE_SENTINELcreatePodcastNote.privacy.test.ts > an unsubscribed feed note names its safe placeholder and leaves a public feed alonecreatePodcastNote.privacy.test.ts > an unsubscribed feed note names its safe placeholder and leaves a public feed alonecreatePodcastNote.privacy.test.ts > the default note omits an empty resume label and keeps public resume linkscreatePodcastNote.privacy.test.ts > a saved template keeps its resume line, and a resume label is plain textcreatePodcastNote.privacy.test.ts > a saved template keeps its resume line, and a resume label is plain textcreatePodcastNote.privacy.test.ts > the default note omits an empty resume label and keeps public resume linkspersistence/podNotesData.test.ts > PodNotes data schema > keeps distinct resource URLs that share a private feed address prefix, including quoted linksutility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > skips a malformed character reference and keeps redacting the restservices/privateFeeds.test.ts > protectPrivateFeeds > redacts entity-escaped descriptions before persistence without changing public bytes; export test passedutility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > redacts addresses in markup escaped with the uppercase entities HTML definesutility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > redacts addresses in markup escaped with the uppercase entities HTML definescreatePodcastNote.privacy.test.ts > an unsubscribed feed placeholder never reflects its private address from its name: https://private.example.com/rss?auth=NOTE_SENTINEL.createPodcastNote.privacy.test.ts > an unsubscribed feed placeholder never reflects its private address from its name: https&#58;//private.example.com/rss?auth=NOTE_SENTINELcreatePodcastNote.privacy.test.ts > an unsubscribed feed placeholder takes the plain title Q&A as its nameutility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > parses a long run of repeated schemes a bounded number of timesutility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > parses a long run of repeated encoded schemes a bounded number of timesutility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > parses a long run of repeated trailing dots a bounded number of timesservices/privateFeeds.test.ts > protectPrivateFeeds > redacts entity-escaped descriptions before persistence without changing public bytesutility/privateFeedUrl.test.ts > redactPrivateFeedAddresses > redacts standalone and quoted browser aliases: https:///%70rivate.example.com/rss?auth=ALIAS_SENTINELservices/publishedTranscript/parse.test.ts > parseTranscript > entities in cue text are decoded onceThe 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
redactPrivateFeedAddresses > redacts the entity in hostname alias while preserving public text byte for byteredactPrivateFeedAddresses > redacts standalone and quoted browser aliases: https:///%70rivate.example.com/rss?auth=ALIAS_SENTINELcandidate filtering preserves every covered address spelling > redacts numeric authority after user info and preserves its distinct public addresscandidate filtering preserves every covered address spelling > redacts numeric authority with backslashes and preserves its distinct public addressredactPrivateFeedAddresses > redacts a quoted address whose authority normalizes to https://[::1]redactPrivateFeedAddresses > redacts a quoted address whose authority normalizes to https://xn--bcher-kva.example.comredactPrivateFeedAddresses > redacts a quoted address whose authority normalizes to https://127.0.0.1candidate filtering preserves every covered address spelling > redacts numeric feed authority and preserves its distinct public addressredactPrivateFeedAddresses > redacts a quoted address whose authority normalizes to https://127.0.0.1PodNotes persistence integration > names a renamed private feed's episodes by its saved key and leaves its URL nowherecreatePodcastNote.privacy.test.ts > a newly written note redacts a subscribed feed quoted as https://private.example.com/rss?auth=NOTE_SENTINELcreatePodcastNote.privacy.test.ts > an unsubscribed feed placeholder never reflects its private address from its name: https://private.example.com/rss?auth=NOTE_SENTINELredactPrivateFeedAddresses > redacts addresses in markup escaped with the uppercase entities HTML definescandidate filtering preserves every covered address spelling > decodes amp in any case, but no other entity name HTML lacks in capitalsPodNotes 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 devicePodNotes persistence integration > does not rescan an equal repair just because restoration copied itPodNotes persistence integration > does not rescan an equal repair just because restoration copied itScope 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
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}}.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.sync.mdandimport_export.md, describe redaction of quoted private addresses in the covered entity and alias spellings. Intemplates.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
redactPrivateFeedAddressesin 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.feedRedactionContexthelper and labeledepisodelinksupport 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 labeledepisodelinkform.decodeEntities.redactPrivateFeedAddressesnow 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.