Authorize the theme bootstrap with a CSP nonce

6dd43f51992a · Christopher David · · parent 0602a1f2a926

Authorize the theme bootstrap with a CSP nonce

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified AGENTS.md
  • modified INVARIANTS.md
  • modified docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md
  • modified docs/component-library.md
  • modified lib/openagents_web/components/layouts/root.html.heex
  • added lib/openagents_web/plugs/content_security_policy.ex
  • modified lib/openagents_web/router.ex
  • modified test/openagents_web/home_controller_test.exs

Diff

8 files changed, +85 -8

AGENTS.md modified +1 -1

@@ -52,7 +52,7 @@ custom classes must fully style the input

52 52
- Out of the box **only the app.js and app.css bundles are supported**
53 53
  - You cannot reference an external vendor'd script `src` or link `href` in the layouts
54 54
  - You must import the vendor deps into app.js and app.css to use them
55
  - **Never write inline `<script>` tags within templates.** The only exception is the synchronous, content-free theme bootstrap in `root.html.heex`, which must run before the first stylesheet paint. Put every other client behavior in `app.js` or a colocated LiveView hook
55
  - **Never write inline `<script>` tags within templates.** The only exception is the synchronous, content-free theme bootstrap in `root.html.heex`, which must run before the first stylesheet paint and carry the response-scoped CSP nonce. Put every other client behavior in `app.js` or a colocated LiveView hook
56 56
57 57
### UI and UX design guidelines
58 58
INVARIANTS.md modified +6 -4

@@ -1523,16 +1523,18 @@ Adopting an additional Basecoat component requires a

1523 1523
`docs/component-library.md` change and an explicit per-component import. The
1524 1524
application exposes one system, light, and dark preference control. A
1525 1525
synchronous, content-free bootstrap applies the stored choice before first
1526
paint and synchronizes changes across tabs. Apps SDK UI glyphs are preferred;
1527
the pinned Heroicons fallback has an explicit inventory and no current product
1528
call sites.
1526
paint and synchronizes changes across tabs. A unique response-scoped CSP nonce
1527
admits only that bootstrap; `script-src` does not allow arbitrary inline code.
1528
Apps SDK UI glyphs are preferred; the pinned Heroicons fallback has an explicit
1529
inventory and no current product call sites.
1529 1530
1530 1531
Evidence: `assets/vendor/basecoat/README.md`, `assets/css/openagents.css`,
1531 1532
`priv/static/fonts`, `OpenAgentsWeb.UI`, `OpenAgentsWeb.ComponentCatalog`,
1532 1533
`OpenAgentsWeb.UITest`, `OpenAgentsWeb.UIGalleryLiveTest`,
1533 1534
`test/openagents_web/component_catalog_test.exs`,
1534 1535
`test/openagents_web/icon_affordances_test.exs`,
1535
`test/openagents_web/live/components_live_test.exs`, and
1536
`test/openagents_web/live/components_live_test.exs`,
1537
`test/openagents_web/home_controller_test.exs`, and
1536 1538
`assets/test/css_contract_test.mjs`.
1537 1539
1538 1540
### LEADERBOARD-001 — The public board publishes one bounded projection
docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md modified +3

@@ -448,6 +448,9 @@ records.

448 448
  palette. The documentation check now refuses the superseded dark-only claims
449 449
  in active contracts, and the component test enumerates the system, light, and
450 450
  dark controls without treating the system preference as a third palette.
451
- Bound the synchronous prepaint theme bootstrap to a unique response-scoped
452
  CSP nonce. The browser policy test proves that the header and script nonce
453
  match, changes between responses, and never enables arbitrary inline scripts.
451 454
- Migrated current glyph uses to the preferred vendored Apps SDK set. Retained
452 455
  Heroicons only as the owner-approved second tier, pinned it to immutable
453 456
  revision `0435d4ca364a608cc75e2f8683d374e55abbae26`, and recorded an empty
docs/component-library.md modified +4

@@ -76,6 +76,10 @@ OpenAgents style pack, every supported button variant survives compilation, no

76 76
retired palette alias survives, both owned themes compile, the operating-system
77 77
fallback compiles, and no third theme selector enters the bundle.
78 78
79
The root theme bootstrap is the only inline script. The browser pipeline creates
80
a unique CSP nonce for each response, places it in `script-src`, and binds it to
81
that bootstrap. Do not admit another inline script with the nonce.
82
79 83
See [the UI roadmap](issues-projects-ui-roadmap.md),
80 84
[ADR 0005](decisions/0005-use-basecoat-and-one-component-system.md), and the
81 85
[hardening plan](2026-08-20-integration-hardening-and-staging-readiness-recommendations.md).
lib/openagents_web/components/layouts/root.html.heex modified +1 -1

@@ -15,7 +15,7 @@

15 15
    "system" stores nothing and leaves data-theme unset so the CSS
16 16
    prefers-color-scheme fallback governs; an explicit choice sets the
17 17
    attribute, which always wins. --%>
18
    <script phx-no-curly-interpolation>
18
    <script nonce={assigns[:csp_nonce]} phx-no-curly-interpolation>
19 19
      (() => {
20 20
        const stored = localStorage.getItem("phx:theme");
21 21
        const apply = (choice) => {
lib/openagents_web/plugs/content_security_policy.ex added +40

@@ -0,0 +1,40 @@

1
defmodule OpenAgentsWeb.Plugs.ContentSecurityPolicy do
2
  @moduledoc """
3
  Adds the browser content security policy and one response-scoped script nonce.
4
5
  The root layout uses the nonce only for the synchronous theme bootstrap. All
6
  other JavaScript remains in the same-origin application bundle.
7
  """
8
9
  import Plug.Conn
10
11
  @behaviour Plug
12
13
  @impl true
14
  def init(opts), do: opts
15
16
  @impl true
17
  def call(conn, _opts) do
18
    nonce = :crypto.strong_rand_bytes(18) |> Base.url_encode64(padding: false)
19
20
    conn
21
    |> assign(:csp_nonce, nonce)
22
    |> put_resp_header("content-security-policy", policy(nonce))
23
  end
24
25
  defp policy(nonce) do
26
    Enum.join(
27
      [
28
        "default-src 'self'",
29
        "base-uri 'self'",
30
        "connect-src 'self' ws: wss:",
31
        "frame-ancestors 'none'",
32
        "img-src 'self' data: https://avatars.githubusercontent.com",
33
        "object-src 'none'",
34
        "script-src 'self' 'nonce-#{nonce}'",
35
        "style-src 'self' 'unsafe-inline'"
36
      ],
37
      "; "
38
    )
39
  end
40
end
lib/openagents_web/router.ex modified +2 -2

@@ -14,11 +14,11 @@ defmodule OpenAgentsWeb.Router do

14 14
    plug :protect_from_forgery
15 15
16 16
    plug :put_secure_browser_headers, %{
17
      "content-security-policy" =>
18
        "default-src 'self'; base-uri 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; img-src 'self' data: https://avatars.githubusercontent.com; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'",
19 17
      "permissions-policy" => "microphone=(self)"
20 18
    }
21 19
20
    plug OpenAgentsWeb.Plugs.ContentSecurityPolicy
21
22 22
    plug :fetch_current_user
23 23
  end
24 24
test/openagents_web/home_controller_test.exs modified +28

@@ -36,6 +36,34 @@ defmodule OpenAgentsWeb.HomeControllerTest do

36 36
    refute policy =~ ~r/img-src[^;]*\shttps:(?:\s|;)/
37 37
  end
38 38
39
  test "the browser policy admits only the response-scoped theme bootstrap", %{conn: conn} do
40
    first = get(conn, ~p"/")
41
    second = conn |> recycle() |> get(~p"/")
42
43
    [first_policy] = get_resp_header(first, "content-security-policy")
44
    [second_policy] = get_resp_header(second, "content-security-policy")
45
46
    [first_nonce] =
47
      Regex.run(~r/script-src 'self' 'nonce-([A-Za-z0-9_-]{24})'/, first_policy,
48
        capture: :all_but_first
49
      )
50
51
    [second_nonce] =
52
      Regex.run(~r/script-src 'self' 'nonce-([A-Za-z0-9_-]{24})'/, second_policy,
53
        capture: :all_but_first
54
      )
55
56
    refute first_nonce == second_nonce
57
    refute first_policy =~ ~r/script-src[^;]*'unsafe-inline'/
58
59
    document = first |> html_response(200) |> LazyHTML.from_document()
60
61
    assert document
62
           |> LazyHTML.query(~s{script[nonce="#{first_nonce}"]})
63
           |> LazyHTML.to_tree()
64
           |> length() == 1
65
  end
66
39 67
  test "the landing styles do not use Sarah's cycling verb" do
40 68
    css = File.read!("assets/css/app.css")
41 69

This page updates live while a promote is in flight · changelog