@segnavia/html 0.36.0 → 0.38.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/dist/sanitize.js CHANGED
@@ -1,25 +1,27 @@
1
1
  import { sanitizeCss } from "./sanitize-css.js";
2
2
  import { isTag } from "domhandler";
3
+ import { dependencyDecision, dependencyOriginSet, originSet } from "@segnavia/format";
3
4
  import render from "dom-serializer";
4
5
  import { parseDocument } from "htmlparser2";
5
6
  //#region src/sanitize.ts
6
- const REFUSED_ELEMENTS = /* @__PURE__ */ new Set([
7
- "script",
8
- "iframe",
9
- "frame",
10
- "frameset",
11
- "object",
12
- "embed",
13
- "applet",
14
- "base",
15
- "portal",
16
- "link",
17
- "form",
18
- "input",
19
- "button",
20
- "select",
21
- "textarea"
22
- ]);
7
+ const REFUSED = {
8
+ script: "always",
9
+ iframe: "always",
10
+ frame: "always",
11
+ frameset: "always",
12
+ object: "always",
13
+ embed: "always",
14
+ applet: "always",
15
+ base: "always",
16
+ portal: "always",
17
+ link: "always",
18
+ form: "unless-scripts",
19
+ input: "unless-scripts",
20
+ button: "unless-scripts",
21
+ select: "unless-scripts",
22
+ textarea: "unless-scripts"
23
+ };
24
+ const REFUSED_ELEMENTS = new Set(Object.keys(REFUSED));
23
25
  const REFUSED_SVG_ELEMENTS = /* @__PURE__ */ new Set([
24
26
  "script",
25
27
  "animate",
@@ -78,14 +80,6 @@ function insideSvg(element) {
78
80
  function normalizeUrl(value) {
79
81
  return [...value].filter((c) => c.charCodeAt(0) > 32 && c.charCodeAt(0) !== 127).join("").toLowerCase();
80
82
  }
81
- function origins(values) {
82
- const set = /* @__PURE__ */ new Set();
83
- for (const value of values ?? []) try {
84
- const url = new URL(value);
85
- if (url.protocol === "https:") set.add(url.origin.toLowerCase());
86
- } catch {}
87
- return set;
88
- }
89
83
  function safeUrl(attribute, value, allowed) {
90
84
  const url = normalizeUrl(value);
91
85
  if (attribute === "href" || attribute === "xlink:href") {
@@ -104,68 +98,109 @@ function safeUrl(attribute, value, allowed) {
104
98
  function refusedElement(element, refused, keepScripts) {
105
99
  const name = element.name.toLowerCase();
106
100
  if (name === "script") return !keepScripts;
101
+ if (name === "link" && keepScripts && element.attribs.rel?.toLowerCase() === "stylesheet") return false;
107
102
  if (refused.has(name)) return true;
108
103
  if (insideSvg(element) && REFUSED_SVG_ELEMENTS.has(name)) return true;
109
104
  return name === "meta" && Object.keys(element.attribs).some((attribute) => attribute.toLowerCase() === "http-equiv");
110
105
  }
111
- function refusedAttribute(name, value, allowed, keepScripts) {
106
+ function dependencyAttribute(element, attribute, value, options, dependencies) {
107
+ if (!options.keepScripts) return void 0;
108
+ const tag = element.name.toLowerCase();
109
+ if (!(tag === "script" && attribute === "src" || tag === "link" && element.attribs.rel?.toLowerCase() === "stylesheet" && attribute === "href")) return void 0;
110
+ const decision = dependencyDecision(value, dependencies);
111
+ return decision.allowed ? "" : decision.reason;
112
+ }
113
+ function refusedAttribute(element, name, value, allowed, options, dependencies) {
112
114
  const attribute = name.toLowerCase();
113
- if (attribute.startsWith("on")) return !keepScripts;
114
- return REFUSED_ATTRIBUTES.has(attribute) || URL_ATTRIBUTES.has(attribute) && !safeUrl(attribute, value, allowed);
115
+ if (attribute.startsWith("on")) return options.keepScripts ? void 0 : "";
116
+ const dependency = dependencyAttribute(element, attribute, value, options, dependencies);
117
+ if (dependency !== void 0) return dependency || void 0;
118
+ return REFUSED_ATTRIBUTES.has(attribute) || URL_ATTRIBUTES.has(attribute) && !safeUrl(attribute, value, allowed) ? "" : void 0;
115
119
  }
116
120
  function refusedSet(options) {
117
- return new Set([...REFUSED_ELEMENTS, ...options.refuseElements ?? []].map((name) => name.toLowerCase()));
121
+ const defaults = options.keepScripts ? Object.entries(REFUSED).filter(([, rule]) => rule === "always").map(([name]) => name) : REFUSED_ELEMENTS;
122
+ return new Set([...defaults, ...options.refuseElements ?? []].map((name) => name.toLowerCase()));
118
123
  }
119
124
  function styleText(element) {
120
125
  return element.children.map((child) => "data" in child ? child.data : "").join("");
121
126
  }
122
- function styleFindings(element, dataFonts) {
127
+ function dependencyCss(source, declarations, options, dependencies) {
128
+ if (!options.keepScripts) return sanitizeCss(source, declarations, options.dataFonts ?? false);
129
+ const restores = [];
130
+ const hold = (url) => {
131
+ if (!dependencyDecision(url, dependencies).allowed) return url;
132
+ return `data:image/segnavia-dependency-${restores.push(url) - 1},x`;
133
+ };
134
+ let marker = "segnavia-held-import";
135
+ while (source.includes(marker)) marker += "-x";
136
+ const imports = [];
137
+ let protectedCss = source.replace(/@import\s+(?:url\(\s*)?["']?(https:\/\/[^\s"')]+)["']?\s*\)?[^;]*;/gi, (rule, url) => {
138
+ if (hold(url) === url) return rule;
139
+ const index = imports.push(rule) - 1;
140
+ return `.${marker}-${index}{}`;
141
+ });
142
+ protectedCss = protectedCss.replace(/@font-face\s*\{[^}]*\}/gi, (block) => block.replace(/https:\/\/[^\s"')]+/gi, hold));
143
+ const result = sanitizeCss(protectedCss, declarations, options.dataFonts ?? false);
144
+ const restore = (value) => value.replace(/data:image\/segnavia-dependency-(\d+),x/g, (_, index) => restores[Number(index)] ?? "");
145
+ let css = restore(result.css);
146
+ for (const [index, rule] of imports.entries()) css = css.replace(`.${marker}-${index}{}`, rule);
147
+ return {
148
+ css,
149
+ removed: result.removed.map(restore)
150
+ };
151
+ }
152
+ function styleFindings(element, options, dependencies) {
123
153
  const found = [];
124
- if (element.name.toLowerCase() === "style") for (const construct of sanitizeCss(styleText(element), false, dataFonts).removed) found.push(`<style> ${construct}`);
154
+ if (element.name.toLowerCase() === "style") for (const construct of dependencyCss(styleText(element), false, options, dependencies).removed) found.push(`<style> ${construct}`);
125
155
  for (const [name, value] of Object.entries(element.attribs)) {
126
156
  if (name.toLowerCase() !== "style") continue;
127
- for (const construct of sanitizeCss(value, true, dataFonts).removed) found.push(`${element.name}[${name}] ${construct}`);
157
+ for (const construct of dependencyCss(value, true, options, dependencies).removed) found.push(`${element.name}[${name}] ${construct}`);
128
158
  }
129
159
  return found;
130
160
  }
131
161
  function findActiveContent(html, options = {}) {
132
- const allowed = origins(options.linkOrigins);
162
+ const allowed = originSet(options.linkOrigins);
163
+ const dependencies = dependencyOriginSet(options.dependencyOrigins);
133
164
  const refused = refusedSet(options);
134
165
  const keepScripts = options.keepScripts ?? false;
135
166
  const found = [];
136
167
  for (const element of elements(parse(html).children)) {
137
168
  if (refusedElement(element, refused, keepScripts)) found.push(`<${element.name}>`);
138
- found.push(...styleFindings(element, options.dataFonts ?? false));
139
- for (const [name, value] of Object.entries(element.attribs)) if (refusedAttribute(name, value, allowed, keepScripts)) found.push(`${element.name}[${name}]`);
169
+ found.push(...styleFindings(element, options, dependencies));
170
+ for (const [name, value] of Object.entries(element.attribs)) {
171
+ const reason = refusedAttribute(element, name, value, allowed, options, dependencies);
172
+ if (reason !== void 0) found.push(`${element.name}[${name}]${reason ? ` ${reason}` : ""}`);
173
+ }
140
174
  }
141
175
  return found;
142
176
  }
143
- function cleanStyleElement(element, dataFonts) {
177
+ function cleanStyleElement(element, options, dependencies) {
144
178
  if (element.name.toLowerCase() !== "style") return;
145
179
  const children = element.children.filter((candidate) => "data" in candidate);
146
180
  const child = children[0];
147
181
  if (!child || !("data" in child)) return;
148
- child.data = sanitizeCss(styleText(element), false, dataFonts).css;
182
+ child.data = dependencyCss(styleText(element), false, options, dependencies).css;
149
183
  for (const extra of children.slice(1)) if ("data" in extra) extra.data = "";
150
184
  }
151
- function cleanAttributes(element, allowed, keepScripts, dataFonts) {
152
- for (const [name, value] of Object.entries(element.attribs)) if (refusedAttribute(name, value, allowed, keepScripts)) delete element.attribs[name];
185
+ function cleanAttributes(element, allowed, options, dependencies) {
186
+ for (const [name, value] of Object.entries(element.attribs)) if (refusedAttribute(element, name, value, allowed, options, dependencies) !== void 0) delete element.attribs[name];
153
187
  else if (name.toLowerCase() === "style") {
154
- const css = sanitizeCss(value, true, dataFonts).css.trim();
188
+ const css = dependencyCss(value, true, options, dependencies).css.trim();
155
189
  if (css) element.attribs[name] = css;
156
190
  else delete element.attribs[name];
157
191
  }
158
192
  }
159
193
  function sanitize(html, options = {}) {
160
- const allowed = origins(options.linkOrigins);
194
+ const allowed = originSet(options.linkOrigins);
195
+ const dependencies = dependencyOriginSet(options.dependencyOrigins);
161
196
  const refused = refusedSet(options);
162
197
  const keepScripts = options.keepScripts ?? false;
163
198
  const document = parse(html);
164
199
  const prune = (nodes) => nodes.filter((node) => {
165
200
  if (!isTag(node)) return true;
166
201
  if (refusedElement(node, refused, keepScripts)) return false;
167
- cleanStyleElement(node, options.dataFonts ?? false);
168
- cleanAttributes(node, allowed, keepScripts, options.dataFonts ?? false);
202
+ cleanStyleElement(node, options, dependencies);
203
+ cleanAttributes(node, allowed, options, dependencies);
169
204
  node.children = prune(node.children);
170
205
  return true;
171
206
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segnavia/html",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,7 +26,7 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@segnavia/format": "0.36.0",
29
+ "@segnavia/format": "0.38.0",
30
30
  "dom-serializer": "^2.0.0",
31
31
  "domhandler": "^5.0.3",
32
32
  "htmlparser2": "^10.0.0",