@dustfeather/deckrun 2.0.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/LICENSE +21 -0
- package/README.md +1073 -0
- package/THIRD-PARTY-NOTICES.md +38 -0
- package/dist/editor-content.js +485 -0
- package/dist/editor.js +3916 -0
- package/dist/fragments.js +71 -0
- package/dist/generate.js +3488 -0
- package/dist/highlights.js +833 -0
- package/dist/index.js +1020 -0
- package/dist/lint.js +330 -0
- package/dist/parser.js +221 -0
- package/dist/pdf.js +200 -0
- package/dist/presentation-options.js +289 -0
- package/dist/preview.js +400 -0
- package/dist/rich-content.js +195 -0
- package/dist/safe-fetch.js +173 -0
- package/dist/sanitize.js +102 -0
- package/dist/themes.js +1041 -0
- package/dist/titles.js +30 -0
- package/package.json +64 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from "dns";
|
|
2
|
+
import { isIP } from "net";
|
|
3
|
+
import { request as httpRequest } from "http";
|
|
4
|
+
import { request as httpsRequest } from "https";
|
|
5
|
+
/**
|
|
6
|
+
* An address the server refuses to open a connection to.
|
|
7
|
+
*
|
|
8
|
+
* `/__fetch-doc` fetches a URL the caller supplies and hands the body back, so
|
|
9
|
+
* without this it is an open proxy into everything the machine can reach that
|
|
10
|
+
* the caller cannot: other loopback services, the cloud instance metadata
|
|
11
|
+
* endpoint on 169.254.169.254, and anything on the local network.
|
|
12
|
+
*/
|
|
13
|
+
function blockedReason(address, family) {
|
|
14
|
+
if (family === 6) {
|
|
15
|
+
const ip = address.toLowerCase().split("%")[0];
|
|
16
|
+
// An IPv4-mapped address is an IPv4 address wearing a different hat.
|
|
17
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(ip);
|
|
18
|
+
if (mapped)
|
|
19
|
+
return blockedReason(mapped[1], 4);
|
|
20
|
+
if (ip === "::1")
|
|
21
|
+
return "loopback";
|
|
22
|
+
if (ip === "::")
|
|
23
|
+
return "unspecified";
|
|
24
|
+
const head = parseInt(ip.split(":")[0] || "0", 16);
|
|
25
|
+
if ((head & 0xfe00) === 0xfc00)
|
|
26
|
+
return "unique-local";
|
|
27
|
+
if ((head & 0xffc0) === 0xfe80)
|
|
28
|
+
return "link-local";
|
|
29
|
+
if ((head & 0xff00) === 0xff00)
|
|
30
|
+
return "multicast";
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const octets = address.split(".").map(Number);
|
|
34
|
+
if (octets.length !== 4 || octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
|
|
35
|
+
return "unparseable";
|
|
36
|
+
}
|
|
37
|
+
const [a, b] = octets;
|
|
38
|
+
if (a === 0)
|
|
39
|
+
return "unspecified";
|
|
40
|
+
if (a === 10)
|
|
41
|
+
return "private";
|
|
42
|
+
if (a === 127)
|
|
43
|
+
return "loopback";
|
|
44
|
+
if (a === 169 && b === 254)
|
|
45
|
+
return "link-local";
|
|
46
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
47
|
+
return "private";
|
|
48
|
+
if (a === 192 && b === 168)
|
|
49
|
+
return "private";
|
|
50
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
51
|
+
return "carrier-grade NAT";
|
|
52
|
+
if (a === 198 && (b === 18 || b === 19))
|
|
53
|
+
return "benchmarking";
|
|
54
|
+
if (a >= 224)
|
|
55
|
+
return "multicast or reserved";
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
export class BlockedAddressError extends Error {
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A dns.lookup replacement that fails the connection when the name resolves
|
|
62
|
+
* somewhere private.
|
|
63
|
+
*
|
|
64
|
+
* The check has to sit here rather than in front of the request: resolving the
|
|
65
|
+
* name separately and then calling fetch() leaves a window in which the record
|
|
66
|
+
* can change between the two, and the connection is then made to an address
|
|
67
|
+
* that was never checked. This runs at connect time, on the addresses the
|
|
68
|
+
* socket is actually about to use.
|
|
69
|
+
*/
|
|
70
|
+
function guardedLookup(hostname, options, callback) {
|
|
71
|
+
dnsLookup(hostname, { ...options, all: true }, (err, addresses) => {
|
|
72
|
+
if (err)
|
|
73
|
+
return callback(err, undefined);
|
|
74
|
+
if (!addresses || addresses.length === 0) {
|
|
75
|
+
return callback(new BlockedAddressError(`'${hostname}' did not resolve`), undefined);
|
|
76
|
+
}
|
|
77
|
+
for (const entry of addresses) {
|
|
78
|
+
const reason = blockedReason(entry.address, entry.family);
|
|
79
|
+
if (reason) {
|
|
80
|
+
return callback(new BlockedAddressError(`'${hostname}' resolves to a ${reason} address (${entry.address})`), undefined);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const wantsAll = options?.all === true;
|
|
84
|
+
if (wantsAll)
|
|
85
|
+
return callback(null, addresses);
|
|
86
|
+
return callback(null, addresses[0].address, addresses[0].family);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Fetches a public http(s) document.
|
|
91
|
+
*
|
|
92
|
+
* Redirects are followed by hand rather than by the client, so every hop is
|
|
93
|
+
* checked instead of only the URL the caller passed — a public URL that 302s
|
|
94
|
+
* to 169.254.169.254 is the ordinary way past a check that only looks at the
|
|
95
|
+
* first request.
|
|
96
|
+
*/
|
|
97
|
+
export async function safeFetch(raw, options) {
|
|
98
|
+
const maxRedirects = options.maxRedirects ?? 5;
|
|
99
|
+
let target = new URL(raw);
|
|
100
|
+
for (let hop = 0;; hop++) {
|
|
101
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
102
|
+
throw new BlockedAddressError("url must be http or https");
|
|
103
|
+
}
|
|
104
|
+
const response = await requestOnce(target, options);
|
|
105
|
+
const location = response.headers.location;
|
|
106
|
+
const isRedirect = typeof response.statusCode === "number" &&
|
|
107
|
+
response.statusCode >= 300 &&
|
|
108
|
+
response.statusCode < 400 &&
|
|
109
|
+
typeof location === "string";
|
|
110
|
+
if (!isRedirect) {
|
|
111
|
+
const { body, truncated } = await readCapped(response, options.maxBytes);
|
|
112
|
+
return {
|
|
113
|
+
status: response.statusCode ?? 0,
|
|
114
|
+
headers: response.headers,
|
|
115
|
+
body,
|
|
116
|
+
url: target,
|
|
117
|
+
truncated,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
response.resume();
|
|
121
|
+
if (hop >= maxRedirects)
|
|
122
|
+
throw new BlockedAddressError("too many redirects");
|
|
123
|
+
target = new URL(location, target);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function requestOnce(target, options) {
|
|
127
|
+
// A hostname that is already an IP literal never reaches the lookup hook —
|
|
128
|
+
// Node connects straight to it — so it is checked here instead.
|
|
129
|
+
const literal = target.hostname.replace(/^\[|\]$/g, "");
|
|
130
|
+
const family = isIP(literal);
|
|
131
|
+
if (family) {
|
|
132
|
+
const reason = blockedReason(literal, family);
|
|
133
|
+
if (reason) {
|
|
134
|
+
throw new BlockedAddressError(`${literal} is a ${reason} address`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const secure = target.protocol === "https:";
|
|
138
|
+
const send = secure ? httpsRequest : httpRequest;
|
|
139
|
+
const requestOptions = {
|
|
140
|
+
protocol: target.protocol,
|
|
141
|
+
hostname: target.hostname,
|
|
142
|
+
port: target.port || (secure ? 443 : 80),
|
|
143
|
+
path: `${target.pathname}${target.search}`,
|
|
144
|
+
method: "GET",
|
|
145
|
+
headers: { "User-Agent": options.userAgent ?? "deckrun", Accept: "*/*" },
|
|
146
|
+
lookup: guardedLookup,
|
|
147
|
+
};
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
const req = send(requestOptions, resolve);
|
|
150
|
+
req.setTimeout(options.timeoutMs ?? 15_000, () => {
|
|
151
|
+
req.destroy(new Error("timed out"));
|
|
152
|
+
});
|
|
153
|
+
req.once("error", reject);
|
|
154
|
+
req.end();
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function readCapped(response, maxBytes) {
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
const chunks = [];
|
|
160
|
+
let size = 0;
|
|
161
|
+
response.on("data", (chunk) => {
|
|
162
|
+
size += chunk.length;
|
|
163
|
+
if (size > maxBytes) {
|
|
164
|
+
response.destroy();
|
|
165
|
+
resolve({ body: Buffer.concat(chunks).toString("utf-8"), truncated: true });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
chunks.push(chunk);
|
|
169
|
+
});
|
|
170
|
+
response.once("end", () => resolve({ body: Buffer.concat(chunks).toString("utf-8"), truncated: false }));
|
|
171
|
+
response.once("error", reject);
|
|
172
|
+
});
|
|
173
|
+
}
|
package/dist/sanitize.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import sanitizeHtml from "sanitize-html";
|
|
2
|
+
/**
|
|
3
|
+
* Strips executable markup out of rendered slide HTML.
|
|
4
|
+
*
|
|
5
|
+
* marked dropped its `sanitize` option in v8 and nothing replaced it, so raw
|
|
6
|
+
* HTML in a deck reached the rendered slide verbatim: a `<script>` tag, an
|
|
7
|
+
* `onerror=` handler and a `javascript:` href all survived. Opening a `.md`
|
|
8
|
+
* file someone else wrote ran their JavaScript — in the editor preview, in the
|
|
9
|
+
* presented deck, and in the standalone HTML export handed to an audience.
|
|
10
|
+
*
|
|
11
|
+
* Benign HTML is kept rather than escaped, because decks legitimately use it
|
|
12
|
+
* for layout. What goes is the part that executes: script and event handlers,
|
|
13
|
+
* plugin and frame embeds, and any URL scheme that is not a plain link or an
|
|
14
|
+
* inline image.
|
|
15
|
+
*/
|
|
16
|
+
const ALLOWED_TAGS = [
|
|
17
|
+
// Text and structure marked emits, plus the layout tags decks tend to use.
|
|
18
|
+
"h1", "h2", "h3", "h4", "h5", "h6",
|
|
19
|
+
"p", "div", "span", "section", "article", "header", "footer", "aside", "main",
|
|
20
|
+
"blockquote", "pre", "code", "kbd", "samp", "var",
|
|
21
|
+
"ul", "ol", "li", "dl", "dt", "dd",
|
|
22
|
+
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col",
|
|
23
|
+
"a", "img", "figure", "figcaption", "picture", "source",
|
|
24
|
+
"b", "i", "em", "strong", "small", "s", "strike", "del", "ins", "mark", "sub", "sup", "u",
|
|
25
|
+
"br", "hr", "wbr", "abbr", "cite", "q", "time", "details", "summary",
|
|
26
|
+
// Inline SVG, so diagrams and icons pasted into a deck still render.
|
|
27
|
+
"svg", "g", "path", "circle", "ellipse", "rect", "line", "polyline", "polygon",
|
|
28
|
+
"text", "tspan", "defs", "marker", "linearGradient", "radialGradient", "stop",
|
|
29
|
+
"clipPath", "mask", "pattern", "symbol", "use", "title", "desc",
|
|
30
|
+
];
|
|
31
|
+
// `data-*` matters to deckrun itself: the math nodes carry `data-display` and
|
|
32
|
+
// the slide wrapper carries `data-index`. `aria-*` is markup, not capability.
|
|
33
|
+
const COMMON_ATTRS = [
|
|
34
|
+
"class", "id", "title", "style", "role", "lang", "dir", "hidden",
|
|
35
|
+
"data-*", "aria-*",
|
|
36
|
+
];
|
|
37
|
+
/** Geometry and paint attributes, so inline SVG survives intact. */
|
|
38
|
+
const SVG_ATTRS = [
|
|
39
|
+
"d", "cx", "cy", "r", "rx", "ry", "x", "y", "x1", "y1", "x2", "y2",
|
|
40
|
+
"width", "height", "points", "transform", "fill", "fill-opacity", "fill-rule",
|
|
41
|
+
"stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-dasharray",
|
|
42
|
+
"stroke-dashoffset", "stroke-opacity", "opacity", "offset", "stop-color",
|
|
43
|
+
"stop-opacity", "gradientUnits", "gradientTransform", "patternUnits",
|
|
44
|
+
"markerWidth", "markerHeight", "refX", "refY", "orient", "clip-path",
|
|
45
|
+
"text-anchor", "dominant-baseline", "font-size", "font-family", "font-weight",
|
|
46
|
+
"dx", "dy", "viewBox", "preserveAspectRatio", "xmlns", "xmlns:xlink",
|
|
47
|
+
// The parser lowercases attribute names, so the camelCase SVG attributes
|
|
48
|
+
// have to be listed in both forms or they are dropped from real diagrams.
|
|
49
|
+
"viewbox", "preserveaspectratio", "gradientunits", "gradienttransform",
|
|
50
|
+
"patternunits", "markerwidth", "markerheight", "refx", "refy",
|
|
51
|
+
];
|
|
52
|
+
export function sanitizeSlideHtml(html) {
|
|
53
|
+
return sanitizeHtml(html, {
|
|
54
|
+
allowedTags: ALLOWED_TAGS,
|
|
55
|
+
allowedAttributes: {
|
|
56
|
+
"*": COMMON_ATTRS,
|
|
57
|
+
a: [...COMMON_ATTRS, "href", "target", "rel", "name"],
|
|
58
|
+
img: [...COMMON_ATTRS, "src", "alt", "width", "height", "loading", "decoding", "srcset", "sizes"],
|
|
59
|
+
source: [...COMMON_ATTRS, "src", "srcset", "sizes", "type", "media"],
|
|
60
|
+
td: [...COMMON_ATTRS, "colspan", "rowspan", "align", "valign"],
|
|
61
|
+
th: [...COMMON_ATTRS, "colspan", "rowspan", "align", "valign", "scope"],
|
|
62
|
+
col: [...COMMON_ATTRS, "span", "width"],
|
|
63
|
+
colgroup: [...COMMON_ATTRS, "span"],
|
|
64
|
+
ol: [...COMMON_ATTRS, "start", "reversed", "type"],
|
|
65
|
+
li: [...COMMON_ATTRS, "value"],
|
|
66
|
+
details: [...COMMON_ATTRS, "open"],
|
|
67
|
+
time: [...COMMON_ATTRS, "datetime"],
|
|
68
|
+
svg: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
69
|
+
g: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
70
|
+
path: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
71
|
+
circle: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
72
|
+
ellipse: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
73
|
+
rect: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
74
|
+
line: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
75
|
+
polyline: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
76
|
+
polygon: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
77
|
+
text: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
78
|
+
tspan: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
79
|
+
marker: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
80
|
+
linearGradient: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
81
|
+
radialGradient: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
82
|
+
stop: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
83
|
+
clipPath: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
84
|
+
mask: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
85
|
+
pattern: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
86
|
+
symbol: [...COMMON_ATTRS, ...SVG_ATTRS],
|
|
87
|
+
use: [...COMMON_ATTRS, ...SVG_ATTRS, "href"],
|
|
88
|
+
},
|
|
89
|
+
allowedSchemes: ["http", "https", "mailto", "tel"],
|
|
90
|
+
allowedSchemesByTag: { img: ["http", "https", "data"] },
|
|
91
|
+
allowedSchemesAppliedToAttributes: ["href", "src", "cite", "srcset"],
|
|
92
|
+
allowProtocolRelative: true,
|
|
93
|
+
// Class names are a styling hook, not a capability; keep them all.
|
|
94
|
+
allowedClasses: false,
|
|
95
|
+
// Anything not in the tag list is unwrapped rather than deleted, so the
|
|
96
|
+
// text inside an unknown wrapper is not silently lost — except for the
|
|
97
|
+
// elements whose *content* is the payload.
|
|
98
|
+
nonTextTags: ["script", "style", "textarea", "option", "noscript", "template", "iframe", "object", "embed"],
|
|
99
|
+
parseStyleAttributes: false,
|
|
100
|
+
allowVulnerableTags: false,
|
|
101
|
+
});
|
|
102
|
+
}
|