Skip to content

Commit a5683db

Browse files
authored
fix: make episode identity key collision-resistant and prototype-safe (#226)
getEpisodeKey built an episode's identity as `${podcastName}::${title}` with no escaping, so distinct (podcastName, title) pairs collided: ("A", "B::C") and ("A::B", "C") both mapped to "A::B::C", and a boundary colon (("A:", "B") vs ("A", ":B")) collided too. Both fields come verbatim from RSS, so a malicious feed could alias another subscribed feed's played-state / dedup key. Keep the plain `name::title` format for ordinary pairs (byte-identical, so existing data.json keys resolve with no migration) and escape only the rare delimiter-forging pair into a NUL-prefixed, colon-free, injective form that is disjoint from both the plain composite keys and the legacy title-only keys. The manual escape never throws (unlike encodeURIComponent on lone surrogates), so a hostile value cannot turn key generation into a crash. findPlayedEpisodesInFeeds grouped played episodes onto a plain object keyed by the feed-controlled podcastName, so names like "__proto__" / "constructor" resolved to inherited prototype members and crashed `.push`, rejecting the returned promise. Group with a Map so any feed-controlled name is a safe key. deepsec: other-key-collision, other-unsafe-object-key
1 parent a79e529 commit a5683db

4 files changed

Lines changed: 265 additions & 10 deletions

File tree

src/utility/episodeKey.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from "vitest";
22

33
import type { Episode } from "src/types/Episode";
4-
import { isSameStoredEpisode } from "./episodeKey";
4+
import { getEpisodeKey, isSameStoredEpisode } from "./episodeKey";
55

66
function ep(title: string, podcastName?: string): Episode {
77
return {
@@ -14,6 +14,91 @@ function ep(title: string, podcastName?: string): Episode {
1414
} as unknown as Episode;
1515
}
1616

17+
describe("getEpisodeKey", () => {
18+
it("keeps the plain podcastName::title format for ordinary episodes (backward compatible)", () => {
19+
expect(getEpisodeKey(ep("Episode 1", "My Podcast"))).toBe(
20+
"My Podcast::Episode 1",
21+
);
22+
});
23+
24+
it("keeps the plain format when names/titles carry single colons", () => {
25+
// Single colons cannot forge the `::` delimiter, so these stay verbatim and
26+
// need no migration.
27+
expect(getEpisodeKey(ep("Ch 5: The Return", "Serial: A Show"))).toBe(
28+
"Serial: A Show::Ch 5: The Return",
29+
);
30+
});
31+
32+
it("falls back to the title for legacy episodes without a podcastName", () => {
33+
expect(getEpisodeKey(ep("Legacy Title"))).toBe("Legacy Title");
34+
});
35+
36+
it("returns an empty string when episode or title is missing", () => {
37+
expect(getEpisodeKey(undefined)).toBe("");
38+
expect(getEpisodeKey(null)).toBe("");
39+
expect(getEpisodeKey(ep(""))).toBe("");
40+
});
41+
42+
it("does not collide when the delimiter appears inside a component (#other-key-collision)", () => {
43+
// Both pairs used to map to "A::B::C".
44+
const a = getEpisodeKey(ep("B::C", "A")); // podcastName "A", title "B::C"
45+
const b = getEpisodeKey(ep("C", "A::B")); // podcastName "A::B", title "C"
46+
expect(a).not.toBe(b);
47+
});
48+
49+
it("does not collide when a name ends with / a title starts with the delimiter colon", () => {
50+
// Both pairs used to map to "A:::B".
51+
const a = getEpisodeKey(ep("B", "A:")); // ("A:", "B")
52+
const b = getEpisodeKey(ep(":B", "A")); // ("A", ":B")
53+
expect(a).not.toBe(b);
54+
});
55+
56+
it("keeps escaped keys disjoint from plain keys (no `::`)", () => {
57+
const encoded = getEpisodeKey(ep("B::C", "A"));
58+
expect(encoded.includes("::")).toBe(false);
59+
expect(encoded).not.toBe(getEpisodeKey(ep("Plain", "Podcast")));
60+
});
61+
62+
it("keeps escaped keys disjoint from legacy title-only keys", () => {
63+
// A feed with an empty <title> produces podcastName="" -> a raw-title key.
64+
// The escaped form minus its (NUL) prefix is an ordinary title a feed could
65+
// carry, so a legacy episode with that title must NOT alias another feed's
66+
// escaped composite key.
67+
const escaped = getEpisodeKey(ep("a", ":")); // podcastName=":", title="a"
68+
expect(escaped.charCodeAt(0)).toBe(0); // the NUL prefix is present
69+
const realisticTitle = escaped.slice(1); // an ordinary, NUL-free title
70+
expect(getEpisodeKey(ep(realisticTitle))).not.toBe(escaped);
71+
});
72+
73+
it("maps a batch of delimiter-adjacent pairs to distinct keys (injective)", () => {
74+
const pairs: Array<[name: string, title: string]> = [
75+
["A", "B::C"],
76+
["A::B", "C"],
77+
["A:", "B"],
78+
["A", ":B"],
79+
["A::B::C", "D"],
80+
["A", "B"],
81+
["A:", ":B"],
82+
[":A", "B:"],
83+
];
84+
const keys = pairs.map(([name, title]) => getEpisodeKey(ep(title, name)));
85+
expect(new Set(keys).size).toBe(pairs.length);
86+
});
87+
88+
it("treats __proto__/constructor names as ordinary string keys", () => {
89+
expect(getEpisodeKey(ep("Ep", "__proto__"))).toBe("__proto__::Ep");
90+
expect(typeof getEpisodeKey(ep("Ep", "constructor"))).toBe("string");
91+
});
92+
93+
it("never throws on a delimiter-forging value containing a lone surrogate", () => {
94+
// encodeURIComponent would throw URIError here; the manual escape must not,
95+
// so a hostile feed value cannot crash key generation.
96+
expect(() => getEpisodeKey(ep("::\uD800", "Pod"))).not.toThrow();
97+
const key = getEpisodeKey(ep("::\uD800", "Pod"));
98+
expect(key.includes("::")).toBe(false);
99+
});
100+
});
101+
17102
describe("isSameStoredEpisode (#214)", () => {
18103
it("matches the same podcast + title (composite key)", () => {
19104
expect(isSameStoredEpisode(ep("E1", "Pod"), ep("E1", "Pod"))).toBe(true);
@@ -34,6 +119,11 @@ describe("isSameStoredEpisode (#214)", () => {
34119
expect(isSameStoredEpisode(ep("E1"), ep("E2", "Pod"))).toBe(false);
35120
});
36121

122+
it("does not conflate two feeds that forge the `::` delimiter (#other-key-collision)", () => {
123+
// ("A", "B::C") and ("A::B", "C") used to share the key "A::B::C".
124+
expect(isSameStoredEpisode(ep("B::C", "A"), ep("C", "A::B"))).toBe(false);
125+
});
126+
37127
it("returns false for null/undefined inputs", () => {
38128
expect(isSameStoredEpisode(undefined, ep("E1", "Pod"))).toBe(false);
39129
expect(isSameStoredEpisode(ep("E1", "Pod"), null)).toBe(false);

src/utility/episodeKey.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,80 @@
11
import type { Episode } from "src/types/Episode";
22

3+
const KEY_DELIMITER = "::";
4+
5+
/**
6+
* Joining a (podcastName, title) pair with a raw `::` is only unambiguous when
7+
* neither component can forge or straddle the delimiter. The mapping breaks
8+
* (two distinct pairs produce one key) when a component contains `::`, or the
9+
* name ends with `:` / the title starts with `:` - those merge with the
10+
* delimiter into `:::`, which can be split two ways. Concretely
11+
* ("A", "B::C") and ("A::B", "C") both yield "A::B::C", and ("A:", "B") and
12+
* ("A", ":B") both yield "A:::B". Both components come verbatim from a
13+
* (potentially malicious) RSS feed, so such a pair can be crafted to collide
14+
* with another subscribed feed's episode. Pairs flagged here are encoded
15+
* instead so the key stays injective.
16+
*/
17+
function compositeKeyIsAmbiguous(podcastName: string, title: string): boolean {
18+
return (
19+
podcastName.includes(KEY_DELIMITER) ||
20+
title.includes(KEY_DELIMITER) ||
21+
podcastName.endsWith(":") ||
22+
title.startsWith(":")
23+
);
24+
}
25+
26+
/**
27+
* A NUL byte cannot appear in XML feed text (nor in the parsed PocketCasts
28+
* titles), so the escaped form below is prefixed with it. That keeps the escaped
29+
* key disjoint from the legacy title-only keys, which are raw, arbitrary titles
30+
* (a feed with an empty `<title>` yields `podcastName === ""` and so a raw-title
31+
* key - see feedParser/pocketCastsParser). Without the prefix a crafted legacy
32+
* title could equal an escaped composite key and re-introduce a cross-feed
33+
* collision.
34+
*/
35+
const ESCAPED_KEY_PREFIX = "\u0000";
36+
37+
/**
38+
* Escapes a key component so it contains no `:` at all: the escape char is
39+
* doubled (`\` -> `\\`) and every colon becomes `\c`. The result is therefore
40+
* colon-free and the mapping is injective. Unlike `encodeURIComponent` this
41+
* never throws (e.g. on lone surrogates), so a hostile feed value cannot turn
42+
* key generation into a crash.
43+
*/
44+
function escapeKeyComponent(value: string): string {
45+
return value.replace(/\\/g, "\\\\").replace(/:/g, "\\c");
46+
}
47+
348
/**
449
* Generates a unique key for an episode.
550
* Uses podcastName + title to avoid collisions between episodes with the same title
651
* from different podcasts.
752
*
53+
* The common case keeps the plain `podcastName::title` format so existing
54+
* `data.json` keys (and the dedup heuristics that look for `::`) stay valid with
55+
* no migration. Only the rare delimiter-forging pair (see
56+
* {@link compositeKeyIsAmbiguous}) is escaped, which makes the key collision-
57+
* resistant: a feed cannot choose a name/title that maps onto another feed's
58+
* episode key.
59+
*
860
* Falls back to title-only for backwards compatibility with episodes that don't have podcastName.
961
*/
1062
export function getEpisodeKey(episode: Episode | null | undefined): string {
1163
if (!episode || !episode.title) {
1264
return "";
1365
}
1466
if (episode.podcastName) {
15-
return `${episode.podcastName}::${episode.title}`;
67+
const { podcastName, title } = episode;
68+
if (compositeKeyIsAmbiguous(podcastName, title)) {
69+
// Collision-resistant form for the rare delimiter-forging pair. Each
70+
// escaped component is colon-free, so joining with a single `:` yields a
71+
// key with exactly one colon and never the `::` delimiter, keeping it
72+
// disjoint from the plain composite keys below; the NUL prefix keeps it
73+
// disjoint from the legacy title-only keys. The encoding is injective, so
74+
// no two distinct pairs can share a key.
75+
return `${ESCAPED_KEY_PREFIX}${escapeKeyComponent(podcastName)}:${escapeKeyComponent(title)}`;
76+
}
77+
return `${podcastName}${KEY_DELIMITER}${title}`;
1678
}
1779
// Fallback for legacy episodes without podcastName
1880
return episode.title;
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
import type { Episode } from "src/types/Episode";
4+
import type { PlayedEpisode } from "src/types/PlayedEpisode";
5+
import type { PodcastFeed } from "src/types/PodcastFeed";
6+
7+
// Feed parsing hits the network, so stub it with a per-url lookup the tests fill in.
8+
const { feedEpisodes } = vi.hoisted(() => ({
9+
feedEpisodes: new Map<string, Episode[]>(),
10+
}));
11+
12+
vi.mock("src/parser/feedParser", () => ({
13+
default: class {
14+
private feed: PodcastFeed | undefined;
15+
constructor(feed?: PodcastFeed) {
16+
this.feed = feed;
17+
}
18+
async getEpisodes(url: string): Promise<Episode[]> {
19+
return feedEpisodes.get(url) ?? [];
20+
}
21+
},
22+
}));
23+
24+
import findPlayedEpisodesInFeeds from "./findPlayedEpisodes";
25+
26+
function playedEp(title: string, podcastName: string): PlayedEpisode {
27+
return { title, podcastName, time: 0, duration: 0, finished: true };
28+
}
29+
30+
function episode(title: string, podcastName: string): Episode {
31+
return {
32+
title,
33+
podcastName,
34+
streamUrl: "",
35+
url: "",
36+
description: "",
37+
content: "",
38+
};
39+
}
40+
41+
function feed(title: string, url: string): PodcastFeed {
42+
return { title, url, artworkUrl: "" };
43+
}
44+
45+
describe("findPlayedEpisodesInFeeds", () => {
46+
beforeEach(() => {
47+
feedEpisodes.clear();
48+
});
49+
50+
it("does not crash when a feed-controlled podcast name shadows Object.prototype (#other-unsafe-object-key)", async () => {
51+
// "__proto__"/"constructor"/"hasOwnProperty" resolve to inherited members on
52+
// a plain object accumulator and would throw on `.push`; a Map keys safely.
53+
const played = [
54+
playedEp("Ep A", "__proto__"),
55+
playedEp("Ep B", "constructor"),
56+
playedEp("Ep C", "hasOwnProperty"),
57+
];
58+
59+
await expect(findPlayedEpisodesInFeeds(played, [])).resolves.toEqual([]);
60+
});
61+
62+
it("returns the feed episodes whose title matches a played episode, including for a __proto__ feed", async () => {
63+
feedEpisodes.set("https://a.example/feed", [
64+
episode("Ep A", "Pod A"),
65+
episode("Ep Z", "Pod A"),
66+
]);
67+
feedEpisodes.set("https://proto.example/feed", [
68+
episode("Ep P", "__proto__"),
69+
]);
70+
71+
const played = [
72+
playedEp("Ep A", "Pod A"),
73+
playedEp("Missing", "Pod A"),
74+
playedEp("Ep P", "__proto__"),
75+
];
76+
const feeds = [
77+
feed("Pod A", "https://a.example/feed"),
78+
feed("__proto__", "https://proto.example/feed"),
79+
];
80+
81+
const result = await findPlayedEpisodesInFeeds(played, feeds);
82+
83+
expect(result.map((e) => e.title)).toEqual(["Ep A", "Ep P"]);
84+
});
85+
86+
it("skips played episodes whose podcast is not among the feeds", async () => {
87+
feedEpisodes.set("https://a.example/feed", [episode("Ep A", "Pod A")]);
88+
89+
const result = await findPlayedEpisodesInFeeds(
90+
[playedEp("Ep A", "Pod A"), playedEp("Ep B", "Unsubscribed")],
91+
[feed("Pod A", "https://a.example/feed")],
92+
);
93+
94+
expect(result.map((e) => e.title)).toEqual(["Ep A"]);
95+
});
96+
});

src/utility/findPlayedEpisodes.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,24 @@ export default async function findPlayedEpisodesInFeeds(
77
playedEpisodes: PlayedEpisode[],
88
feeds: PodcastFeed[],
99
): Promise<Episode[]> {
10-
const episodesByPodcast = playedEpisodes.reduce((acc: { [podcastName: string]: PlayedEpisode[] }, episode) => {
11-
const podcastName = episode.podcastName;
12-
const episodes = acc[podcastName] || [];
13-
episodes.push(episode);
14-
acc[podcastName] = episodes;
15-
return acc;
16-
}, {});
10+
// Group by podcast name with a Map, not a plain object. The name comes
11+
// verbatim from a feed's `<title>`, so a crafted value like "__proto__" or
12+
// "constructor" would resolve `acc[name]` to an inherited prototype member
13+
// (truthy, no `.push`) and throw, rejecting this Promise. A Map keys on the
14+
// string itself, so any name is handled safely.
15+
const episodesByPodcast = new Map<string, PlayedEpisode[]>();
16+
for (const episode of playedEpisodes) {
17+
const episodes = episodesByPodcast.get(episode.podcastName);
18+
if (episodes) {
19+
episodes.push(episode);
20+
} else {
21+
episodesByPodcast.set(episode.podcastName, [episode]);
22+
}
23+
}
1724

1825
const playedEpisodesInFeeds: Episode[] = [];
1926

20-
for (const [podcastName, episodes] of Object.entries(episodesByPodcast)) {
27+
for (const [podcastName, episodes] of episodesByPodcast) {
2128
const feed = feeds.find(feed => feed.title === podcastName);
2229
if (!feed) continue;
2330

0 commit comments

Comments
 (0)