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

Style tag injected into document head #571

Closed
aomarks opened this issue Oct 18, 2021 · 21 comments
Closed

Style tag injected into document head #571

aomarks opened this issue Oct 18, 2021 · 21 comments
Assignees
Labels
question General questions about the project.

Comments

@aomarks
Copy link

aomarks commented Oct 18, 2021

Describe the bug

Importing a shoelace component inserts a <style> tag into the document <head>.

If shoelace components are being used in a shadow root, then this style won't have any effect.

Would it be possible to include these styles in the default stylesheet instead?

(I see it used to be in https://unpkg.com/browse/@shoelace-style/shoelace@2.0.0-beta.47/dist/themes/base.css, but now it's a separate and automatically inserted into the <head> from https://unpkg.com/browse/@shoelace-style/shoelace@2.0.0-beta.57/dist/styles/component.styles.js).

This isn't strictly causing a problem because the class names are unique enough, but I did find it a little odd and seemingly unnecessary, and it violates the principle of encapsulation.

To Reproduce

import '@shoelace-style/shoelace/dist/components/dialog/dialog.js';

Observe new style tag in head:

<style>
  .sl-scroll-lock {
    overflow: hidden !important;
  }

  .sl-toast-stack {
    position: fixed;
    top: 0;
    right: 0;
    z-index: var(--sl-z-index-toast);
    width: 28rem;
    max-width: 100%;
    max-height: 100%;
    overflow: auto;
  }

  .sl-toast-stack sl-alert {
    --box-shadow: var(--sl-shadow-large);
    margin: var(--sl-spacing-medium);
  }
</style>
@aomarks aomarks added the bug Things that aren't working right in the library. label Oct 18, 2021
@claviska
Copy link
Member

Those are utility styles that were split out of the base stylesheet, as you've mentioned.

The scroll lock class is used by a number of components via the internal scroll utility and will always be applied to the <body>.

The toast stack styles are used by the alert component. When you have an <sl-alert> and call its toast() method, the alert will be moved from its current position in the DOM into the toast stack as described here. This will always be a child of the <body> so the styles must be in the light DOM.

Now that there's no base stylesheet requirement, I don't think it makes sense to put these into the light and dark stylesheets. They'd need to be duplicated, and it would mean third-party themes that don't use light/dark will have to reimplement them.

It could be argued that the toast stack styles can be moved into the alert component, but that limits their reuse. (You might want to utilize the stack for other types of alerts, for example.) The case for moving scroll lock into a separate component is even less compelling.

it violates the principle of encapsulation

Sort of. They're not being used in components. They're being used by components because it's impossible to encapsulate what they're doing from within a shadow root.

I hope that explains why those exist. If you have a suggestion based on this new info, let me know!

@claviska claviska added question General questions about the project. and removed bug Things that aren't working right in the library. labels Oct 18, 2021
@aomarks
Copy link
Author

aomarks commented Oct 18, 2021

it violates the principle of encapsulation

Sort of. They're not being used in components. They're being used by components because it's impossible to encapsulate what they're doing from within a shadow root.

I mean this in the sense that I wouldn't usually expect importing a web component to modify my document scope styles. I expect the component's styles to be encapsulated and not affect the outside. In this case, I imported <sl-dialog> into one of my component's shadow roots, and I ended up getting styles for <sl-toast> injected into my document's scope (and I'm not even using toast currently).

Also, this is "viral", in the sense if that if distribute a component that internally uses shoelace components, my end users are now going to have this unnecessary <style> tag in their document scope too.

Thanks for the info about toast -- that's interesting! Is the reason you hoist toasts to the document scope to work around any z-index issues, where a child stacking context might prevent the toast from being on the top layer? I've been wondering about patterns for this myself recently too. A downside of hoisting (apart from breaking encapsulation) is that you lose any custom property values or ::part styles that the user might have applied to their toast, unless they also defined those values in the document scope, right?

@claviska
Copy link
Member

Also, this is "viral", in the sense if that if distribute a component that internally uses shoelace components, my end users are now going to have this unnecessary <style> tag in their document scope too.

I agree, but I'm not aware of a better way. Perhaps the scroll utility could add the styles on lock and remove them on unlock. They still need to apply to the body, though.

Aside: I use an sl- prefixed class here instead of document.body.style because you can't control when other things change the style, but the likeliness of other things toggling a "namespaced" class is very low.

Is the reason you hoist toasts to the document scope to work around any z-index issues, where a child stacking context might prevent the toast from being on the top layer?

It's so you can more easily position the stack and not worry about heavy positioning algorithms and z-index conflicts (since alerts can appear anywhere in the DOM).

CleanShot 2021-10-18 at 17 13 51@2x

It's worth noting that, when you call toast(), the toast stack is added to the DOM automatically and removed after all toasts finish displaying.

There could be an <sl-toast-stack> requirement that includes these styles, but I chose not to do that because:

  • More upfront config makes it harder to use
  • Most users only want to configure its position (e.g. top right, top left)
  • This way users don't have to worry about maintaining their own toast stack
  • I did not want to support multiple stacks (it's awful UX)

@claviska
Copy link
Member

A downside of hoisting (apart from breaking encapsulation) is that you lose any custom property values that the user might have applied to their toast, unless they also defined those values in the document scope, right?

This is true, which is why the docs call out that the alert will be hoisted when they call toast(). I don't know of a reliable way to position multiple elements in the DOM in such a way without moving them. You could used a fixed positioning strategy, but elements in different containing blocks might be offset if they use a transform. That's way too heavy to calculate at runtime, so until the platform gives us something more versatile, hoisting is probably the way to go. 😕

@aomarks
Copy link
Author

aomarks commented Oct 22, 2021

I don't know of a reliable way to position multiple elements in the DOM in such a way without moving them.

Yeah, this makes sense, thanks for all the context! Hopefully whatwg/html#6349 will solve this properly eventually.

I agree, but I'm not aware of a better way. Perhaps the scroll utility could add the styles on lock and remove them on unlock. They still need to apply to the body, though.

Maybe an improvement could be to hoist to a shadow root in the body, instead of into the light dom of the body? E.g. there could actually be a <sl-toast-stack> component like you mentioned, except that you could automatically create it and append it to body like already happens with the <div> so that the user doesn't need to declare it themselves. It could be created the first time any toast needs to display. Seems like that would have the same DX, but with some advantages:

  • No <style> tag injection needed. Can use :host style rules on the stack component instead.
  • User document scope styles couldn't inadvertently affect the stack. Right now the stack is a <div class="sl-toast-stack"> in <body>, so a user style rule like div { position: static !important; } would break the stack layout (contrived example I know, but people do weird stuff, and maybe there are better examples).
  • Slightly smaller bundles when not using toast. The toast module could depend on the toast-stack module, so its CSS and append logic wouldn't need to be shipped unless toast was actually imported.

@fabricedesre
Copy link

Another issue with this inline styles is that they are preventing Shoelace to be used in contexts where a CSP preventing inline styles is in place. That may not be common, but it's a blocker for me :)

@claviska
Copy link
Member

If your CSP blocks inline styles then it will block both <style> tags and style=“” attributes. And if that’s the case, neither a class toggle nor a style attribute on the body will work and every component library ever made that uses such a pattern will break.

For example, it’s extremely common to apply something like <body style="overflow: none;"> when a dialog is shown. Body-level utilities are still a necessary evil at this moment in time, and the library can’t provide them without some unencapsulated styles.

@fabricedesre
Copy link

Why can't something like <body style="overflow: none;"> be achieved by setting a class on the body instead?

I'm checking if I can use the sha256-XXX CSP directives to allow the inline styles but no luck so far.

@claviska
Copy link
Member

Because the class has to reference a style that the library provides. Either we do:

<style>
  .scroll-lock {
    overflow: hidden;
  }
</style>

…and toggle the class on the body or we set its style=“” directly. It doesn’t make sense to ask every user to copy an arbitrary collection of styles just to get basic component functionality to work.

This problem isn’t exclusive to Shoelace. Bootstrap, for example, will also break.

@fabricedesre
Copy link

I don't understand why the .scroll-lock style could not be part of a regular CSS file for these kind of styles, and toggling the class on body. Users would only have to load this CSS file, like they do already for themes.

@claviska
Copy link
Member

They used to be, but folks seemed uncomfortable with global styles so utility classes were removed in beta.48. What was previously known as the “base” stylesheet no longer exists.

Quite frankly, it was easier to put them in a base stylesheet but that also meant every single theme must implement them.

One advantage to the current approach is that utilities just work. Another is that keeping them up to date is no longer the responsibility of theme developers.

@fabricedesre
Copy link

I see, thanks for the explanation!

@9876691
Copy link

9876691 commented Jan 22, 2022

This caught me out as well when trying to tighten up my CSP.

So currently I have to open up the CSP for Shoelace.

default-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' data:

It's the unsafe-inline which is the issue and is considered a security risk, which means it will get flagged in a Pen Test.

@claviska
Copy link
Member

Again, if your CSP restricts inline styles it will block both <style> and <div style="">, so the only viable option is to use classes and provide the utility styles in a separate stylesheet. We can probably import utility styles from the light and dark themes to work around that, but there are other instances of style="" inside components that the library uses.

For example, the image comparer uses an inline style to set a calculated clip-path property. QR code uses one to set the code's width/height. Color picker uses them in a number of places to set the position of handles, colors, and such. Sure, we could work around this by juggling a bunch of custom properties, but it's silly to me that a CSP would block a custom element from rendering any type of styles in its own shadow root.

If anything, a CSP should limit specific properties and/or linked resources. Worried about malicious tracking images? Prevent those instead of blocking all valid uses of inline styles. Worried about malicious behaviors? Block the behavior property. I'm not sure if that's even possible to do with CSPs, but IMO killing a ton of useful functionality is rather heavy-handed.

I'm willing to move the utility styles back into a stylesheet, but removing all instances of style from component logic adds a layer of complexity I'd prefer not to introduce. Is there any way to relax certain aspects of the CSP without disabling it entirely?

@fabricedesre
Copy link

I think that even with the CSP blocking instances of style="" inside components, you can replace that by JS code using the style properties (like elem.style.width = "40px") instead.

@claviska
Copy link
Member

It's less about how to work around it and more about why is there a need to work around it? IMO a blanket policy against all inline styles is too restrictive. It makes it harder to author components and harder for contributors because something very common and expected is now suddently an antipattern. 😕

CSPs should (if they don't already) allow more granular settings so as to only block properties and resources that are potentially harmful. That seems more logical than removing an important and useful piece of the platform that we've relied on for decades.

@claviska
Copy link
Member

claviska commented Jan 22, 2022

Possibly some good news here. @fabricedesre is right in that setting the style property is allowed (despite it being reflected, which is strange to me). While I still have reservations about the heavy-handedness of this CSP rule, I don't think it affects inline styles in Shoelace templates because they're rendered by Lit.

For example, the styleMap directive applies styles this way. So at least for component templates, this appears to be a non-issue.

If that's the case, I'm happy to move the injected utility styles into the light/dark themes to work around it. Does everyone feel that this is a reasonable resolution? If so, I'll make the change before the next release.

@9876691
Copy link

9876691 commented Jan 24, 2022

It's less about how to work around it and more about why is there a need to work around it? IMO a blanket policy against all inline styles is too restrictive. It makes it harder to author components and harder for contributors because something very common and expected is now suddently an antipattern. 😕

You're right, every web component tutorial I have seen so far uses this pattern.

Possibly some good news here. @fabricedesre is right in that setting the style property is allowed (despite it being reflected, which is strange to me).

If I set my CSP to

default-src 'none'; style-src 'self'; script-src 'self'

Then my whole site is immune to both XSS and CSS injection. The style property could not be set by reflected code as the JavaScript would not be unsafe-inline.

This is then a really nice defence in depth for the whole site as if I forget to sanitize even one input I am still protected.

If that's the case, I'm happy to move the injected utility styles into the light/dark themes to work around it. Does everything feel that this is a reasonable resolution? If so, I'll make the change before the next release.

If you have an easy fix for this, that would be amazing and well appreciated.

@claviska
Copy link
Member

I've updated the way theme files are maintained so they're raw CSS instead of Lit stylesheets. This was necessary because the magic that was producing the raw CSS before didn't allow for interpolation. To do this properly would have required a lot more complexity than I care to add, so instead the Lit styles are generated from the raw CSS now.

With that problem solved, moving the utility styles into the theme stylesheets and scrapping the <style> injection was the easy part. Shoelace will play nice with CSPs now and it won't inject anything unexpected into the document.

@claviska
Copy link
Member

Forgot to mention: this will be available in the upcoming 2.0.0-beta.65 release.

@claviska
Copy link
Member

I'm posting this here for prosperity. This was an alternate approach for the make-css.js script that kept themes as *.styles.ts files and generated *.css files from those modules.

I wasn't able to import the styles into Node because Lit requires a browser. I wasn't able to mock the necessary browser APIs using JSDOM for whatever reason. I ended using an Express app + Puppeteer to resolve the modules and get the raw CSS in a nasty way:

import chalk from 'chalk';
import express from 'express';
import fs from 'fs';
import puppeteer from 'puppeteer';
import prettier from 'prettier';
import stripComments from 'strip-css-comments';

const app = express();
const port = 8088;

console.log('Generating stylesheets');

// Serve static files from the dist directory
app.use(express.static('./dist'));

// Create a fake endpoint to import and inject theme styles
app.get('/', function (req, res) {
  res.set('Content-Type', 'text/html');

  const html = `
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"></head>
<body>
  <script type="module">
    import light from 'http://localhost:${port}/themes/light.styles.js';
    import dark from 'http://localhost:${port}/themes/dark.styles.js';

    function appendStyles(id, styles) {
      const style = document.createElement('style');
      style.id = id;
      style.textContent = styles;
      document.body.append(style);
    }

    appendStyles('light', light.cssText);
    appendStyles('dark', dark.cssText);
  </script>
</body>
</html>
`;

  res.send(Buffer.from(html));
});

const server = app.listen(port, () => {
  console.log(`Stylesheet server is listening on port ${port}`);
});

// Launch Puppeteer targeting the / endpoint so we can scrape themes from it
(async () => {
  try {
    const browser = await puppeteer.launch();
    const page = await browser.newPage();
    await page.goto(`http://localhost:${port}/`, {
      waitUntil: 'domcontentloaded'
    });

    // Stylesheets are injected into style containers with #light and #dark ids
    const stylesheets = await page.evaluate(() => ({
      light: document.getElementById('light').textContent,
      dark: document.getElementById('dark').textContent
    }));

    // Write the raw CSS files
    Object.keys(stylesheets).forEach(key => {
      const css = stylesheets[key];
      const formattedStyles = prettier.format(stripComments(css), { parser: 'css' });
      fs.writeFileSync(`./dist/themes/${key}.css`, formattedStyles, 'utf8');
    });

    await browser.close();
    server.close();
  } catch (err) {
    console.error(chalk.red('Error generating stylesheets!'));
    console.error(err);
  }
})();

This approach works, but it's slow and heavy, which is why I reverted themes to be written in raw CSS. It's easier to generate the Lit-friendly modules than do it the other way around. 🤦🏻‍♂️

Are CSS modules a thing yet? 😅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question General questions about the project.
Projects
None yet
Development

No branches or pull requests

4 participants