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 +31 -18
- package/core/infobar.js +7 -7
- package/core/lib/processor-helper-inline.js +1 -1
- package/core/lib/processor-helper.js +1 -1
- package/package.json +1 -1
- package/processors/compression/compression.js +2 -1
- package/single-file.js +1 -1
- package/test/capture/canonical-link.js +50 -0
- package/test/capture/dom.js +13 -1
- package/test/capture/maff-metadata.js +205 -0
- package/test/capture/script-uri-sanitization.js +82 -0
- package/test/sfz-harness/pages-archive.js +5 -2
- package/vendor/zip/z-worker.js +1 -1
- package/vendor/zip/zip.js +706 -950
- package/vendor/zip/zip.min.js +1 -1
- package/zip-build/package-lock.json +4 -4
- package/zip-build/package.json +1 -1
- package/zip-build/reserved-property-names.json +4 -0
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: "
|
|
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.
|
|
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.
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
144
|
+
background-color: #737373;
|
|
145
145
|
}
|
|
146
146
|
50% {
|
|
147
|
-
|
|
147
|
+
background-color: #dd6a00;
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
@keyframes ripple {
|
|
152
152
|
0% {
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
transform: scale(1);
|
|
154
|
+
opacity: 1;
|
|
155
155
|
}
|
|
156
156
|
45%, 100% {
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
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
|
+
}
|
package/test/capture/dom.js
CHANGED
|
@@ -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	vascript:alert(1)"],
|
|
37
|
+
["newline before the scheme", " 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,
|
|
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,
|