Skip to content

luci-base: add nonce-based CSP support to replace unsafe-inline#8788

Open
maxskokov wants to merge 5 commits into
openwrt:masterfrom
maxskokov:feat-csp-nonce
Open

luci-base: add nonce-based CSP support to replace unsafe-inline#8788
maxskokov wants to merge 5 commits into
openwrt:masterfrom
maxskokov:feat-csp-nonce

Conversation

@maxskokov

@maxskokov maxskokov commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Pull request details

Description

The CSP plugin (luci-plugin-csp) used 'unsafe-inline' in script-src
because there was no mechanism to pass a per-request nonce to http header
plugins. This adds that mechanism across the stack with minimal changes.

dispatcher.uc: generate a per-request nonce via randomid(16) and store
it in ctx.csp_nonce (available in templates via runtime.env) and
http.csp_nonce (available in write_headers()).

luciplugins.uc: extend run_plugins() with an optional extra_ctx arg
forwarded to each plugin — backward compatible, existing plugins ignore it.

http.uc: pass { csp_nonce: this.csp_nonce } into run_plugins() from
write_headers().

Core .ut templates and all themes: add nonce="{{ ctx?.csp_nonce }}" to
inline <script> tags.

luci-plugin-csp: use 'nonce-${nonce}' instead of 'unsafe-inline' when
a nonce is available, falling back to 'unsafe-inline' for backwards
compatibility.

Follows up on the discussion in #8785.


Follow-up: inline event handler migration

Addressed the reviewer note that CSP2+ browsers ignore 'unsafe-inline' in
script-src once a nonce is present, meaning inline event attributes
(onclick, onsubmit, onreset, onkeyup, onchange) and javascript:
URIs are blocked even when <script> elements are nonce-gated.

luci-base / cbi.js: extended cbi_init() to wire up all affected
handler patterns declaratively via data-* attributes:
[data-href], [data-cbi-submit], [data-cbi-state], [data-cbi-rowswap],
.cbi-section-create-name (keyup), .cbi-cidr-toggle (click), and
form[name="cbi"] submit/reset validation (error message read from
data-error-msg). Also moved switchToCIDRList() from ipaddr.htm into
cbi.js.

luci-lua-runtime / template.lua: exposed csp_nonce as a named key
in the Lua template viewns (same pattern as token and REQUEST_URI),
making it accessible as a plain variable in legacy CBI .htm templates.

luci-compat: removed all inline event attributes from header.htm,
footer.htm, simpleform.htm, tblsection.htm, tsection.htm,
ipaddr.htm, and wireless_modefreq.htm. Replaced with data-* attributes
handled by cbi_init(). Added nonce="<%=csp_nonce%>" to all inline
<script> blocks.

luci-app-commands: added nonce="{{ ctx?.csp_nonce }}" to the inline
<script> block; replaced onclick on Run/Download/Link buttons with
data-action/data-cmd attributes dispatched via a DOMContentLoaded
listener inside the nonce'd script.


Maintainer (preferred)

@jow-


Tested on

Verified by code inspection.


Checklist

  • This PR is not from my main or master branch 💩, but a separate branch. ✅
  • Each commit has a valid ✒️ Signed-off-by: <my@email.address> row (via git commit --signoff).
  • Each commit and PR title has a valid 📝 <package name>: title first line subject for packages.
  • (Optional) Includes what Issue it closes — follows up on Discussion: nonce-based CSP to replace unsafe-inline in luci-plugin-csp #8785.

The CSP plugin previously used 'unsafe-inline' in script-src because
there was no mechanism to pass a per-request nonce to http header
plugins. This commit adds that mechanism across the stack:

- dispatcher.uc: generate a per-request nonce via randomid(16) and
  store it in both ctx.csp_nonce (for templates) and http.csp_nonce
  (for http header plugins)
- luciplugins.uc: extend run_plugins() with an optional extra_ctx arg,
  passed to each plugin — backward compatible, existing plugins ignore
  extra args
- http.uc: forward { csp_nonce } to run_plugins() from write_headers()
- core .ut templates and all themes: add nonce attribute to inline
  <script> tags using ctx?.csp_nonce
- luci-plugin-csp: use 'nonce-' instead of 'unsafe-inline'
  when a nonce is available, falling back to 'unsafe-inline' otherwise

Signed-off-by: Maxim Skokov <skokov.m020709@gmail.com>

@openwrt-ai openwrt-ai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One inline note on the CSP script-src change. The <script>-tag nonce plumbing itself is coherent (nonce is generated in dispatch() before templates render and before write_headers() emits the header, and the header's 'nonce-<hex>' matches the nonce="<hex>" attribute value), and the single commit's message accurately describes the diff.


Generated by Claude Code

const uci = cursor();
const mode = uci.get('luci_plugins', plugin_id, 'mode') || 'strict';

const nonce = extra_ctx?.csp_nonce ? `'nonce-${extra_ctx.csp_nonce}'` : "'unsafe-inline'";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once a nonce is present in script-src, CSP2+ browsers ignore 'unsafe-inline' in that directive — and here 'unsafe-inline' is dropped from script-src entirely when a nonce is available. Nonces only whitelist <script> elements; they do not cover inline event-handler attributes (onclick=, onchange=, onsubmit=, onkeyup=, …) or javascript: URIs, which previously worked solely because of 'unsafe-inline'. Those will now be blocked whenever the plugin runs in strict or permissive mode.

Inline handlers are still used in-tree, e.g. core csrftoken.ut:21 (onclick="window.history.back();"), extensively across the luci-compat CBI templates (footer.htm, tblsection.htm, tsection.htm, simpleform.htm, header.htm, …), and in luci-app-commands/commands.ut:151. Their buttons/links would silently stop responding under the hardened policy.

The <script>-tag migration in this PR doesn't reach these attributes. Consider either migrating the remaining inline handlers to addEventListener in nonced scripts, or carrying 'unsafe-hashes' plus the corresponding hashes in the policy for handlers that must stay inline. At minimum this behavior change is worth calling out in the commit/PR description.


Generated by Claude Code

maxskokov added 4 commits July 4, 2026 01:04
Inline event handlers (onclick, onsubmit, onreset, onkeyup, onchange)
are blocked by CSP nonce policies because nonces only cover <script>
elements, not event attributes or javascript: URIs.

Extend cbi_init() to wire up the following patterns declaratively so
that templates can remove all inline handlers:

  [data-href]         -> location.href navigation
  [data-cbi-submit]   -> cbi_submit() for named form actions
  [data-cbi-state]    -> sets form.cbi_state before submit
  [data-cbi-rowswap]  -> cbi_row_swap() with data-cbi-sortstore
  .cbi-section-create-name -> keyup cbi_validate_named_section_add()
  .cbi-cidr-toggle    -> switchToCIDRList() click handler
  form[name="cbi"]    -> submit/reset validation via cbi_validate_form()
                         and cbi_validate_reset(), error message read
                         from data-error-msg attribute

Also move switchToCIDRList() from ipaddr.htm into cbi.js where it
belongs alongside the other CBI helpers.

Remove the remaining onclick from csrftoken.ut by adding a nonce'd
<script> block with an addEventListener call.

Signed-off-by: Maxim Skokov <skokov.m020709@gmail.com>
Add csp_nonce as a named key in the Lua template viewns, resolved
from disp.context.csp_nonce (which maps to the ucode-side ctx.csp_nonce
set by the dispatcher). This lets legacy Lua/CBI templates reference
the per-request nonce as a plain variable, consistent with how token
and REQUEST_URI are already exposed.

Required for adding nonce="<%=csp_nonce%>" to inline <script> blocks
in luci-compat CBI templates.

Signed-off-by: Maxim Skokov <skokov.m020709@gmail.com>
CSP nonce policies block inline event attributes (onclick, onsubmit,
onreset, onkeyup, onchange) even when <script> elements are nonce-gated.
Convert all such handlers in the CBI Lua templates to data-* attributes
that cbi_init() now handles declaratively:

  footer.htm:    onclick on Back/Skip/Apply/Reset -> data-href,
                 data-cbi-submit; nonce on <script>cbi_init()
  header.htm:    remove onsubmit/onreset from form, add data-error-msg
                 so cbi_init() can wire up cbi_validate_form()
  simpleform.htm: onclick on Back/Cancel/Skip -> data-href, data-cbi-submit;
                  nonce on <script>cbi_init()
  tblsection.htm: onclick on Up/Down/Edit/Delete/Add -> data-cbi-rowswap,
                  data-href, data-cbi-state; onkeyup on name input removed
                  (cbi_init() now binds .cbi-section-create-name)
  tsection.htm:   same pattern for Delete/Add
  ipaddr.htm:     remove inline <script> (switchToCIDRList moved to cbi.js),
                  add class cbi-cidr-toggle to the CIDR toggle button
  wireless_modefreq.htm: add nonce to both <script> blocks; remove onchange
                  from mode/band selects; wire them up in the post-init
                  nonce'd script that already calls cbi_init_wifi()

Signed-off-by: Maxim Skokov <skokov.m020709@gmail.com>
The inline <script> block lacked a nonce attribute, and the Run/Download/
Link buttons used onclick handlers — both blocked by CSP nonce policy.

Add nonce="{{ ctx?.csp_nonce }}" to the <script> block. Replace the
three onclick attributes with data-action and data-cmd attributes, then
register a single DOMContentLoaded listener inside the nonce'd script
that dispatches to command_run(), command_download() or command_link()
based on data-action.

Signed-off-by: Maxim Skokov <skokov.m020709@gmail.com>
@maxskokov

Copy link
Copy Markdown
Contributor Author

Migration scope note: this PR switches the CSP script-src policy to nonce-only whenever ctx.csp_nonce/http.csp_nonce is present (the plugin drops 'unsafe-inline' entirely once a nonce exists), and the inline-handler migration here covers luci-base, luci-compat, and luci-app-commands.

The rest of the tree isn't migrated yet, so enabling luci-plugin-csp in strict/permissive mode after this merges will silently break inline handlers/scripts on these pages (found by grepping for remaining onclick=/onchange=/un-nonced <script>):

  • applications/luci-app-lxc/htdocs/luci-static/resources/view/lxc/overview.js — Start/Stop/Delete/Configure buttons built via onclick='action_handler(this)' string concatenation
  • applications/luci-app-bmx7/ucode/template/bmx7/bmxnodes.ut — node info popup (onclick="displayExtraInfo(...)")
  • modules/luci-compat/luasrc/view/cbi/delegator.htm — bare <script>cbi_d_update();</script>, no nonce
  • modules/luci-mod-status/ucode/template/admin_status/index.ut — inline <script> blocks, no nonce

Flagging so we can decide whether to widen this PR's scope before merge, or split the rest off into follow-up PRs (same pattern as #8762 splitting off from #8749).

@openwrt-ai openwrt-ai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 4 new commits; no new issues found.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants