« get me outta code hell

data, html, infra: supporting changes for sanitizing content - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-09-11 10:09:48 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-09-11 10:09:48 -0300
commitd878ab29f20c0727acafb4b1150d4e31d69c55c0 (patch)
tree69a92256a271642e948acc9ce3874e3e1e0ea6e1
parent0ff743b1350b1d42ba23d9701a0b7acfb7501254 (diff)
data, html, infra: supporting changes for sanitizing content
-rw-r--r--src/data/things/language.js88
-rw-r--r--src/data/things/thing.js4
-rw-r--r--src/util/html.js57
3 files changed, 104 insertions, 45 deletions
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 7755c505..cc49b735 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -1,5 +1,10 @@
 import Thing from './thing.js';
 
+import {Tag} from '#html';
+import {isLanguageCode} from '#validators';
+
+import CacheableObject from './cacheable-object.js';
+
 export class Language extends Thing {
   static [Thing.getPropertyDescriptors] = ({
     validators: {
@@ -68,7 +73,7 @@ export class Language extends Thing {
 
     // Update only
 
-    escapeHTML: Thing.common.externalFunction(),
+    escapeHTML: Thing.common.externalFunction({expose: true}),
 
     // Expose only
 
@@ -140,19 +145,9 @@ export class Language extends Thing {
   }
 
   formatString(key, args = {}) {
-    if (this.strings && !this.strings_htmlEscaped) {
-      throw new Error(`HTML-escaped strings unavailable - please ensure escapeHTML function is provided`);
-    }
-
-    return this.formatStringHelper(this.strings_htmlEscaped, key, args);
-  }
+    const strings = this.strings_htmlEscaped;
 
-  formatStringNoHTMLEscape(key, args = {}) {
-    return this.formatStringHelper(this.strings, key, args);
-  }
-
-  formatStringHelper(strings, key, args = {}) {
-    if (!strings) {
+    if (!this.strings) {
       throw new Error(`Strings unavailable`);
     }
 
@@ -160,22 +155,25 @@ export class Language extends Thing {
       throw new Error(`Invalid key ${key} accessed`);
     }
 
-    const template = strings[key];
+    const template = this.strings[key];
 
     // Convert the keys on the args dict from camelCase to CONSTANT_CASE.
     // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut
     // like, who cares, dude?) Also, this is an array, 8ecause it's handy
-    // for the iterating we're a8out to do.
-    const processedArgs = Object.entries(args).map(([k, v]) => [
-      k.replace(/[A-Z]/g, '_$&').toUpperCase(),
-      v,
-    ]);
+    // for the iterating we're a8out to do. Also strip HTML from arguments
+    // that are literal strings - real HTML content should always be proper
+    // HTML objects (see html.js).
+    const processedArgs =
+      Object.entries(args).map(([k, v]) => [
+        k.replace(/[A-Z]/g, '_$&').toUpperCase(),
+        this.#sanitizeStringArg(v),
+      ]);
 
     // Replacement time! Woot. Reduce comes in handy here!
-    const output = processedArgs.reduce(
-      (x, [k, v]) => x.replaceAll(`{${k}}`, v),
-      template
-    );
+    const output =
+      processedArgs.reduce(
+        (x, [k, v]) => x.replaceAll(`{${k}}`, v),
+        template);
 
     // Post-processing: if any expected arguments *weren't* replaced, that
     // is almost definitely an error.
@@ -183,7 +181,37 @@ export class Language extends Thing {
       throw new Error(`Args in ${key} were missing - output: ${output}`);
     }
 
-    return output;
+    // Last caveat: Wrap the output in an HTML tag so that it doesn't get
+    // treated as unsanitized HTML if *it* gets passed as an argument to
+    // *another* formatString call.
+    return this.#wrapSanitized(output);
+  }
+
+  // Escapes HTML special characters so they're displayed as-are instead of
+  // treated by the browser as a tag. This does *not* have an effect on actual
+  // html.Tag objects, which are treated as sanitized by default (so that they
+  // can be nested inside strings at all).
+  #sanitizeStringArg(arg) {
+    const escapeHTML = this.escapeHTML;
+
+    if (!escapeHTML) {
+      throw new Error(`escapeHTML unavailable`);
+    }
+
+    if (typeof arg !== 'string') {
+      return arg.toString();
+    }
+
+    return escapeHTML(arg);
+  }
+
+  // Wraps the output of a formatting function in a no-name-nor-attributes
+  // HTML tag, which will indicate to other calls to formatString that this
+  // content is a string *that may contain HTML* and doesn't need to
+  // sanitized any further. It'll still .toString() to just the string
+  // contents, if needed.
+  #wrapSanitized(output) {
+    return new Tag(null, null, output);
   }
 
   formatDate(date) {
@@ -252,19 +280,25 @@ export class Language extends Thing {
   // Conjunction list: A, B, and C
   formatConjunctionList(array) {
     this.assertIntlAvailable('intl_listConjunction');
-    return this.intl_listConjunction.format(array.map(arr => arr.toString()));
+    return this.#wrapSanitized(
+      this.intl_listConjunction.format(
+        array.map(item => this.#sanitizeStringArg(item))));
   }
 
   // Disjunction lists: A, B, or C
   formatDisjunctionList(array) {
     this.assertIntlAvailable('intl_listDisjunction');
-    return this.intl_listDisjunction.format(array.map(arr => arr.toString()));
+    return this.#wrapSanitized(
+      this.intl_listDisjunction.format(
+        array.map(item => this.#sanitizeStringArg(item))));
   }
 
   // Unit lists: A, B, C
   formatUnitList(array) {
     this.assertIntlAvailable('intl_listUnit');
-    return this.intl_listUnit.format(array.map(arr => arr.toString()));
+    return this.#wrapSanitized(
+      this.intl_listUnit.format(
+        array.map(item => this.#sanitizeStringArg(item))));
   }
 
   // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB
diff --git a/src/data/things/thing.js b/src/data/things/thing.js
index c2876f56..5705ee7e 100644
--- a/src/data/things/thing.js
+++ b/src/data/things/thing.js
@@ -105,8 +105,8 @@ export default class Thing extends CacheableObject {
 
     // External function. These should only be used as dependencies for other
     // properties, so they're left unexposed.
-    externalFunction: () => ({
-      flags: {update: true},
+    externalFunction: ({expose = false} = {}) => ({
+      flags: {update: true, expose},
       update: {validate: (t) => typeof t === 'function'},
     }),
 
diff --git a/src/util/html.js b/src/util/html.js
index a311bbba..f0c7bfdf 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -806,24 +806,43 @@ export class Template {
     }
 
     // Null is always an acceptable slot value.
-    if (value !== null) {
-      if ('validate' in description) {
-        description.validate({
-          ...commonValidators,
-          ...validators,
-        })(value);
-      }
+    if (value === null) {
+      return true;
+    }
+
+    if ('validate' in description) {
+      description.validate({
+        ...commonValidators,
+        ...validators,
+      })(value);
+    }
 
-      if ('type' in description) {
-        const {type} = description;
-        if (type === 'html') {
-          if (!isHTML(value)) {
+    if ('type' in description) {
+      switch (description.type) {
+        case 'html': {
+          if (!isHTML(value))
             throw new TypeError(`Slot expects html (tag, template or blank), got ${typeof value}`);
-          }
-        } else {
-          if (typeof value !== type) {
-            throw new TypeError(`Slot expects ${type}, got ${typeof value}`);
-          }
+
+          return true;
+        }
+
+        case 'string': {
+          // Tags and templates are valid in string arguments - they'll be
+          // stringified when exposed to the description's .content() function.
+          if (isTag(value) || isTemplate(value))
+            return true;
+
+          if (typeof value !== 'string')
+            throw new TypeError(`Slot expects string, got ${typeof value}`);
+
+          return true;
+        }
+
+        default: {
+          if (typeof value !== description.type)
+            throw new TypeError(`Slot expects ${description.type}, got ${typeof value}`);
+
+          return true;
         }
       }
     }
@@ -847,6 +866,12 @@ export class Template {
       return providedValue;
     }
 
+    if (description.type === 'string') {
+      if (isTag(providedValue) || isTemplate(providedValue)) {
+        return providedValue.toString();
+      }
+    }
+
     if (providedValue !== null) {
       return providedValue;
     }