single-file-core 1.5.129 → 1.5.130

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/core/index.js CHANGED
@@ -31,6 +31,7 @@ const DEBUG = false;
31
31
  const Set = globalThis.Set;
32
32
  const Map = globalThis.Map;
33
33
  const JSON = globalThis.JSON;
34
+ const URL = globalThis.URL;
34
35
 
35
36
  let util;
36
37
 
@@ -160,7 +161,7 @@ const STAGES = [{
160
161
  { action: "cleanupPage" }
161
162
  ],
162
163
  parallel: [
163
- { option: "enableMaff", action: "insertMAFFMetaData" },
164
+ { option: "readMaffMetadata", action: "readMAFFMetaData" },
164
165
  { action: "setDocInfo" }
165
166
  ]
166
167
  }, {
@@ -421,6 +422,9 @@ const SHADOWROOT_DELEGATES_FOCUS = "shadowrootdelegatesfocus";
421
422
  const SHADOWROOT_CLONABLE = "shadowrootclonable";
422
423
  const SHADOWROOT_SERIALIZABLE = "shadowrootserializable";
423
424
  const SCRIPT_OPTIONS = "data-single-file-options";
425
+ const JAVASCRIPT_URI_PROTOCOL = "javascript:";
426
+ const DISABLED_SCRIPT_URI = "javascript:void(0)";
427
+ const SCRIPT_URI_ATTRIBUTE_NAMES = ["href", "src", "action", "formaction", "data"];
424
428
  const UTF8_CHARSET = "utf-8";
425
429
  const TAINTED_CANVAS_WARNING_MESSAGE = "SingleFile: canvas elements tainted by a cross-origin resource, dropped from the page:";
426
430
 
@@ -448,7 +452,7 @@ class Processor {
448
452
  initialize() {
449
453
  this.options.saveDate = new Date();
450
454
  this.options.saveUrl = this.options.url;
451
- if (this.options.enableMaff) {
455
+ if (this.options.readMaffMetadata) {
452
456
  this.maffMetaDataPromise = this.batchRequest.addURL(util.resolveURL("index.rdf", this.options.baseURI || this.options.url), { expectedType: "document" });
453
457
  }
454
458
  this.maxResources = this.batchRequest.getMaxResources();
@@ -497,7 +501,7 @@ class Processor {
497
501
  }
498
502
  this.workStyleElement = this.doc.createElement("style");
499
503
  this.doc.body.appendChild(this.workStyleElement);
500
- this.onEventAttributeNames = getOnEventAttributeNames(this.doc);
504
+ this.onEventAttributeNames = new Set(getOnEventAttributeNames(this.doc));
501
505
  }
502
506
 
503
507
  finalize() {
@@ -676,7 +680,7 @@ class Processor {
676
680
  optionsElement.type = "application/json";
677
681
  optionsElement.setAttribute(SCRIPT_OPTIONS, "");
678
682
  optionsElement.textContent = JSON.stringify({
679
- saveUrl: this.options.url,
683
+ saveUrl: this.options.saveUrl,
680
684
  saveDate: this.options.saveDate.getTime(),
681
685
  visitDate: (this.options.visitDate || this.options.saveDate).getTime(),
682
686
  filenameTemplate: this.options.filenameTemplate,
@@ -852,18 +856,16 @@ class Processor {
852
856
  }
853
857
 
854
858
  removeEmbedScripts() {
855
- const JAVASCRIPT_URI_PREFIX = "javascript:";
856
- const DISABLED_SCRIPT = "javascript:void(0)";
857
- this.onEventAttributeNames.forEach(attributeName => this.doc.querySelectorAll("[" + attributeName + "]").forEach(element => element.removeAttribute(attributeName)));
858
- this.doc.querySelectorAll("[href]").forEach(element => {
859
- if (element.href && element.href.match && element.href.trim().startsWith(JAVASCRIPT_URI_PREFIX)) {
860
- element.setAttribute("href", DISABLED_SCRIPT);
861
- }
862
- });
863
- this.doc.querySelectorAll("[src]").forEach(element => {
864
- if (element.src && element.src.trim().startsWith(JAVASCRIPT_URI_PREFIX)) {
865
- element.setAttribute("src", DISABLED_SCRIPT);
866
- }
859
+ this.doc.querySelectorAll("*").forEach(element => {
860
+ Array.from(element.attributes).forEach(attribute => {
861
+ const localName = attribute.localName || attribute.name;
862
+ const attributeName = localName.toLowerCase();
863
+ if (this.onEventAttributeNames.has(attributeName)) {
864
+ element.removeAttributeNS(attribute.namespaceURI, localName);
865
+ } else if (SCRIPT_URI_ATTRIBUTE_NAMES.includes(attributeName) && isScriptURI(attribute.value)) {
866
+ element.setAttributeNS(attribute.namespaceURI, attribute.name, DISABLED_SCRIPT_URI);
867
+ }
868
+ });
867
869
  });
868
870
  const scriptElements = this.doc.querySelectorAll("script:not([type=\"application/ld+json\"]):not([" + SCRIPT_OPTIONS + "])");
869
871
  this.stats.set("discarded", "scripts", scriptElements.length);
@@ -1647,7 +1649,7 @@ class Processor {
1647
1649
  this.doc.documentElement.style.removeProperty("-sf-min-height");
1648
1650
  }
1649
1651
 
1650
- async insertMAFFMetaData() {
1652
+ async readMAFFMetaData() {
1651
1653
  const maffMetaData = await this.maffMetaDataPromise;
1652
1654
  if (maffMetaData && maffMetaData.content) {
1653
1655
  const NAMESPACE_RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
@@ -1655,7 +1657,10 @@ class Processor {
1655
1657
  const originalURLElement = maffDoc.querySelector("RDF > Description > originalurl");
1656
1658
  const archiveTimeElement = maffDoc.querySelector("RDF > Description > archivetime");
1657
1659
  if (originalURLElement) {
1658
- this.options.saveUrl = originalURLElement.getAttributeNS(NAMESPACE_RDF, "resource");
1660
+ const value = originalURLElement.getAttributeNS(NAMESPACE_RDF, "resource");
1661
+ if (value) {
1662
+ this.options.saveUrl = value;
1663
+ }
1659
1664
  }
1660
1665
  if (archiveTimeElement) {
1661
1666
  const value = archiveTimeElement.getAttributeNS(NAMESPACE_RDF, "resource");
@@ -1709,6 +1714,14 @@ function normalizeURL(url) {
1709
1714
  }
1710
1715
  }
1711
1716
 
1717
+ function isScriptURI(value) {
1718
+ try {
1719
+ return new URL(value).protocol == JAVASCRIPT_URI_PROTOCOL;
1720
+ } catch {
1721
+ return false;
1722
+ }
1723
+ }
1724
+
1712
1725
  function getOnEventAttributeNames(doc) {
1713
1726
  const element = doc.body || doc.createElement("div");
1714
1727
  const attributeNames = [];
package/core/infobar.js CHANGED
@@ -141,28 +141,28 @@ const INFOBAR_STYLES = `
141
141
 
142
142
  @keyframes flash {
143
143
  0%, 100% {
144
- background-color: #737373;
144
+ background-color: #737373;
145
145
  }
146
146
  50% {
147
- background-color: #dd6a00;
147
+ background-color: #dd6a00;
148
148
  }
149
149
  }
150
150
 
151
151
  @keyframes ripple {
152
152
  0% {
153
- transform: scale(1);
154
- opacity: 1;
153
+ transform: scale(1);
154
+ opacity: 1;
155
155
  }
156
156
  45%, 100% {
157
- transform: scale(2);
158
- opacity: 0;
157
+ transform: scale(2);
158
+ opacity: 0;
159
159
  }
160
160
  }
161
161
 
162
162
  @media (prefers-reduced-motion: reduce) {
163
163
  .infobar,
164
164
  .infobar:not(:focus-within):not(.infobar-focus)::after {
165
- animation-name: none;
165
+ animation-name: none;
166
166
  }
167
167
  }
168
168
 
@@ -562,7 +562,7 @@ function getProcessorHelperClass(utilInstance) {
562
562
  }
563
563
 
564
564
  setMetaCSP(metaElement) {
565
- metaElement.content = "default-src 'none'; font-src 'self' data:; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self' data:; script-src 'unsafe-inline' data:; object-src 'self' data:; frame-src 'self' data:;";
565
+ metaElement.content = "default-src 'none'; font-src 'self' data:; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self' data:; script-src 'unsafe-inline' data:; object-src 'self' data:; frame-src 'self' data:; form-action 'none'; base-uri 'none';";
566
566
  }
567
567
 
568
568
  removeUnusedStylesheets(doc) {
@@ -488,7 +488,7 @@ function getProcessorHelperClass(utilInstance) {
488
488
  }
489
489
 
490
490
  setMetaCSP(metaElement) {
491
- metaElement.content = "default-src 'none'; connect-src 'self' data: blob:; font-src 'self' data: blob:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline' data: blob:; frame-src 'self' data: blob:; media-src 'self' data: blob:; script-src 'self' 'unsafe-inline' data: blob:; object-src 'self' data: blob:;";
491
+ metaElement.content = "default-src 'none'; connect-src 'self' data: blob:; font-src 'self' data: blob:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline' data: blob:; frame-src 'self' data: blob:; media-src 'self' data: blob:; script-src 'self' 'unsafe-inline' data: blob:; object-src 'self' data: blob:; form-action 'none'; base-uri 'none';";
492
492
  }
493
493
 
494
494
  removeUnusedStylesheets() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "single-file-core",
3
- "version": "1.5.129",
3
+ "version": "1.5.130",
4
4
  "description": "SingleFile Core",
5
5
  "author": "Gildas Lormeau",
6
6
  "license": "AGPL-3.0-or-later",
@@ -132,7 +132,8 @@ export {
132
132
  process,
133
133
  createArchive,
134
134
  escapeHTML,
135
- PROCESS_OPTION_NAMES
135
+ PROCESS_OPTION_NAMES,
136
+ DEFAULT_MAX_APPENDED_DATA_LENGTH
136
137
  };
137
138
 
138
139
  async function process(pageData, options, lastModDate = new Date()) {
package/single-file.js CHANGED
@@ -91,7 +91,7 @@ async function getPageData(options = {}, initOptions, doc, win) {
91
91
  }
92
92
  options.doc = doc;
93
93
  options.win = win;
94
- options.insertCanonicalLink = true;
94
+ options.insertCanonicalLink = options.insertCanonicalLink === undefined ? true : options.insertCanonicalLink;
95
95
 
96
96
  const externalOnProgress = options.onprogress;
97
97
  options.onprogress = async event => {
@@ -0,0 +1,50 @@
1
+ import { capture, html } from "./common.js";
2
+
3
+ const PAGE_URL = "https://example.com/page.html";
4
+ const FILE_URL = "file:///tmp/page.html";
5
+ const PAGE = html("<h1>page</h1>");
6
+
7
+ const resources = {
8
+ [PAGE_URL]: { body: PAGE },
9
+ [FILE_URL]: { body: PAGE }
10
+ };
11
+
12
+ let failed = false;
13
+
14
+ // insertCanonicalLink was forced to true in single-file.js after the options were merged, so it read
15
+ // as an option in three places and could be set from none: the CLI flag was written, measured doing
16
+ // nothing, and removed again rather than shipped. It now defaults to true instead of being forced,
17
+ // which is what makes the flag and the extension config key mean anything.
18
+ {
19
+ const content = await capture(resources, { url: PAGE_URL, content: PAGE });
20
+ check("a canonical link is inserted by default", content.includes("rel=\"canonical\""), true);
21
+ }
22
+
23
+ {
24
+ const content = await capture(resources, { url: PAGE_URL, content: PAGE, insertCanonicalLink: false });
25
+ check("insertCanonicalLink false suppresses it", content.includes("rel=\"canonical\""), false);
26
+ }
27
+
28
+ {
29
+ const content = await capture(resources, { url: PAGE_URL, content: PAGE, insertCanonicalLink: true });
30
+ check("insertCanonicalLink true keeps it", content.includes("rel=\"canonical\""), true);
31
+ }
32
+
33
+ // The href guard is the reason the option is safe to default on: a page saved from disk has no
34
+ // canonical URL to point at, and the element is skipped rather than written with a file: href.
35
+ {
36
+ const content = await capture(resources, { url: FILE_URL, content: PAGE });
37
+ check("a page saved from file: gets no canonical link", content.includes("rel=\"canonical\""), false);
38
+ }
39
+
40
+ if (failed) {
41
+ console.log("FAILED");
42
+ Deno.exit(1);
43
+ }
44
+ console.log("OK");
45
+
46
+ function check(label, actual, expected) {
47
+ const ok = actual === expected;
48
+ console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
49
+ failed ||= !ok;
50
+ }
@@ -3,7 +3,7 @@
3
3
  // reads globalThis.window, then calls init() and new MutationObserver(init) at module scope. That
4
4
  // hook belongs to the page world and does nothing useful here; it only has to load without throwing.
5
5
  // Import this module first and import single-file.js dynamically, the way common.js does.
6
- import { DOMParser, Document } from "jsr:@b-fuze/deno-dom@0.1.56";
6
+ import { DOMParser, Document, Element } from "jsr:@b-fuze/deno-dom@0.1.56";
7
7
 
8
8
  globalThis.DOMParser = DOMParser;
9
9
  globalThis.Document = Document;
@@ -12,3 +12,15 @@ globalThis.MutationObserver = class {
12
12
  observe() { }
13
13
  disconnect() { }
14
14
  };
15
+
16
+ // deno-dom implements neither of these, so removeEmbedScripts throws here and nowhere else. Mapping
17
+ // them onto the qualified-name methods is faithful for what deno-dom can represent, which is only
18
+ // null-namespace attributes: it drops the prefix of xlink:href and lowercases nothing, so the
19
+ // namespaced and mixed-case cases cannot be written as a fixture at all. Those are covered by the
20
+ // browser suite in single-file-cli, which drives a real DOM.
21
+ Element.prototype.setAttributeNS = function (namespaceURI, qualifiedName, value) {
22
+ this.setAttribute(qualifiedName, value);
23
+ };
24
+ Element.prototype.removeAttributeNS = function (namespaceURI, localName) {
25
+ this.removeAttribute(localName);
26
+ };
@@ -0,0 +1,205 @@
1
+ import "./dom.js";
2
+
3
+ // core/util.js captures globalThis.DOMParser when it loads, and deno-dom throws on "text/xml", so
4
+ // the MAFF metadata could not be parsed here at all. This substitutes a stub for that one mime type,
5
+ // installed before common.js imports core, the same ordering constraint dom.js itself documents.
6
+ //
7
+ // What the stub stands for and what it does not. The two defects fixed alongside this suite are both
8
+ // about what core does with what the parser HANDS BACK — an attribute that came back null, and which
9
+ // of two almost-identical option fields gets written out — so a stub returning null or a string
10
+ // covers them faithfully. It does NOT cover the parse: that `RDF > Description > originalurl` matches
11
+ // `<RDF:RDF><RDF:Description><MAF:originalurl>` by local name, and that getAttributeNS resolves the
12
+ // RDF prefix, are properties of a real XML DOM that only a browser suite can confirm.
13
+ const XML_DOCUMENTS = new Map();
14
+ const NativeDOMParser = globalThis.DOMParser;
15
+
16
+ class StubXMLDocument {
17
+ constructor(values) {
18
+ this.values = values;
19
+ }
20
+ // undefined means the element is absent, null means it is present with no RDF:resource attribute
21
+ querySelector(selector) {
22
+ const localName = selector.split(">").pop().trim();
23
+ const value = this.values[localName];
24
+ return value === undefined ? null : { getAttributeNS: () => value };
25
+ }
26
+ }
27
+
28
+ globalThis.DOMParser = class {
29
+ parseFromString(content, mimeType) {
30
+ if (mimeType == "text/xml") {
31
+ return new StubXMLDocument(XML_DOCUMENTS.get(content) || {});
32
+ }
33
+ return new NativeDOMParser().parseFromString(content, mimeType);
34
+ }
35
+ };
36
+
37
+ const { capture, frameData, html, WIN_ID_ATTRIBUTE_NAME } = await import("./common.js");
38
+
39
+ const PAGE_URL = "https://example.com/page.html";
40
+ const RDF_URL = "https://example.com/index.rdf";
41
+ const FRAME_URL = "https://example.com/frame-dir/frame.html";
42
+ const FRAME_RDF_URL = "https://example.com/frame-dir/index.rdf";
43
+ const ORIGINAL_URL = "https://original.example/real.html";
44
+ const ARCHIVE_TIME = "Mon, 01 Jan 2024 10:20:30 GMT";
45
+ const ARCHIVE_TIME_MS = new Date(ARCHIVE_TIME).getTime();
46
+ const COMPLETE = { originalurl: ORIGINAL_URL, archivetime: ARCHIVE_TIME };
47
+ const PAGE = html("<h1>page</h1>");
48
+ const FRAME_PAGE = html("<h1>frame</h1>");
49
+ const HOST_PAGE = html("<h1>host</h1><iframe src=\"" + FRAME_URL + "\" " + WIN_ID_ATTRIBUTE_NAME + "=\"0.1\"></iframe>");
50
+
51
+ // The canonical link would be the obvious place to read the recovered url, but deno-dom reflects
52
+ // neither the href nor the type property, so core's `element.href = ...` leaves no attribute behind.
53
+ // Two observables survive that: the SingleFile comment, which core builds from options.saveUrl, and
54
+ // the embedded options block, which is where the second defect lives.
55
+ const OPTIONS_BLOCK = /<script data-single-file-options[^>]*>([^<]*)<\/script>/;
56
+
57
+ let fixtureIndex = 0;
58
+
59
+ function rdf(values) {
60
+ const content = "<?xml version=\"1.0\"?><!-- fixture " + (fixtureIndex++) + " -->";
61
+ XML_DOCUMENTS.set(content, values);
62
+ return content;
63
+ }
64
+
65
+ class CountingResources extends Map {
66
+ constructor(entries) {
67
+ super(entries);
68
+ this.counts = new Map();
69
+ }
70
+ get(key) {
71
+ this.counts.set(key, (this.counts.get(key) || 0) + 1);
72
+ return super.get(key);
73
+ }
74
+ countOf(key) {
75
+ return this.counts.get(key) || 0;
76
+ }
77
+ }
78
+
79
+ function resources(rdfContent) {
80
+ const entries = [
81
+ [PAGE_URL, { body: PAGE }],
82
+ [FRAME_URL, { body: FRAME_PAGE }]
83
+ ];
84
+ if (rdfContent !== undefined) {
85
+ entries.push([RDF_URL, { body: rdfContent, contentType: "text/xml" }]);
86
+ }
87
+ return new CountingResources(entries);
88
+ }
89
+
90
+ function commentURL(content) {
91
+ const match = content.match(/ url: ([^\n]*)/);
92
+ return match && match[1].trim();
93
+ }
94
+
95
+ function embeddedOptions(content) {
96
+ const match = content.match(OPTIONS_BLOCK);
97
+ return match && JSON.parse(match[1]);
98
+ }
99
+
100
+ let failed = false;
101
+
102
+ // readMaffMetadata is the new name of enableMaff, which was implemented in core and set by nothing:
103
+ // no CLI flag, no config key, no UI anywhere. That is what made renaming it free, and exposing it is
104
+ // what made the two defects below reachable by a user.
105
+ {
106
+ const map = resources(rdf(COMPLETE));
107
+ const content = await capture(map, { url: PAGE_URL, content: PAGE, insertSingleFileComment: true });
108
+ check("index.rdf is not requested when the option is off", map.countOf(RDF_URL), 0);
109
+ check("the page url is saved when the option is off", commentURL(content), PAGE_URL);
110
+ }
111
+
112
+ {
113
+ const map = resources(rdf(COMPLETE));
114
+ const content = await capture(map, { url: PAGE_URL, content: PAGE, readMaffMetadata: true, insertSingleFileComment: true });
115
+ check("index.rdf is requested when the option is on", map.countOf(RDF_URL), 1);
116
+ check("the original url is recovered", commentURL(content), ORIGINAL_URL);
117
+ }
118
+
119
+ // THE CRASH. An originalurl with no RDF:resource made getAttributeNS return null, saveUrl became
120
+ // null, and the canonical link then called .match() on it and failed the whole capture with a
121
+ // TypeError. The archivetime branch beside it had always guarded its own value, so the two halves of
122
+ // one method disagreed with each other.
123
+ {
124
+ let threw = false;
125
+ let content = "";
126
+ try {
127
+ content = await capture(resources(rdf({ originalurl: null, archivetime: ARCHIVE_TIME })), {
128
+ url: PAGE_URL,
129
+ content: PAGE,
130
+ readMaffMetadata: true,
131
+ insertSingleFileComment: true
132
+ });
133
+ } catch {
134
+ threw = true;
135
+ }
136
+ check("an originalurl with no resource attribute does not fail the capture", threw, false);
137
+ check("and the page url is kept", commentURL(content), PAGE_URL);
138
+ }
139
+
140
+ {
141
+ const content = await capture(resources(rdf({ originalurl: ORIGINAL_URL, archivetime: null })), {
142
+ url: PAGE_URL,
143
+ content: PAGE,
144
+ readMaffMetadata: true,
145
+ insertSingleFileComment: true
146
+ });
147
+ check("an archivetime with no resource attribute is tolerated", commentURL(content), ORIGINAL_URL);
148
+ }
149
+
150
+ {
151
+ const content = await capture(resources(), { url: PAGE_URL, content: PAGE, readMaffMetadata: true, insertSingleFileComment: true });
152
+ check("a missing index.rdf leaves the page url in place", commentURL(content), PAGE_URL);
153
+ }
154
+
155
+ // THE INCONSISTENCY. saveFilenameTemplateData wrote saveUrl from options.url, one line above writing
156
+ // saveDate from the value MAFF had just recovered, so the embedded block paired the archive's date
157
+ // with the extracted copy's path, and a recompute in the editor resolved {url-*} against the wrong
158
+ // one. options.saveUrl and options.url are identical on every other code path, which is what let it
159
+ // sit unnoticed.
160
+ {
161
+ const content = await capture(resources(rdf(COMPLETE)), {
162
+ url: PAGE_URL,
163
+ content: PAGE,
164
+ readMaffMetadata: true,
165
+ saveFilenameTemplateData: true
166
+ });
167
+ const embedded = embeddedOptions(content);
168
+ check("the embedded options block exists", Boolean(embedded), true);
169
+ if (embedded) {
170
+ check("the embedded saveUrl is the recovered one", embedded.saveUrl, ORIGINAL_URL);
171
+ check("the embedded saveDate is the recovered one", embedded.saveDate, ARCHIVE_TIME_MS);
172
+ }
173
+ }
174
+
175
+ {
176
+ const content = await capture(resources(rdf(COMPLETE)), { url: PAGE_URL, content: PAGE, saveFilenameTemplateData: true });
177
+ const embedded = embeddedOptions(content);
178
+ check("the embedded saveUrl is the page url when the option is off", embedded && embedded.saveUrl, PAGE_URL);
179
+ }
180
+
181
+ // Only the root document reaches Processor.initialize, because Runner.run guards that call with
182
+ // `if (this.root)`. That guard is the whole reason a page with twenty frames does not make twenty
183
+ // pointless index.rdf requests, and nothing else pins it. Note initializeProcessor resets a list of
184
+ // root-only options for frames and readMaffMetadata is deliberately NOT in it: next to this guard
185
+ // such a reset is dead code, which is exactly what adding one and watching nothing change proved.
186
+ {
187
+ const map = resources(rdf(COMPLETE));
188
+ const frames = [frameData("0.1", FRAME_URL, FRAME_PAGE)];
189
+ const content = await capture(map, { url: PAGE_URL, content: HOST_PAGE, frames, readMaffMetadata: true });
190
+ check("the root asks for its index.rdf", map.countOf(RDF_URL), 1);
191
+ check("a frame does not ask for one of its own", map.countOf(FRAME_RDF_URL), 0);
192
+ check("the frame is still captured", content.includes("frame"), true);
193
+ }
194
+
195
+ if (failed) {
196
+ console.log("FAILED");
197
+ Deno.exit(1);
198
+ }
199
+ console.log("OK");
200
+
201
+ function check(label, actual, expected) {
202
+ const ok = actual === expected;
203
+ console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
204
+ failed ||= !ok;
205
+ }
@@ -0,0 +1,82 @@
1
+ import { capture, html } from "./common.js";
2
+
3
+ const PAGE_URL = "https://example.com/page.html";
4
+
5
+ // blockScripts is what removeEmbedScripts is wired to, and it is the option every save turns on to
6
+ // promise that the saved page holds no script. These fixtures are the ways a javascript: URL used to
7
+ // survive that promise.
8
+ const BLOCK = { blockScripts: true };
9
+
10
+ // The sanitizer used to read the resolved IDL property, element.href and element.src, and rewrite the
11
+ // attribute only when the property was a string starting with "javascript:". That missed every SVG
12
+ // link, because SVGAElement.href is an SVGAnimatedString and the guard skipped it, and it never
13
+ // looked at form submission targets at all. Each of these executed on one click in a saved page.
14
+ const VECTORS = [
15
+ ["svg link", "<svg xmlns=\"http://www.w3.org/2000/svg\"><a id=\"target\" href=\"javascript:alert(1)\"><rect/></a></svg>"],
16
+ ["form action", "<form action=\"javascript:alert(1)\"><input type=\"submit\"></form>"],
17
+ ["button formaction", "<form><button formaction=\"javascript:alert(1)\">go</button></form>"],
18
+ ["image input formaction", "<form><input type=\"image\" formaction=\"javascript:alert(1)\"></form>"],
19
+ ["object data", "<object data=\"javascript:alert(1)\"></object>"],
20
+ ["anchor href", "<a href=\"javascript:alert(1)\">go</a>"],
21
+ ["iframe src", "<iframe src=\"javascript:alert(1)\"></iframe>"]
22
+ ];
23
+
24
+ let failed = false;
25
+
26
+ for (const [label, body] of VECTORS) {
27
+ const content = await capture({}, { url: PAGE_URL, content: html(body), ...BLOCK });
28
+ check(`${label} is neutralized`, content.includes("javascript:alert"), false);
29
+ }
30
+
31
+ // The URL parser decides what a javascript: URL is, so the obfuscations it folds away have to be
32
+ // folded away here too: leading whitespace is stripped, a tab inside the scheme is removed, and the
33
+ // scheme is compared case-insensitively.
34
+ const OBFUSCATIONS = [
35
+ ["leading whitespace and mixed case", " \tJaVaScRiPt:alert(1)"],
36
+ ["tab inside the scheme", "ja&#9;vascript:alert(1)"],
37
+ ["newline before the scheme", "&#10;javascript:alert(1)"],
38
+ ["uppercase scheme", "JAVASCRIPT:alert(1)"]
39
+ ];
40
+
41
+ for (const [label, value] of OBFUSCATIONS) {
42
+ const content = await capture({}, { url: PAGE_URL, content: html(`<a href="${value}">go</a>`), ...BLOCK });
43
+ check(`${label} is neutralized`, content.includes("alert(1)"), false);
44
+ }
45
+
46
+ // The control. A space inside the scheme is not a javascript: URL, and neither is a relative path, so
47
+ // a sanitizer that rewrote either would be matching on text rather than on what the URL parser says.
48
+ // Neither href survives as written, because resolveHrefs makes both absolute afterwards; what the
49
+ // control asserts is that the sanitizer did not claim them.
50
+ {
51
+ const content = await capture({}, { url: PAGE_URL, content: html("<a href=\"java script:alert(1)\">go</a><a href=\"page.html\">go</a>"), ...BLOCK });
52
+ check("a space inside the scheme is not treated as a script URI", content.includes("javascript:void(0)"), false);
53
+ check("a relative href is not treated as a script URI", content.includes("page.html"), true);
54
+ }
55
+
56
+ // The other half of removeEmbedScripts, kept here so that a rewrite of the attribute walk cannot drop
57
+ // it silently. Only the script elements can be checked from here: the event handler attribute names
58
+ // come from enumerating the on* IDL properties of an element, and deno-dom implements none of them,
59
+ // so the set is empty in this harness and a handler fixture would pass whatever the code did. The
60
+ // browser suite in single-file-cli covers the handlers.
61
+ {
62
+ const scripts = await capture({}, { url: PAGE_URL, content: html("<script>alert(1)</script><svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(2)</script></svg>"), ...BLOCK });
63
+ check("html and svg script elements are removed", scripts.includes("alert("), false);
64
+ }
65
+
66
+ // The option still has to be an option: with blockScripts off none of this is rewritten.
67
+ {
68
+ const content = await capture({}, { url: PAGE_URL, content: html("<a href=\"javascript:alert(1)\">go</a>") });
69
+ check("nothing is rewritten with blockScripts off", content.includes("javascript:alert(1)"), true);
70
+ }
71
+
72
+ if (failed) {
73
+ console.log("FAILED");
74
+ Deno.exit(1);
75
+ }
76
+ console.log("OK");
77
+
78
+ function check(label, actual, expected) {
79
+ const ok = actual === expected;
80
+ console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
81
+ failed ||= !ok;
82
+ }
@@ -249,8 +249,8 @@ async function makePage(seed, { url, title, originalUrls }) {
249
249
 
250
250
  // a page archive holding, on purpose, nothing the packager's own writer would produce by default:
251
251
  // a directory record, a name that needs the language encoding flag, a stored entry beside one
252
- // deflated at the highest level, unix ownership, an extra field zip.js does not interpret, and
253
- // dates outside the one the packager pins on its writer
252
+ // deflated at the highest level, unix ownership, a non-default version-made-by spec byte, an
253
+ // extra field zip.js does not interpret, and dates outside the one the packager pins on its writer
254
254
  async function makeMetadataPage() {
255
255
  const zipWriter = new ZipWriter(new Uint8ArrayWriter(), { lastModDate: SOURCE_DATE });
256
256
  await zipWriter.add("folder/", null, { directory: true, comment: "a folder" });
@@ -261,6 +261,9 @@ async function makeMetadataPage() {
261
261
  lastAccessDate: SOURCE_DATE,
262
262
  internalFileAttributes: 1,
263
263
  msDosCompatible: false,
264
+ // only the high byte is rewritten to host Unix, so the 0x32 spec byte survives and the
265
+ // copied value differs from the 0x0300 both writers land on by default
266
+ versionMadeBy: 0x0332,
264
267
  unixMode: 0o100755,
265
268
  uid: 501,
266
269
  gid: 20,