Skip to main content

max / goingson

3.2 KB · 96 lines History Blame Raw
1 /**
2 * GoingsOn - Escaping primitives (single source of truth)
3 *
4 * The CHRONIC-XSS seal lives here. Both the main app (via utils.js, which
5 * re-exports these onto GoingsOn.utils) and the standalone compose window (which
6 * loads this file directly) use these exact functions, so there is ONE
7 * implementation of each escaper and the enforcement gate in js/tests/run.js
8 * covers every caller. Load this before utils.js (main app) and before
9 * compose-form.js (compose window).
10 */
11 (function() {
12 'use strict';
13
14 /**
15 * Escape HTML special characters for a text/innerHTML context.
16 * Note: textContent serialization does NOT encode `"`, so this is NOT safe for
17 * a double-quoted attribute value, use escapeAttrValue there.
18 * @param {string} text
19 * @returns {string}
20 */
21 function escapeHtml(text) {
22 if (typeof document !== 'undefined') {
23 const div = document.createElement('div');
24 div.textContent = text == null ? '' : String(text);
25 return div.innerHTML;
26 }
27 // Non-DOM fallback (test harness): entity-encode the HTML metacharacters.
28 return String(text == null ? '' : text).replace(/[&<>"']/g, (c) => ({
29 '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
30 })[c]);
31 }
32
33 /**
34 * Escape a string for a raw JavaScript string-literal context (NOT an HTML
35 * attribute). Kept private: only sound as a building block of escapeHandlerArg.
36 * @param {string} str
37 * @returns {string}
38 */
39 function escapeJsString(str) {
40 if (str == null) return '';
41 return String(str)
42 .replace(/\\/g, '\\\\')
43 .replace(/'/g, "\\'")
44 .replace(/"/g, '\\"')
45 .replace(/\n/g, '\\n')
46 .replace(/\r/g, '\\r');
47 }
48
49 /**
50 * Escape a string for placement inside a double-quoted HTML attribute value.
51 * Encodes `&` and `"` so the value cannot terminate the attribute. This is the
52 * ONLY escaper for HTML attributes. (ultra-fuzz Run #28 C1, CHRONIC-XSS.)
53 * @param {string} str
54 * @returns {string}
55 */
56 function escapeAttrValue(str) {
57 return String(str ?? '')
58 .replace(/&/g, '&amp;')
59 .replace(/"/g, '&quot;');
60 }
61
62 /**
63 * Escape a value placed inside a quoted JS string *within* a double-quoted
64 * inline handler, e.g. `onclick="fn('${escapeHandlerArg(x)}')"`. Survives both
65 * the JS-string and HTML-attribute layers.
66 * @param {string} str
67 * @returns {string}
68 */
69 function escapeHandlerArg(str) {
70 return escapeAttrValue(escapeJsString(str));
71 }
72
73 /**
74 * Return a URL only if it uses a safe scheme (http/https/mailto), else "#".
75 * Blocks `javascript:`, `data:`, and custom-handler schemes. Still pass the
76 * result through escapeAttrValue for the attribute itself.
77 * @param {string} url
78 * @returns {string}
79 */
80 function safeUrl(url) {
81 const s = String(url ?? '').trim();
82 return /^(https?:|mailto:)/i.test(s) ? s : '#';
83 }
84
85 const api = { escapeHtml, escapeAttrValue, escapeHandlerArg, safeUrl };
86
87 // Attach to the same GoingsOn the rest of the app references by bare identifier.
88 // The standalone compose window loads this file with no goingson.js, so create
89 // the namespace if it doesn't exist yet.
90 if (typeof GoingsOn === 'undefined') {
91 (typeof window !== 'undefined' ? window : globalThis).GoingsOn = {};
92 }
93 GoingsOn.escape = api;
94
95 })();
96