Blog · 25 August 2026 · 8 min read

One inline <style> tag
broke our entire app.

A few lines of CSS added to stop a white flash at launch silently converted our Content Security Policy into one that blocked every stylesheet and inline style the app injected at runtime. Our browser test suite was structurally incapable of noticing; a customer reported it before we did.

The Ampersand window is transparent over a native macOS material, so for the first few frames after launch there is nothing painted in it. To stop that reading as a white flash we added a few lines of CSS to the app’s HTML shell — inlined, so it applies before anything else loads.

That release shipped. Some days later a customer sent a screenshot of the editor’s source pane: the line-number gutter stacked in a column above the text instead of beside it, the wrong typeface, no padding, a raw system focus ring, and not one syntax colour. In the same message, a diagram that should have been off-white was rendering solid black.

Nothing in our test suite had said a word — not in development, not against the production bundle, not in either browser engine we test. Here is the mechanism, because it is completely obvious afterwards and close to invisible before.

violations on a single note open
122violations on a single note opensame production bundle run twice; the header was the only difference
tests that failed
0tests that faileddevelopment and production bundles × WebKit and Chromium
customer who noticed first
1customer who noticed firsta screenshot, sent unprompted

Two facts, each fine on its own

The first. Tauri hardens the Content Security Policy of the assets your app ships. At build time it walks the HTML it is about to serve and stamps a placeholder nonce attribute onto every <style> element it finds; then, as it serves each asset, it swaps in a freshly generated value and appends a matching 'nonce-<random>' source to the style-src directive. As a default that is good: the only inline stylesheets that run are the ones already in your bundle.

Note the scope precisely. It stamps <style> elements. It never touches style="…" attributes — it cannot, because there is nothing there to stamp.

The second. In CSP Level 2 and later, if a directive contains a nonce or a hash source, then 'unsafe-inline' is ignored. Not merged, not overridden — ignored, as though you had never written it. That is deliberate and in the specification: a nonce is the more precise statement of intent, so the blanket permission is discarded in its favour.

Both facts are reasonable. Put together, on a policy that was relying on 'unsafe-inline', they are a trapdoor.

style-src, three ways
# what we authored
style-src 'self' 'unsafe-inline'

# what the build produced, once a single inline <style>
# element existed for it to stamp a nonce onto
style-src 'self' 'unsafe-inline' 'nonce-KpQ1z8vR...'

# what the browser actually enforced, because a nonce in a
# directive makes 'unsafe-inline' ignored
style-src 'self' 'nonce-KpQ1z8vR...'
Only the last line ever ran. Nobody wrote it — it is what the first line becomes once a nonce exists to be appended, and the permission that disappears in the transformation is the one the app’s entire runtime styling rested on.

One tag, and the policy inverts

Before the change there was no <style> element anywhere in the shell, so the build had nothing to stamp, appended no nonce, and the policy shipped as written — inline styles permitted. Adding the anti-flash block gave it something to stamp. A nonce appeared in style-src. And 'unsafe-inline' — the permission the app’s entire runtime styling rested on — quietly stopped counting.

Only the elements the build stamped ever carry the nonce. Anything that injects CSS after the page is running has no way to know the value — it is generated as the asset is served, and there is nowhere to read it from. So every one of those injections was refused.

What that meant in practice

The most visible casualty was the editor’s source pane. CodeMirror does not ship a stylesheet you link; it installs its theme by inserting a <style> element when the view is created — in the running app, with no nonce. The browser declined all of it.

Injected at runtime. Refused.
/* The editor's source pane is CodeMirror, which ships its
   theme by inserting a <style> element when the view is
   created -- long after the build, with no nonce to carry.
   None of this applied. */

.cm-scroller {
  display: flex;            /* gutter beside the text.
                               without it: above it. */
  font-family: var(--font-mono);
  padding: 56px;
}

.cm-content:focus {
  outline: none;            /* without it: the raw OS accent
                               ring, around everything. */
}
One declaration decides whether a code editor is a code editor. With it, the line-number gutter sits beside the text; without it, in a column above it — which is the screenshot we were sent.

Losing display: flex on one element turned a code editor into a column of line numbers sitting on top of a column of text.

That was the part we could see, and it was not the extent of it. Running the same production bundle twice — once under the broken policy, once under the fixed one, with the header as the only difference — reported violations of style-src-elem and style-src-attr.

Violation report, same bundle
# the same production bundle, run twice.
# the only difference between the runs is the header.

securitypolicyviolation   style-src-elem   inline   blocked
securitypolicyviolation   style-src-attr   inline   blocked
securitypolicyviolation   style-src-attr   inline   blocked
...

122 events. one note opened.
We went looking for the broken stylesheet and found a second directive underneath it. style-src-elem cost us the source pane; style-src-attr cost us every inline style attribute in the application.

style-src-attr is the one that matters. It means el.setAttribute('style', …) stopped applying. Not a stylesheet somewhere — every inline style attribute in the app. That is Framer Motion’s transforms and opacity, Radix’s popover, dropdown and dialog positioning, the editor zoom level, the split-pane ratio, image width and alignment, and table column widths. All of it silently inert.

Mermaid was collateral of the same kind: it ships its theme as a <style> element inside the SVG it generates, so diagrams rendered with an unthemed fill — rgb(0, 0, 0) where the palette calls for rgb(244, 242, 238). That was the black diagram in the screenshot.

Tailwind’s class-driven styling was unaffected — those classes live in a stylesheet that was in the bundle at build time. That is why the shell — the sidebar, the tab strip, the general shape of the app — still looked broadly correct, and why the failure read as “some things are broken” rather than “the security policy is wrong”. Had the app rendered as unstyled HTML we would have found the cause in four minutes.

Who gets a nonce · schematic
<style> in the shipped HTMLthe anti-flash rules we had just added
stamped with a nonce at build timeallowed
<style> inserted while runningCodeMirror’s theme · Mermaid’s theme, inside the SVG it generates
did not exist yet; nothing to stampblocked
style="…" set by scriptFramer Motion · Radix positioning · editor zoom · image width · table columns
cannot be stamped — there is no element to markblocked
class="…" in a bundled stylesheetTailwind — the sidebar, the tab strip, the general shape
not an inline style at allunaffected
The build can only stamp what exists at build time, so exactly one of these four rows was ever going to carry a nonce. The bottom row is why the app still looked broadly right — and therefore why this read as “some things are broken” rather than “the security policy is wrong”.

Why every test we had said the app was fine

We have a browser-based end-to-end suite that drives the real frontend. It passed — against the development server and against the production bundle, in WebKit and in Chromium, on a pane that was thoroughly broken in the shipped app.

It passed because it serves the app over plain HTTP with no Tauri layer in front of it, and therefore with no Content Security Policy applied at all. The suite was not wrong about what it measured; it was measuring an environment in which the bug cannot exist. A test environment that cannot reproduce production’s security headers cannot test production’s behaviour, and it will report perfect health while doing it.

The obvious repair — set a policy in development too — does not work here either.

A development-mode CSP setting would have been inert

Tauri attaches the CSP header only to assets it serves itself. Our app points at an external dev server, so in development the dev server serves the document and no policy is applied at all. A development CSP entry would sit in the config doing nothing while looking exactly like coverage — the worse failure mode: not an absent test, but a test-shaped object that always passes.

The fix, which is not a relaxation

Tauri lets you opt out of its nonce injection through a setting named dangerousDisableAssetCspModification. It accepts true, or an array of directive names. The array form is the entire fix.

The opt-out, scoped to one directive
// what we ship. the array is the entire fix.
"security": {
  "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; ...",
  "dangerousDisableAssetCspModification": ["style-src"]
}

// what we deliberately did not do. `true` opts every
// directive out of nonce injection -- script-src included,
// which is the one place it genuinely earns its keep.
"dangerousDisableAssetCspModification": true
An array of one, and the difference between the two forms is the whole security argument: naming a directive stops a nonce being appended to that directive, while true would surrender script-src — the place the hardening genuinely earns its keep.

The name reads like a warning, and for the true form it is one: that opts every directive out, script-src included. Naming a single directive is much smaller — it neither removes style-src nor adds a source to it. It stops a nonce being appended to that one directive, so the policy we wrote is the policy that ships.

script-src keeps the nonce hardening untouched, and that asymmetry is the whole argument. Script injection is where cross-site scripting lives, and we inject no scripts at runtime, so nonce-gating scripts costs us nothing. Inline styles are a different risk class, and the alternative — removing every runtime style injection and every inline style attribute across the app — means giving up the animation library, the code editor and the diagram renderer. That is not a security posture, it is a rewrite.

A test now guards it: if the HTML shell contains an inline <style>, the configuration must opt out.

The fix that actually mattered was a test

Correcting the configuration fixed the bug. It did nothing about the reason we shipped it: no test we owned could see the artifact we ship.

So there is now a separate end-to-end suite that builds the production bundle, previews it, reconstructs the exact policy the packaged app really enforces — nonce injection included — and fails the run on any securitypolicyviolation event.

Listening for the event is what makes it general. We could have asserted that the source pane’s scroller is a flex container; that assertion would have caught this bug and nothing else ever again. The violation event catches a blocked stylesheet, but equally a new domain missing from connect-src, a font, an image, a worker, an iframe — the entire “worked in development, blocked in the shipped app” class, including the ones we have not thought of.

Its very first run found a second bug that had been shipping quietly: connect-src was missing data:, and our PDF export renderer fetches its layout engine as an inlined data: WASM URI. The packaged app had been refusing it every time the export window opened. The impact was benign — the renderer falls back and the preview paints identically — but it logged an error on every open, and that is noise that would have masked a real violation. The suite paid for itself before it finished its first run.

What we took from it

  • A nonce in a CSP directive silently disables 'unsafe-inline'. Adding a security mechanism can subtract permissions you believed you had — with no error, no warning, and no build failure.
  • A test environment that cannot reproduce production’s security headers cannot test production’s behaviour. Ours reported health with total confidence across two engines and two build modes, on a bug every customer already had.
  • Assert on the artifact you ship, not on the thing that is convenient to run. The packaged application is the product; anything else is a model of it, and the gap between them is where this class of bug lives.
  • Prefer the browser’s own violation event to an assertion about a symptom. The symptom is whatever happened to break this time. The event is the category.

The last thing worth recording is the one that stings: a customer found this before we did — their screenshot was a better test than everything we had, because it was taken of the real app. There is now a suite that takes that screenshot for us, against a production build, under the policy the packaged app really ships.

The security overview and the technical specifications cover the rest of how the app is put together.

Get started

Built carefully.
Fixed in public.

Ampersand is a native markdown editor that keeps your notes as plain files on your own computer. Try it, and hold us to this standard.

Free to start · Mac, Windows & Linux · No account required