Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Set the nonce attribute on inline style tags created by the output processor #2665

Open
christianp opened this issue Mar 30, 2021 · 9 comments
Labels
Code Example Contains an illustrative code example, solution, or work-around Feature Request v3

Comments

@christianp
Copy link
Contributor

Content security policies can specify a value for the nonce that must be present on all inline styles, in the style-src part of the directive. When a nonce is specified, the unsafe-inline directive is ignored.

The idea is to prevent styles included in user-supplied content from being applied. The nonce should be different each time the page is loaded, so this only protects against content that doesn't change with each page load.

The Google closure library resolves this by finding a <script> tag in the page with the nonce attribute, and using that. There isn't always one of those, so it should be possible to pass it as an option in the MathJax config, too.

I'm looking at this today to get the MathJax integration on mastodon working, so I might have a pull request soon.

@christianp
Copy link
Contributor Author

I thought it was OK, but it turns out I just had an old version of Chrome and the style attributes on individual elements do indeed get blocked. I've spent the afternoon trying to work out how to pull these out during processing, and add them to the document stylesheet.

I think that it'll need to go in an extension, so it can work as a postFilter, like the safe extension does. Does that sound right, @dpvc?

@dpvc
Copy link
Member

dpvc commented Apr 1, 2021

I remember looking into this before, and was pretty sure that style attributes on DOM nodes would be a problem, not just the <style> nodes themselves. I seem to recall (but can't now find a reference for it) that if you nonce a script, then it can add style attributes, so if you add the nonce attribute to the script tag that loads the MathJax component you are using, then perhaps that will do it?

Otherwise, yes, it appears you will have to make stylesheet entries for all the elements' style attributes. Argh!

The safe extension is one model for how to do that as an extension (using a postFilter on the output jax rather than the array of input jax as in that example).

Another option would be to use a renderAction that follows the typesetting but precedes the document updating. That doesn't require you to make your own handler and such, but does mean you have to provide two paths (one for document updating, and one for individual expression updating).

Finally, another potential approach would be to handle it via the DOMAdaptor itself. You could subclass the HTMLAdaptor and override the setStyle(), getStyle(), and allStyles() methods to manage node styles yourself. You could, for example, maintain a Map from nodes to the styles for that node, add a unique class to each node that has styles set, and have your own stylesheet that you maintain that maps those classes to the needed styles. You could tie into the adaptor's append() and replace() methods to test of they are inserting stylesheets, and either update those sheets before inserting, or insert your own at that point. Alternatively, you could add a renderAction to make that happen if you don't want to be sneaking about monitoring for the insertion of the stylesheet.

You would need to register your adaptor with MathJax.startup as the one to use in place of the HTMLAdaptor. If you want to go that route, I can try to put together an example.

@christianp
Copy link
Contributor Author

Your last option is the one I spent an afternoon trying, but I couldn't work out how to join up the stylesheet insertion. It sounds like renderActions are what I was missing.

@dpvc
Copy link
Member

dpvc commented Apr 6, 2021

I didn't get to look at this until today, and it turns out that renderAction is probably not the right place to do this. I had forgotten about the fact that MathJax inserts items that are not directly tied to the typeset expressions, like nodes used to measure the em and ex sizes, and nodes needed to measure characters not in the MathJax fonts, and so on. These nodes have styles, and they need to be handled as well. The renderActions can't be used for that.

Fortunately, it can all be done through the DOM adaptor. I have put together the following example configuration that implements a CSPAdaptor as a subclass of the HTMLAdaptor that I think should do the trick. It overrides the setStyle(), getStyle() and allStyles() methods to maintain the styles separately and insert the needed styles into a separate stylesheet that it maintains. It also overrides the clone method to make sure that cloned nodes get their own managed styles separate from the original nodes. Finally, it overrides the append(), insert(), and replace() methods to check when new items are being added to the active DOM, and inserts the needed style definitions for them when they are. It also automatically adds the nonce to added stylesheets (so no need to handle that separately by the output jax).

Because MathJax adds and removes elements (like the ones used for measurements), this code also takes some pains to detect when DOM elements have been dropped, and to remove their CSS rules and internal data. That makes it practical to use in dynamic pages where the math may be added or removed from the page without ending up using up lots of memory for rules and data that have been removed. This garbage-collection process can probably be improved, but it is at least an attempt to remove unneeded data.

MathJax = {
  CSP: {nonce: 'hello'},
  startup: {
    ready() {
      const {HTMLAdaptor} = MathJax._.adaptors.HTMLAdaptor;
      const {Styles} = MathJax._.util.Styles;
      const {combineDefaults} = MathJax._.components.global;
      
      //
      //  Allow configuration of nonce via MathJax = {CSP: {nonce: '...'}}
      //
      combineDefaults(MathJax.config, 'CSP', {nonce: ''});
      
      /*****************************************************************/
      
      /*
       * Class to hold styles for a given node, and the rule that specifies it.
       */
      class CssItem {
        css = new Styles();      // The style for the node
        node = null;             // The DOM node, when added to the DOM
        rule = null;             // The stylesheet rule for this DOM node

        prev = 0;                // The index of the pevious node in the list
        next = 0;                // The index of the next node in the list
        
        constructor(cssText = '') {
          if (cssText) this.css.parse(cssText);
        }
      }
      
      /*****************************************************************/
      
      /*
       * Class to hold a linke list of CssItem data.
       */
      class CssList {
        list = [new CssItem()];  // The list of items (the first is the root item)
        root = this.list[0];     // The root item (next is start of list, prev is end of list)
        free = [];               // The list of free indices (for reuse)
        
        /*
         * The length of the list (not counting empty slots).
         */
        get length() {
          return this.list.length - this.free.length - 1;
        }
        
        /*
         * The data for the nth item.
         */
        get(n) {
          if (n < 1) return null;
          return this.list[n];
        }
        
        /*
         * Add a new item with the given CSS string.
         */
        add(css) {
          const n = (this.free.length ? this.free.pop() : this.list.length);
          const item = new CssItem(css);
          this.list[n] = item;
          item.next = 0;
          item.prev = this.root.prev;
          this.list[item.prev].next = n;
          this.root.prev = n;
          return n;
        }
        
        /*
         * Remove the nth item from the list.
         */
        remove(n) {
          const item = this.list[n];
          if (!item || n === 0) return;
          this.list[item.prev].next = item.next;
          this.list[item.next].prev = item.prev;
          this.free.push(n);
          this.list[n] = null;
        }

      }

      /*****************************************************************/

      /*
       * A sublcass of HTMLAdaptor that removes in-line styles and uses a
       * stylesheet for the styles instead (for better CSP support).
       */
      class CSPAdaptor extends HTMLAdaptor {
        nodes = new CssList();                 // The list of CssItems for the nodes with CSS
        domCount = 0;                          // The number of such nodes in the DOM
        dataName = 'data-mjx-css-id';          // The attribute to use for the node id
        dataSelector = `[${this.dataName}]`;   // The selector to target the nodes with ids
        sheet = null;                          // The stylesheet object for the CSP styles
        nonde = '';                            // The nonce to use for stylesheets

        removeId = 0;                          // The next id to check for removal
        removeCount = 10;                      // The number of nodes to check for removal at one time
        
        /*
         * Do the usual constructor and then create the style sheet.
         */
        constructor(window, nonce = '') {
          super(window);
          const style = window.document.head.appendChild(window.document.createElement('style'));
          style.setAttribute('id', 'CSP-STYLES');
          if (nonce) style.setAttribute('nonce', nonce);
          this.sheet = style.sheet;
          this.nonce = nonce;
        }
        
        /*
         * Get the CssItem for the given node, or create one with the given CSS (if given).
         */
        getCSS(node, css = null) {
          let item = this.nodes.get(node.getAttribute(this.dataName));
          if (item) return item;
          if (css === null) return new CssItem();
          const n = this.nodes.add(css);
          node.setAttribute(this.dataName, n);
          return this.nodes.get(n);
        }
        
        /*
         * Return an array of nodes that have styles in the tree rooted at the given node.
         */
        getCssNodes(node) {
          const nodes = Array.from(node.querySelectorAll(this.dataSelector));
          if (node.getAttribute(this.dataName)) nodes.push(node);
          return nodes;
        }
        
        /*
         * Modify the CssItem's css, and if there is an active rule for this item, modify that as well.
         */
        setStyle(node, name, value) {
          const item = this.getCSS(node, '');
          item.css.set(name, value);
          if (item.rule) {
            item.rule.style[name] = value;
          }
        }
        
        /*
         * Get the node's style value from the CssItem style.
         */
        getStyle(node, name) {
          const css = this.getCSS(node).css;
          return css.get(name) || '';
        }
        
        /*
         * Get the cssText from the CssItem for this node.
         */
        allSyles(node) {
          const css = this.getCSS(node).css;
          return css.cssText;
        }

        /*
         * If any cloned nodes have styles, give them separate ids and copy the styles.
         */
        clone(node1) {
          const node2 = super.clone(node1);
          for (const node of this.getCssNodes(node2)) {
            const id = node.getAttribute(this.dataName);
            node.removeAttribute(this.dataName);
            this.getCSS(node, this.nodes.get(id).css.cssText);
          }
          return node2;
        }
        
        /*
         * Try to clean up DOM elements that may have been removed.
         * We look through the list for items that were once in the DOM
         * but aren't any longer (but only look through a few at a time,
         * for efficiency).  This could be made more sophisticated, e.g.
         * keeping the list of nodes that are in the DOM separate from
         * those that aren't, or not counting the ones it actually removes
         * so that large strings of dead nodes could be removed at once.
         * This is probably good enough for now.           
         */
        checkRemoved() {
          let n = Math.min(this.domCount, this.removeCount);
          let m = this.nodes.length;
          while (n && m--) {
            if (this.removeId === 0) {
              this.removeId = this.nodes.root.next;
            }
            const item = this.nodes.get(this.removeId);
            if (item.node) {
              if (!this.window.document.contains(item.node)) {
                this.nodes.remove(this.removeId);
                item.node.removeAttribute(this.dataName);
                if (item.rule) {
                  this.sheet.deleteRule(Array.from(this.sheet.cssRules).indexOf(item.rule));
                }
                this.domCount--;
              }
              n--;
            }
            this.removeId = item.next;
          }
        }
        
        /*
         * When a child is added to a parent, check if the parent is in the DOM.
         * If so, do a removal check (this is our garbage collection call).
         * Then for each node that has manages styles,
         *   If the node is not already in the DOM
         *     Insert the new style into the style sheet
         *     and record the style rule and node in the CssItem.
         */
        updateCSS(parent, child) {
          if (parent && this.window.document.contains(parent)) {
            this.checkRemoved();
            for (const node of this.getCssNodes(child)) {
              const id = node.getAttribute(this.dataName);
              const item = this.nodes.get(id);
              if (!item.node) {
                const rule = `[${this.dataName}="${id}"] {${item.css.cssText}}`;
                this.sheet.insertRule(rule, 0);
                item.rule = this.sheet.cssRules[0];
                item.node = node;
                this.domCount++;
              }
            }
          }
        }
        
        /*
         * Add nonce to any stylesheets (handles both CHTML and SVG output).
         */
        addNonce(node) {
          if (this.nonce && node.nodeName === 'STYLE') {
            node.setAttribute('nonce', this.nonce);
          }
        }
        
        /*
         * If the child is being added to the active DOM, update the CSS first.
         */
        append(parent, child) {
          this.updateCSS(parent, child);
          this.addNonce(child);
          return super.append(parent, child);
        }
        
        /*
         * If the child is being added to the active DOM, update the CSS first.
         */
        insert(parent, child) {
          this.updateCSS(parent, child);
          this.addNonce(child);
          return super.insert(parent, child);
        }
        
        /*
         * If the new node is being added to the active DOM, update the CSS first.
         */
        replace(nnode, onode) {
          this.updateCSS(onode.parentNode, nnode);
          this.addNonce(nnode);
          return super.replace(nnode, onode);
        }
        
      }
      
      /*****************************************************************/
      
      //
      // Have the browerAdaptor use the new CSPAdaptor.
      //
      MathJax.startup.registerConstructor("browserAdaptor", () => new CSPAdaptor(window, MathJax.config.CSP.nonce));

      //
      // Do the usual startup
      //
      MathJax.startup.defaultReady();

    }
  }
};

Of course, this should be made into an actual extension, but this setup allows you to modify and test the code easily without having to webpack extensions for every change. In any case, you may find it a useful example for your efforts.

@dpvc dpvc added Code Example Contains an illustrative code example, solution, or work-around Feature Request v3 labels Apr 6, 2021
@mbourne
Copy link

mbourne commented May 24, 2021

Davide

I found this when trying to settle the security issues with that demo page I worked on for Bernd.

I used your code snippet which worked fine and added nonce's to the styles that MathJax adds. However, the nonce additions came too late (for the setup I was using on that page) and Chrome had already tried and rejected applying the styles. (I ended up having to use 'unsafe-inline' in the header, which kind of negates the point of security.)

Is it possible to add an option where the developer provides an nonce value in the setup like you have above, but with an extra flag that just says basically to add the nonce tags as the styles are added to the page?

@dpvc
Copy link
Member

dpvc commented May 24, 2021

@mbourne, I'm not sure I understand the request. The current code adds the none to the style tags before they are inserted into the page, and there are no explicit style attributes on any of the nodes used to typeset the math. The styles are inserted into a special (nonced) stylesheet before the math is inserted into the page, so the styles should be all set before the elements are in the DOM.

Can you be more specific about what you are doing?

@mbourne
Copy link

mbourne commented May 26, 2021

Sorry, but I realise now that the above code is not meant to do what I'm talking about. Your questions address exactly what I mean.

My question comes from the fact that on that page for Bernd, the styles that were added do not include any nonce's.

image

The first style tag is my own (with the nonce that I applied), and the other 3 were added by MathJax, and do not have nonce's.

In an attempt to rectify this, I tried to add the nonce to each style tag after it was created, and that didn't do anything as far as applying the styles to the page.

Then I tried removing the styles and creating new ones, this time with nonce's. This time the browser recognised them (I assume by the appearance in DOM inspector), but the styles were not applied to the page.

image

This is the page where I'm doing this (I removed 'unsafe-inline' in the headers for this page):

https://bourne2learn.com/cg3/peu/neural_networks_backpropagation-styles-nonce3.php

So back to my initial question. How do I tell the startup sequence what my nonce value is, and have it applied to the styles created by MathJax?

@dpvc
Copy link
Member

dpvc commented May 26, 2021

@mbourne, there was additional information in the PR that Christian provided, and it indicates that there are styles generated by the MathJax menu that you will not be able to manage in this way. The three stylesheets that you mention are generated by the menu framework, and can't be fixed in an easy way. Even if you could nonce those stylesheets, the menu code uses inline styles that don't pass through the DOMadaptor that we have subclassed above, and so this will not catch those, and you will still get errors. You will not be able to include the menu when using this approach, and since the menu its included in the tex-chtml component that you are using, you will need to change that to load the needed pieces explicitly and using the startup component instead. Something like:

<script>
MathJax = {
  loader: {load: ['input/tex', 'output/chtml']},
  ... (the rest of the stuff from above) ...
};
</script>
<script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/startup"></script>

Here I've left out the menu component and the assistive MathML component, which may also cause problems under certain circumstances (the MathML could include styles, which will not be trapped through the code above).

@dpvc
Copy link
Member

dpvc commented May 26, 2021

@mbourne: I've looked closer at your page and noticed a few things that may be contributing to your issues. First, you haven't included the nonce in the style-src, so I think that will prevent the styles from being applied even if they have the nonce. Second, you don't seem to be using the code I gave in my first comment above, and are simply trying to add the nonce yourself. That's not going to work. The code above does two things: adds the nonce to the CHTML stylesheet, and, most crucially, prevents MathJax from using in-line styles for any of the elements that it uses to typeset the mathematics. Without those two functions, you will certainly run afoul of the CSP policy.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Code Example Contains an illustrative code example, solution, or work-around Feature Request v3
Projects
None yet
Development

No branches or pull requests

3 participants