feeds-fun 1.25.2 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/EntryForList.vue +24 -3
- package/src/components/FeedForList.vue +1 -0
- package/src/components/body_list/EntryBody.vue +70 -17
- package/src/components/body_list/EntryCover.vue +75 -0
- package/src/components/body_list/Reference.vue +55 -0
- package/src/components/body_list/References.vue +16 -0
- package/src/components/main/IntegrationsTable.vue +154 -0
- package/src/components/main/ShowMoreButton.vue +17 -0
- package/src/integrations/YouTube.vue +29 -0
- package/src/logic/api.ts +13 -0
- package/src/logic/enums.ts +32 -0
- package/src/logic/iframeSanitizer.ts +267 -0
- package/src/logic/settings.ts +1 -0
- package/src/logic/tests/iframeSanitizer.test.ts +111 -0
- package/src/logic/tests/utils.test.ts +109 -0
- package/src/logic/types.ts +163 -47
- package/src/logic/utils.ts +31 -1
- package/src/main.ts +12 -0
- package/src/stores/entries.ts +9 -1
- package/src/stores/integrations.ts +14 -0
- package/src/values/Icon.vue +18 -0
- package/src/views/FeedsView.vue +10 -0
- package/src/views/MainView.vue +8 -6
- package/src/views/RulesView.vue +8 -2
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
type PathMatcher = (url: URL) => boolean;
|
|
2
|
+
|
|
3
|
+
type ProviderRule = {
|
|
4
|
+
hostname: string;
|
|
5
|
+
matchesPath: PathMatcher;
|
|
6
|
+
rewriteUrl?: (url: URL) => URL;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const DOM_PURIFY_IFRAME_OPTIONS = {
|
|
10
|
+
ADD_TAGS: ["iframe"],
|
|
11
|
+
ADD_ATTR: ["src", "title"]
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const GENERATED_IFRAME_ATTRIBUTES = {
|
|
15
|
+
loading: "lazy",
|
|
16
|
+
referrerpolicy: "strict-origin-when-cross-origin",
|
|
17
|
+
sandbox: "allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox",
|
|
18
|
+
allow: "fullscreen; picture-in-picture; encrypted-media"
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
const IFRAME_TITLE_DEFAULT = "Embedded media";
|
|
22
|
+
|
|
23
|
+
const oneSegmentAfter = (prefix: string): PathMatcher => {
|
|
24
|
+
const expression = new RegExp(`^${prefix}/[^/]+$`);
|
|
25
|
+
|
|
26
|
+
return (url: URL) => expression.test(url.pathname);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const startsWithPath = (prefix: string): PathMatcher => {
|
|
30
|
+
const expression = new RegExp(`^${prefix}/.+$`);
|
|
31
|
+
|
|
32
|
+
return (url: URL) => expression.test(url.pathname);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function rewriteYoutubeToPrivacyEnhanced(url: URL) {
|
|
36
|
+
const rewritten = new URL(url.toString());
|
|
37
|
+
rewritten.hostname = "www.youtube-nocookie.com";
|
|
38
|
+
return rewritten;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function addVimeoDnt(url: URL) {
|
|
42
|
+
const rewritten = new URL(url.toString());
|
|
43
|
+
rewritten.searchParams.set("dnt", "1");
|
|
44
|
+
return rewritten;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hasQueryParameter(url: URL, name: string, value?: string) {
|
|
48
|
+
if (!url.searchParams.has(name)) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return value === undefined || url.searchParams.get(name) === value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isAbsoluteHttpsUrl(rawUrl: string) {
|
|
56
|
+
if (rawUrl.trim() !== rawUrl) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const url = new URL(rawUrl);
|
|
62
|
+
return url.protocol === "https:";
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sanitizedUrl(rawUrl: string) {
|
|
69
|
+
if (!isAbsoluteHttpsUrl(rawUrl)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const url = new URL(rawUrl);
|
|
74
|
+
const rule = PROVIDER_RULES.find((candidate) => candidate.hostname === url.hostname);
|
|
75
|
+
|
|
76
|
+
if (rule === undefined || !rule.matchesPath(url)) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
url.username = "";
|
|
81
|
+
url.password = "";
|
|
82
|
+
url.hash = "";
|
|
83
|
+
|
|
84
|
+
const rewritten = rule.rewriteUrl?.(url) ?? url;
|
|
85
|
+
|
|
86
|
+
return rewritten.toString();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const PROVIDER_RULES: ProviderRule[] = [
|
|
90
|
+
{
|
|
91
|
+
hostname: "www.youtube-nocookie.com",
|
|
92
|
+
matchesPath: (url) => /^\/embed(\/[^/]+)?$/.test(url.pathname)
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
hostname: "www.youtube.com",
|
|
96
|
+
matchesPath: (url) => /^\/embed(\/[^/]+)?$/.test(url.pathname),
|
|
97
|
+
rewriteUrl: rewriteYoutubeToPrivacyEnhanced
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
hostname: "player.vimeo.com",
|
|
101
|
+
matchesPath: (url) => /^\/video\/\d+$/.test(url.pathname),
|
|
102
|
+
rewriteUrl: addVimeoDnt
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
hostname: "geo.dailymotion.com",
|
|
106
|
+
matchesPath: (url) => /^\/player(\/[^/]+\.html|\.html)$/.test(url.pathname)
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
hostname: "www.dailymotion.com",
|
|
110
|
+
matchesPath: (url) => /^\/embed\/video\/[^/]+$/.test(url.pathname)
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
hostname: "embed.ted.com",
|
|
114
|
+
matchesPath: oneSegmentAfter("/talks")
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
hostname: "www.ted.com",
|
|
118
|
+
matchesPath: startsWithPath("/embed")
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
hostname: "w.soundcloud.com",
|
|
122
|
+
matchesPath: (url) => url.pathname === "/player/"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
hostname: "open.spotify.com",
|
|
126
|
+
matchesPath: (url) => /^\/embed\/[^/]+\/[^/]+$/.test(url.pathname)
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
hostname: "bandcamp.com",
|
|
130
|
+
matchesPath: startsWithPath("/EmbeddedPlayer")
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
hostname: "www.mixcloud.com",
|
|
134
|
+
matchesPath: (url) => url.pathname === "/widget/iframe/"
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
hostname: "embed.music.apple.com",
|
|
138
|
+
matchesPath: (url) => /^\/[a-z]{2}\/[^/]+\/.+$/.test(url.pathname)
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
hostname: "player.twitch.tv",
|
|
142
|
+
matchesPath: (url) => url.pathname === "/"
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
hostname: "clips.twitch.tv",
|
|
146
|
+
matchesPath: (url) => url.pathname === "/embed"
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
hostname: "player.bilibili.com",
|
|
150
|
+
matchesPath: (url) => url.pathname === "/player.html"
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
hostname: "archive.org",
|
|
154
|
+
matchesPath: oneSegmentAfter("/embed")
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
hostname: "framatube.org",
|
|
158
|
+
matchesPath: oneSegmentAfter("/videos/embed")
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
hostname: "videopress.com",
|
|
162
|
+
matchesPath: oneSegmentAfter("/v")
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
hostname: "www.openstreetmap.org",
|
|
166
|
+
matchesPath: (url) => url.pathname === "/export/embed.html"
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
hostname: "www.google.com",
|
|
170
|
+
matchesPath: (url) =>
|
|
171
|
+
/^\/maps\/embed\/v1\/[^/]+$/.test(url.pathname) ||
|
|
172
|
+
url.pathname === "/maps/embed" ||
|
|
173
|
+
url.pathname === "/maps/d/embed"
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
hostname: "maps.google.com",
|
|
177
|
+
matchesPath: (url) => url.pathname === "/maps" && hasQueryParameter(url, "output", "embed")
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
hostname: "umap.openstreetmap.fr",
|
|
181
|
+
matchesPath: (url) => /^\/([a-z]{2}\/)?map\/[^/]+_\d+$/.test(url.pathname)
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
hostname: "docs.google.com",
|
|
185
|
+
matchesPath: (url) =>
|
|
186
|
+
/^\/presentation\/d\/e\/[^/]+\/embed$/.test(url.pathname) ||
|
|
187
|
+
(/^\/document\/d\/e\/[^/]+\/pub$/.test(url.pathname) && hasQueryParameter(url, "embedded", "true")) ||
|
|
188
|
+
/^\/spreadsheets\/d\/e\/[^/]+\/pubhtml$/.test(url.pathname)
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
hostname: "speakerdeck.com",
|
|
192
|
+
matchesPath: oneSegmentAfter("/player")
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
hostname: "www.scribd.com",
|
|
196
|
+
matchesPath: (url) => /^\/embeds\/[^/]+\/content$/.test(url.pathname)
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
hostname: "www.slideshare.net",
|
|
200
|
+
matchesPath: startsWithPath("/slideshow/embed_code")
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
hostname: "www.canva.com",
|
|
204
|
+
matchesPath: (url) => /^\/design\/[^/]+\/view$/.test(url.pathname) && hasQueryParameter(url, "embed")
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
hostname: "view.officeapps.live.com",
|
|
208
|
+
matchesPath: (url) => {
|
|
209
|
+
const source = url.searchParams.get("src");
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
url.pathname === "/op/embed.aspx" &&
|
|
213
|
+
source !== null &&
|
|
214
|
+
isAbsoluteHttpsUrl(source) &&
|
|
215
|
+
Array.from(url.searchParams.keys()).every((key) => key === "src")
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
function setGeneratedAttributes(iframe: HTMLIFrameElement, src: string, title: string | null) {
|
|
222
|
+
iframe.setAttribute("src", src);
|
|
223
|
+
iframe.setAttribute("title", title || IFRAME_TITLE_DEFAULT);
|
|
224
|
+
iframe.setAttribute("loading", GENERATED_IFRAME_ATTRIBUTES.loading);
|
|
225
|
+
iframe.setAttribute("referrerpolicy", GENERATED_IFRAME_ATTRIBUTES.referrerpolicy);
|
|
226
|
+
iframe.setAttribute("sandbox", GENERATED_IFRAME_ATTRIBUTES.sandbox);
|
|
227
|
+
iframe.setAttribute("allow", GENERATED_IFRAME_ATTRIBUTES.allow);
|
|
228
|
+
iframe.setAttribute("allowfullscreen", "");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function replacementForIframe(source: HTMLIFrameElement) {
|
|
232
|
+
const src = source.getAttribute("src");
|
|
233
|
+
|
|
234
|
+
if (src === null) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const sanitizedSrc = sanitizedUrl(src);
|
|
239
|
+
|
|
240
|
+
if (sanitizedSrc === null) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const iframe = source.ownerDocument.createElement("iframe");
|
|
245
|
+
|
|
246
|
+
setGeneratedAttributes(iframe, sanitizedSrc, source.getAttribute("title"));
|
|
247
|
+
|
|
248
|
+
return iframe;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function sanitizeIframes(html: string) {
|
|
252
|
+
const parsed = new DOMParser().parseFromString(html, "text/html");
|
|
253
|
+
const iframes = parsed.body.querySelectorAll("iframe");
|
|
254
|
+
|
|
255
|
+
for (const iframe of iframes) {
|
|
256
|
+
const replacement = replacementForIframe(iframe);
|
|
257
|
+
|
|
258
|
+
if (replacement === null) {
|
|
259
|
+
iframe.remove();
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
iframe.replaceWith(replacement);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return parsed.body.innerHTML;
|
|
267
|
+
}
|
package/src/logic/settings.ts
CHANGED
|
@@ -23,6 +23,7 @@ export const crmPrivacy = import.meta.env.VITE_FFUN_CRM_PRIVACY || null;
|
|
|
23
23
|
export const crmImpressum = import.meta.env.VITE_FFUN_CRM_IMPRESSUM || null;
|
|
24
24
|
|
|
25
25
|
export const hasCollections = import.meta.env.VITE_FFUN_HAS_COLLECTIONS == "true" || false;
|
|
26
|
+
export const hasIntegrations = import.meta.env.VITE_FFUN_HAS_INTEGRATIONS == "true" || false;
|
|
26
27
|
|
|
27
28
|
function jsonOrDefault<T>(value: string | undefined, defaultValue: T): T {
|
|
28
29
|
if (!value) return defaultValue;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {describe, expect, it} from "vitest";
|
|
2
|
+
import {sanitizeIframes} from "@/logic/iframeSanitizer";
|
|
3
|
+
|
|
4
|
+
function iframeFor(src: string) {
|
|
5
|
+
const sanitized = sanitizeIframes(`<iframe src="${src}"></iframe>`);
|
|
6
|
+
const parsed = new DOMParser().parseFromString(sanitized, "text/html");
|
|
7
|
+
return parsed.body.querySelector("iframe");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("sanitizeIframes", () => {
|
|
11
|
+
it.each([
|
|
12
|
+
"https://www.youtube-nocookie.com/embed/video-id",
|
|
13
|
+
"https://www.youtube-nocookie.com/embed/videoseries?list=playlist-id",
|
|
14
|
+
"https://www.youtube-nocookie.com/embed",
|
|
15
|
+
"https://player.vimeo.com/video/12345",
|
|
16
|
+
"https://geo.dailymotion.com/player/player-id.html?video=video-id",
|
|
17
|
+
"https://geo.dailymotion.com/player.html?video=video-id",
|
|
18
|
+
"https://www.dailymotion.com/embed/video/video-id",
|
|
19
|
+
"https://embed.ted.com/talks/talk-slug",
|
|
20
|
+
"https://www.ted.com/embed/talks/talk-slug",
|
|
21
|
+
"https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/1",
|
|
22
|
+
"https://open.spotify.com/embed/track/track-id",
|
|
23
|
+
"https://bandcamp.com/EmbeddedPlayer/album=1/size=large",
|
|
24
|
+
"https://www.mixcloud.com/widget/iframe/?feed=%2Fshow%2F",
|
|
25
|
+
"https://embed.music.apple.com/us/album/album-name/12345",
|
|
26
|
+
"https://player.twitch.tv/?channel=channel&parent=feeds.fun",
|
|
27
|
+
"https://clips.twitch.tv/embed?clip=clip-id&parent=feeds.fun",
|
|
28
|
+
"https://player.bilibili.com/player.html?bvid=video-id",
|
|
29
|
+
"https://archive.org/embed/archive-id",
|
|
30
|
+
"https://framatube.org/videos/embed/video-id",
|
|
31
|
+
"https://videopress.com/v/video-id",
|
|
32
|
+
"https://www.openstreetmap.org/export/embed.html?bbox=1%2C2%2C3%2C4",
|
|
33
|
+
"https://www.google.com/maps/embed/v1/place?key=key&q=place",
|
|
34
|
+
"https://www.google.com/maps/embed?pb=map-data",
|
|
35
|
+
"https://www.google.com/maps/d/embed?mid=map-id",
|
|
36
|
+
"https://maps.google.com/maps?q=place&output=embed",
|
|
37
|
+
"https://umap.openstreetmap.fr/en/map/map-name_123",
|
|
38
|
+
"https://docs.google.com/presentation/d/e/presentation-id/embed",
|
|
39
|
+
"https://docs.google.com/document/d/e/document-id/pub?embedded=true",
|
|
40
|
+
"https://docs.google.com/spreadsheets/d/e/sheet-id/pubhtml",
|
|
41
|
+
"https://speakerdeck.com/player/deck-id",
|
|
42
|
+
"https://www.scribd.com/embeds/document-id/content",
|
|
43
|
+
"https://www.slideshare.net/slideshow/embed_code/key",
|
|
44
|
+
"https://www.canva.com/design/design-id/view?embed",
|
|
45
|
+
"https://view.officeapps.live.com/op/embed.aspx?src=https%3A%2F%2Fexample.com%2Fdocument.docx"
|
|
46
|
+
])("allows provider embed URL %s", (src) => {
|
|
47
|
+
expect(iframeFor(src)).not.toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("rewrites YouTube embeds to the privacy-enhanced host", () => {
|
|
51
|
+
const iframe = iframeFor("https://www.youtube.com/embed/video-id?start=10#fragment");
|
|
52
|
+
|
|
53
|
+
expect(iframe?.getAttribute("src")).toBe("https://www.youtube-nocookie.com/embed/video-id?start=10");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("adds Vimeo DNT parameter", () => {
|
|
57
|
+
const iframe = iframeFor("https://player.vimeo.com/video/12345?h=hash");
|
|
58
|
+
|
|
59
|
+
expect(iframe?.getAttribute("src")).toBe("https://player.vimeo.com/video/12345?h=hash&dnt=1");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("removes iframes that do not satisfy source requirements", () => {
|
|
63
|
+
const sanitized = sanitizeIframes(`
|
|
64
|
+
<iframe></iframe>
|
|
65
|
+
<iframe src="http://www.youtube-nocookie.com/embed/video-id"></iframe>
|
|
66
|
+
<iframe src="https://www.youtube-nocookie.com.evil.example/embed/video-id"></iframe>
|
|
67
|
+
<iframe src="https://www.youtube-nocookie.com/watch?v=video-id"></iframe>
|
|
68
|
+
<iframe src="/embed/video-id"></iframe>
|
|
69
|
+
`);
|
|
70
|
+
|
|
71
|
+
expect(sanitized).not.toContain("<iframe");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("rebuilds allowed iframes with only generated attributes and title", () => {
|
|
75
|
+
const sanitized = sanitizeIframes(`
|
|
76
|
+
<iframe
|
|
77
|
+
src="https://www.youtube-nocookie.com/embed/video-id"
|
|
78
|
+
width="560"
|
|
79
|
+
height="315"
|
|
80
|
+
title="Video title"
|
|
81
|
+
srcdoc="<p>HTML</p>"
|
|
82
|
+
loading="eager"
|
|
83
|
+
referrerpolicy="no-referrer"
|
|
84
|
+
sandbox="allow-top-navigation"
|
|
85
|
+
allow="camera"
|
|
86
|
+
allowfullscreen="false"
|
|
87
|
+
name="source-frame">
|
|
88
|
+
</iframe>
|
|
89
|
+
`);
|
|
90
|
+
const parsed = new DOMParser().parseFromString(sanitized, "text/html");
|
|
91
|
+
const iframe = parsed.body.querySelector("iframe");
|
|
92
|
+
|
|
93
|
+
expect(iframe?.getAttribute("src")).toBe("https://www.youtube-nocookie.com/embed/video-id");
|
|
94
|
+
expect(iframe?.getAttribute("title")).toBe("Video title");
|
|
95
|
+
expect(iframe?.getAttribute("loading")).toBe("lazy");
|
|
96
|
+
expect(iframe?.getAttribute("referrerpolicy")).toBe("strict-origin-when-cross-origin");
|
|
97
|
+
expect(iframe?.getAttribute("sandbox")).toBe(
|
|
98
|
+
"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
|
99
|
+
);
|
|
100
|
+
expect(iframe?.getAttribute("allow")).toBe("fullscreen; picture-in-picture; encrypted-media");
|
|
101
|
+
expect(iframe?.hasAttribute("allowfullscreen")).toBe(true);
|
|
102
|
+
expect(iframe?.hasAttribute("width")).toBe(false);
|
|
103
|
+
expect(iframe?.hasAttribute("height")).toBe(false);
|
|
104
|
+
expect(iframe?.hasAttribute("srcdoc")).toBe(false);
|
|
105
|
+
expect(iframe?.hasAttribute("name")).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("uses a fallback iframe title when source title is missing", () => {
|
|
109
|
+
expect(iframeFor("https://archive.org/embed/archive-id")?.getAttribute("title")).toBe("Embedded media");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -7,6 +7,73 @@ afterEach(() => {
|
|
|
7
7
|
});
|
|
8
8
|
|
|
9
9
|
describe("purifyBody", () => {
|
|
10
|
+
it("removes styling and page behavior attributes", () => {
|
|
11
|
+
const raw = `
|
|
12
|
+
<p aria-label="Summary" class="lead" data-id="42" dir="rtl" id="entry"
|
|
13
|
+
lang="en" onclick="alert()" slot="main" style="color: red" title="Title">Body</p>
|
|
14
|
+
<a href="https://example.com" class="link" download ping="https://tracker.example"
|
|
15
|
+
rel="nofollow" target="_self" type="text/html">Link</a>
|
|
16
|
+
<img alt="Chart" height="100" src="https://example.com/chart.png"
|
|
17
|
+
sizes="(max-width: 600px) 100vw, 600px"
|
|
18
|
+
srcset="https://example.com/chart-2x.png 2x" style="width: 100px"
|
|
19
|
+
width="100" />
|
|
20
|
+
<video controls poster="https://example.com/poster.png" style="width: 100%">
|
|
21
|
+
<source media="(min-width: 800px)" src="https://example.com/video.mp4"
|
|
22
|
+
type="video/mp4" />
|
|
23
|
+
<track default kind="captions" label="English"
|
|
24
|
+
src="https://example.com/captions.vtt" srclang="en" />
|
|
25
|
+
</video>
|
|
26
|
+
<table><tr><th colspan="2" scope="col" style="color:red">Head</th></tr></table>
|
|
27
|
+
<ol reversed start="5" type="A"><li value="7">Item</li></ol>
|
|
28
|
+
<time datetime="2026-04-27">Today</time>
|
|
29
|
+
<svg viewBox="0 0 10 10">
|
|
30
|
+
<path d="M0 0h10v10H0z" fill="red" stroke="blue"></path>
|
|
31
|
+
</svg>
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
const purified = purifyBody({raw, default_: "No description"});
|
|
35
|
+
|
|
36
|
+
expect(purified).toContain('aria-label="Summary"');
|
|
37
|
+
expect(purified).toContain('dir="rtl"');
|
|
38
|
+
expect(purified).toContain('href="https://example.com"');
|
|
39
|
+
expect(purified).toContain('lang="en"');
|
|
40
|
+
expect(purified).toContain('src="https://example.com/chart.png"');
|
|
41
|
+
expect(purified).toContain('sizes="(max-width: 600px) 100vw, 600px"');
|
|
42
|
+
expect(purified).toContain('srcset="https://example.com/chart-2x.png 2x"');
|
|
43
|
+
expect(purified).toContain('alt="Chart"');
|
|
44
|
+
expect(purified).toContain('title="Title"');
|
|
45
|
+
expect(purified).toContain("controls");
|
|
46
|
+
expect(purified).toContain('poster="https://example.com/poster.png"');
|
|
47
|
+
expect(purified).toContain('media="(min-width: 800px)"');
|
|
48
|
+
expect(purified).toContain('src="https://example.com/video.mp4"');
|
|
49
|
+
expect(purified).toContain('type="video/mp4"');
|
|
50
|
+
expect(purified).toContain('slot="main"');
|
|
51
|
+
expect(purified).toContain("default");
|
|
52
|
+
expect(purified).toContain('kind="captions"');
|
|
53
|
+
expect(purified).toContain('label="English"');
|
|
54
|
+
expect(purified).toContain('src="https://example.com/captions.vtt"');
|
|
55
|
+
expect(purified).toContain('srclang="en"');
|
|
56
|
+
expect(purified).toContain('colspan="2"');
|
|
57
|
+
expect(purified).toContain('datetime="2026-04-27"');
|
|
58
|
+
expect(purified).toContain("download");
|
|
59
|
+
expect(purified).toContain('scope="col"');
|
|
60
|
+
expect(purified).toContain("reversed");
|
|
61
|
+
expect(purified).toContain('start="5"');
|
|
62
|
+
expect(purified).toContain('type="A"');
|
|
63
|
+
expect(purified).toContain('viewBox="0 0 10 10"');
|
|
64
|
+
expect(purified).toContain('d="M0 0h10v10H0z"');
|
|
65
|
+
expect(purified).toContain('fill="red"');
|
|
66
|
+
expect(purified).toContain('stroke="blue"');
|
|
67
|
+
expect(purified).toContain('value="7"');
|
|
68
|
+
|
|
69
|
+
expect(purified).not.toContain('class="');
|
|
70
|
+
expect(purified).not.toContain('data-id="');
|
|
71
|
+
expect(purified).not.toContain('id="');
|
|
72
|
+
expect(purified).not.toContain('onclick="');
|
|
73
|
+
expect(purified).not.toContain('ping="');
|
|
74
|
+
expect(purified).not.toContain('style="');
|
|
75
|
+
});
|
|
76
|
+
|
|
10
77
|
it("sets required security attributes for links", () => {
|
|
11
78
|
const raw = '<a href="https://example.com" target="_self" rel="noopener" referrerpolicy="unsafe-url">Example</a>';
|
|
12
79
|
|
|
@@ -17,6 +84,48 @@ describe("purifyBody", () => {
|
|
|
17
84
|
expect(purified).toContain('rel="noopener noreferrer nofollow"');
|
|
18
85
|
expect(purified).toContain('referrerpolicy="strict-origin-when-cross-origin"');
|
|
19
86
|
});
|
|
87
|
+
|
|
88
|
+
it("keeps sanitized allowlisted iframes", () => {
|
|
89
|
+
const raw = `
|
|
90
|
+
<iframe
|
|
91
|
+
src="https://www.youtube.com/embed/video-id"
|
|
92
|
+
width="560"
|
|
93
|
+
height="315"
|
|
94
|
+
title="Video title"
|
|
95
|
+
class="video"
|
|
96
|
+
allow="camera"
|
|
97
|
+
sandbox="allow-top-navigation">
|
|
98
|
+
</iframe>
|
|
99
|
+
`;
|
|
100
|
+
|
|
101
|
+
const purified = purifyBody({raw, default_: "No description"});
|
|
102
|
+
const parsed = new DOMParser().parseFromString(purified, "text/html");
|
|
103
|
+
const iframe = parsed.body.querySelector("iframe");
|
|
104
|
+
|
|
105
|
+
expect(iframe?.getAttribute("src")).toBe("https://www.youtube-nocookie.com/embed/video-id");
|
|
106
|
+
expect(iframe?.getAttribute("title")).toBe("Video title");
|
|
107
|
+
expect(iframe?.getAttribute("loading")).toBe("lazy");
|
|
108
|
+
expect(iframe?.getAttribute("referrerpolicy")).toBe("strict-origin-when-cross-origin");
|
|
109
|
+
expect(iframe?.getAttribute("sandbox")).toBe(
|
|
110
|
+
"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
|
111
|
+
);
|
|
112
|
+
expect(iframe?.getAttribute("allow")).toBe("fullscreen; picture-in-picture; encrypted-media");
|
|
113
|
+
expect(iframe?.hasAttribute("allowfullscreen")).toBe(true);
|
|
114
|
+
expect(iframe?.hasAttribute("width")).toBe(false);
|
|
115
|
+
expect(iframe?.hasAttribute("height")).toBe(false);
|
|
116
|
+
expect(iframe?.hasAttribute("class")).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("strips srcdoc from sanitized iframes", () => {
|
|
120
|
+
const raw = '<iframe src="https://www.youtube-nocookie.com/embed/video-id" srcdoc="<p>HTML</p>"></iframe>';
|
|
121
|
+
|
|
122
|
+
const purified = purifyBody({raw, default_: "No description"});
|
|
123
|
+
const parsed = new DOMParser().parseFromString(purified, "text/html");
|
|
124
|
+
const iframe = parsed.body.querySelector("iframe");
|
|
125
|
+
|
|
126
|
+
expect(iframe?.getAttribute("src")).toBe("https://www.youtube-nocookie.com/embed/video-id");
|
|
127
|
+
expect(iframe?.hasAttribute("srcdoc")).toBe(false);
|
|
128
|
+
});
|
|
20
129
|
});
|
|
21
130
|
|
|
22
131
|
describe("purifyTitle", () => {
|