panel: move inline styles and confirmations out of the templates

Groundwork for the Content-Security-Policy of phase 14.A. A policy that has to
allow inline script is not worth writing — script-src 'unsafe-inline' gives
back exactly the XSS foothold the policy exists to remove — so the three
inline constructs the templates still had are moved out first:

  - the layout's <style> block becomes /static/panel.css;
  - the one style="background:#b42318" attribute becomes the .danger class
    that already existed for it;
  - the four onsubmit="return confirm(...)" handlers become data-confirm,
    handled by a delegated listener in panel.js. Delegation matters: the
    application rows are also delivered by HTMX swaps.

htmx would otherwise inject a <style> of its own for the request-indicator
classes and become the single reason the policy needs an exemption; the panel
uses no hx-indicator, so the meta config switches it off.

A guard test keeps this from silently regressing later, which it otherwise
would: an inline handler added to a template does not fail, it just quietly
stops working in the browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 23:01:46 +03:00
parent 7a09e62bf1
commit 14b4933917
6 changed files with 193 additions and 120 deletions
+119
View File
@@ -0,0 +1,119 @@
/* Panel stylesheet. It lives in a file rather than in a <style> block in the
layout so the panel's Content-Security-Policy can be a plain
"default-src 'self'" with no inline-style exemption (phase 14.A). Any rule
added here must therefore stay here: an inline style="..." attribute in a
template is blocked by that policy and silently does nothing. */
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
margin: 0; padding: 2rem 1rem; background: #f6f7f9; color: #1b1f24;
}
@media (prefers-color-scheme: dark) {
body { background: #14171a; color: #e6e8eb; }
.card { background: #1d2125 !important; border-color: #2b3138 !important; }
input { background: #14171a !important; color: inherit !important; border-color: #2b3138 !important; }
}
main { max-width: 42rem; margin: 0 auto; }
h1 { font-size: 1.4rem; margin: 0 0 1rem; }
.card {
background: #fff; border: 1px solid #e2e5e9; border-radius: 10px;
padding: 1.5rem; margin: 0 auto;
}
.card.narrow { max-width: 24rem; }
label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; }
input {
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
border: 1px solid #cfd4da; border-radius: 6px; background: #fff;
}
button {
margin-top: 1.2rem; padding: 0.6rem 1.1rem; font-size: 1rem; font-weight: 600;
color: #fff; background: #2563eb; border: 0; border-radius: 6px; cursor: pointer;
}
button:hover { background: #1d4ed8; }
.error { color: #b42318; margin: 0.6rem 0 0; font-weight: 600; }
.muted { color: #6b7280; }
.topbar { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.2rem; }
.topbar .actions { display: flex; gap: 0.9rem; align-items: baseline; }
form.inline { display: inline; margin: 0; }
form.inline button { background: none; color: #2563eb; padding: 0; margin: 0; font-weight: 600; }
form.inline button:hover { text-decoration: underline; background: none; }
main { max-width: 48rem; }
.card + .card { margin-top: 1.2rem; }
.flash { background: #ecfdf3; border: 1px solid #abefc6; color: #067647; padding: 0.7rem 1rem; border-radius: 8px; margin-bottom: 1.2rem; }
@media (prefers-color-scheme: dark) { .flash { background: #0d2818 !important; border-color: #1a5336 !important; color: #75d99b !important; } }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid #e2e5e9; }
@media (prefers-color-scheme: dark) { th, td { border-color: #2b3138 !important; } }
th { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; color: #6b7280; }
td.actions { text-align: right; }
a.danger { color: #b42318; }
.code { display: block; white-space: pre-wrap; word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85rem; background: #f0f2f4; border: 1px solid #e2e5e9; border-radius: 6px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; }
@media (prefers-color-scheme: dark) { .code { background: #14171a !important; border-color: #2b3138 !important; } }
h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
.back { display: inline-block; margin-bottom: 1rem; }
select, textarea {
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
border: 1px solid #cfd4da; border-radius: 6px; background: #fff; color: inherit;
font-family: inherit;
}
textarea { resize: vertical; }
@media (prefers-color-scheme: dark) {
select, textarea { background: #14171a !important; color: inherit !important; border-color: #2b3138 !important; }
}
button.danger { background: #b42318; }
button.danger:hover { background: #912018; }
form.inline button.danger { background: none; color: #b42318; }
form.inline button.danger:hover { background: none; }
td.actions form.inline, td.actions details { margin-left: 0.6rem; }
details summary { cursor: pointer; color: #2563eb; font-weight: 600; }
details form { margin-top: 0.6rem; }
.credential { border-color: #f5c518; background: #fffbeb; }
@media (prefers-color-scheme: dark) { .credential { background: #2a2408 !important; border-color: #6b5a10 !important; } }
/* Panel navigation: rendered once from the layout, so it is present on every
authenticated page without each content template having to include it. */
.nav {
display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline;
gap: 0.4rem 1rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
border-bottom: 1px solid #e2e5e9;
}
@media (prefers-color-scheme: dark) { .nav { border-color: #2b3138 !important; } }
.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: baseline; }
.nav .links a, .nav .links [aria-current] { padding: 0.2rem 0.5rem; border-radius: 6px; }
.nav .links [aria-current] {
font-weight: 600; color: #1b1f24; background: #e6ebf5; box-shadow: inset 0 -2px 0 #2563eb;
}
@media (prefers-color-scheme: dark) {
.nav .links [aria-current] { color: #e6e8eb !important; background: #22303f !important; }
}
/* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server
status page and the per-domain DNS checks, so a colour means the same thing
everywhere. The class suffix is the check's own status value. */
.st {
display: inline-block; padding: 0.05rem 0.45rem; border-radius: 999px;
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
vertical-align: middle; border: 1px solid transparent;
}
.st-ok { background: #ecfdf3; color: #067647; border-color: #abefc6; }
.st-warn { background: #fffaeb; color: #b54708; border-color: #fedf89; }
.st-error { background: #fef3f2; color: #b42318; border-color: #fecdca; }
.st-unknown { background: #f0f2f4; color: #6b7280; border-color: #e2e5e9; }
@media (prefers-color-scheme: dark) {
.st-ok { background: #0d2818 !important; color: #75d99b !important; border-color: #1a5336 !important; }
.st-warn { background: #2e2308 !important; color: #f5c86b !important; border-color: #6b5210 !important; }
.st-error { background: #2d1211 !important; color: #f5a29b !important; border-color: #6b201a !important; }
.st-unknown { background: #22262b !important; color: #9aa3ad !important; border-color: #2b3138 !important; }
}
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
.code-row .code { flex: 1; min-width: 0; }
button.copy {
flex: none; margin-top: 0.3rem; padding: 0.45rem 0.7rem; font-size: 0.8rem;
background: #eef1f5; color: #2563eb; border: 1px solid #cfd4da;
}
button.copy:hover { background: #e2e7ee; }
@media (prefers-color-scheme: dark) {
button.copy { background: #22262b !important; border-color: #2b3138 !important; }
button.copy:hover { background: #2b3138 !important; }
}
+14
View File
@@ -32,6 +32,20 @@
}); });
}); });
// --- Confirmation on destructive forms --------------------------------
// Forms that delete something or invalidate a working credential carry a
// data-confirm message. The prompt lives here rather than in an inline
// onsubmit attribute because the panel's Content-Security-Policy allows no
// inline script (phase 14.A). The listener is delegated from the document,
// so it also covers markup swapped in by HTMX. With JavaScript disabled the
// form submits without asking — exactly as the inline handler behaved.
document.addEventListener("submit", function (ev) {
var form = ev.target.closest("form[data-confirm]");
if (form && !window.confirm(form.dataset.confirm)) {
ev.preventDefault();
}
});
// --- Address list shown only in list mode ----------------------------- // --- Address list shown only in list mode -----------------------------
// The "Addresses" field applies to list mode only; in wildcard mode the // The "Addresses" field applies to list mode only; in wildcard mode the
// server ignores it, so hiding it removes a field that does nothing. The // server ignores it, so hiding it removes a field that does nothing. The
+1 -1
View File
@@ -18,7 +18,7 @@
invalid; remove it from DNS if you do not plan to re-add the domain.</p> invalid; remove it from DNS if you do not plan to re-add the domain.</p>
<form method="post" action="/domains/{{.Domain.ID}}/delete"> <form method="post" action="/domains/{{.Domain.ID}}/delete">
<button type="submit" style="background:#b42318">Delete {{.Domain.Name}}</button> <button type="submit" class="danger">Delete {{.Domain.Name}}</button>
</form> </form>
</div> </div>
{{end}} {{end}}
+4 -4
View File
@@ -152,18 +152,18 @@
</form> </form>
{{if .HasLimit}} {{if .HasLimit}}
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit" <form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
onsubmit="return confirm('Remove the rate limit for {{.Login}}? Only the global level-1 limit will apply.')"> data-confirm="Remove the rate limit for {{.Login}}? Only the global level-1 limit will apply.">
<input type="hidden" name="clear" value="1"> <input type="hidden" name="clear" value="1">
<button type="submit" class="danger">Remove limit</button> <button type="submit" class="danger">Remove limit</button>
</form> </form>
{{end}} {{end}}
</details> </details>
<form class="inline" method="post" action="/applications/{{.ID}}/password" <form class="inline" method="post" action="/applications/{{.ID}}/password"
onsubmit="return confirm('Regenerate the password for {{.Login}}? The current password stops working immediately.')"> data-confirm="Regenerate the password for {{.Login}}? The current password stops working immediately.">
<button type="submit">New password</button> <button type="submit">New password</button>
</form> </form>
<form class="inline" method="post" action="/applications/{{.ID}}/delete" <form class="inline" method="post" action="/applications/{{.ID}}/delete"
onsubmit="return confirm('Delete application {{.Login}}? Its credentials stop working immediately.')"> data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
<button type="submit" class="danger">Delete</button> <button type="submit" class="danger">Delete</button>
</form> </form>
</td> </td>
@@ -204,7 +204,7 @@
</form> </form>
{{if .DomainHasRL}} {{if .DomainHasRL}}
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit" <form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit"
onsubmit="return confirm('Remove the domain rate limit? Only the global level-1 limit will apply.')"> data-confirm="Remove the domain rate limit? Only the global level-1 limit will apply.">
<input type="hidden" name="clear" value="1"> <input type="hidden" name="clear" value="1">
<button type="submit" class="danger">Remove limit</button> <button type="submit" class="danger">Remove limit</button>
</form> </form>
+7 -115
View File
@@ -5,123 +5,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title> <title>{{.Title}}</title>
<link rel="icon" href="/static/favicon.png" type="image/png"> <link rel="icon" href="/static/favicon.png" type="image/png">
<link rel="stylesheet" href="/static/panel.css">
{{/* Unless told otherwise, htmx injects a stylesheet element of its own into
the head for the request-indicator classes. The panel uses no
hx-indicator, and that injected element would be the one thing the
Content-Security-Policy (phase 14.A) has to make an exception for, so it
is switched off here. */}}
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<script src="/static/htmx.min.js" defer></script> <script src="/static/htmx.min.js" defer></script>
<script src="/static/panel.js" defer></script> <script src="/static/panel.js" defer></script>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
margin: 0; padding: 2rem 1rem; background: #f6f7f9; color: #1b1f24;
}
@media (prefers-color-scheme: dark) {
body { background: #14171a; color: #e6e8eb; }
.card { background: #1d2125 !important; border-color: #2b3138 !important; }
input { background: #14171a !important; color: inherit !important; border-color: #2b3138 !important; }
}
main { max-width: 42rem; margin: 0 auto; }
h1 { font-size: 1.4rem; margin: 0 0 1rem; }
.card {
background: #fff; border: 1px solid #e2e5e9; border-radius: 10px;
padding: 1.5rem; margin: 0 auto;
}
.card.narrow { max-width: 24rem; }
label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; }
input {
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
border: 1px solid #cfd4da; border-radius: 6px; background: #fff;
}
button {
margin-top: 1.2rem; padding: 0.6rem 1.1rem; font-size: 1rem; font-weight: 600;
color: #fff; background: #2563eb; border: 0; border-radius: 6px; cursor: pointer;
}
button:hover { background: #1d4ed8; }
.error { color: #b42318; margin: 0.6rem 0 0; font-weight: 600; }
.muted { color: #6b7280; }
.topbar { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.2rem; }
.topbar .actions { display: flex; gap: 0.9rem; align-items: baseline; }
form.inline { display: inline; margin: 0; }
form.inline button { background: none; color: #2563eb; padding: 0; margin: 0; font-weight: 600; }
form.inline button:hover { text-decoration: underline; background: none; }
main { max-width: 48rem; }
.card + .card { margin-top: 1.2rem; }
.flash { background: #ecfdf3; border: 1px solid #abefc6; color: #067647; padding: 0.7rem 1rem; border-radius: 8px; margin-bottom: 1.2rem; }
@media (prefers-color-scheme: dark) { .flash { background: #0d2818 !important; border-color: #1a5336 !important; color: #75d99b !important; } }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid #e2e5e9; }
@media (prefers-color-scheme: dark) { th, td { border-color: #2b3138 !important; } }
th { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; color: #6b7280; }
td.actions { text-align: right; }
a.danger { color: #b42318; }
.code { display: block; white-space: pre-wrap; word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85rem; background: #f0f2f4; border: 1px solid #e2e5e9; border-radius: 6px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; }
@media (prefers-color-scheme: dark) { .code { background: #14171a !important; border-color: #2b3138 !important; } }
h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
.back { display: inline-block; margin-bottom: 1rem; }
select, textarea {
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
border: 1px solid #cfd4da; border-radius: 6px; background: #fff; color: inherit;
font-family: inherit;
}
textarea { resize: vertical; }
@media (prefers-color-scheme: dark) {
select, textarea { background: #14171a !important; color: inherit !important; border-color: #2b3138 !important; }
}
button.danger { background: #b42318; }
button.danger:hover { background: #912018; }
form.inline button.danger { background: none; color: #b42318; }
form.inline button.danger:hover { background: none; }
td.actions form.inline, td.actions details { margin-left: 0.6rem; }
details summary { cursor: pointer; color: #2563eb; font-weight: 600; }
details form { margin-top: 0.6rem; }
.credential { border-color: #f5c518; background: #fffbeb; }
@media (prefers-color-scheme: dark) { .credential { background: #2a2408 !important; border-color: #6b5a10 !important; } }
/* Panel navigation: rendered once from this layout, so it is present on every
authenticated page without each content template having to include it. */
.nav {
display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline;
gap: 0.4rem 1rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
border-bottom: 1px solid #e2e5e9;
}
@media (prefers-color-scheme: dark) { .nav { border-color: #2b3138 !important; } }
.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: baseline; }
.nav .links a, .nav .links [aria-current] { padding: 0.2rem 0.5rem; border-radius: 6px; }
.nav .links [aria-current] {
font-weight: 600; color: #1b1f24; background: #e6ebf5; box-shadow: inset 0 -2px 0 #2563eb;
}
@media (prefers-color-scheme: dark) {
.nav .links [aria-current] { color: #e6e8eb !important; background: #22303f !important; }
}
/* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server
status page and the per-domain DNS checks, so a colour means the same thing
everywhere. The class suffix is the check's own status value. */
.st {
display: inline-block; padding: 0.05rem 0.45rem; border-radius: 999px;
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
vertical-align: middle; border: 1px solid transparent;
}
.st-ok { background: #ecfdf3; color: #067647; border-color: #abefc6; }
.st-warn { background: #fffaeb; color: #b54708; border-color: #fedf89; }
.st-error { background: #fef3f2; color: #b42318; border-color: #fecdca; }
.st-unknown { background: #f0f2f4; color: #6b7280; border-color: #e2e5e9; }
@media (prefers-color-scheme: dark) {
.st-ok { background: #0d2818 !important; color: #75d99b !important; border-color: #1a5336 !important; }
.st-warn { background: #2e2308 !important; color: #f5c86b !important; border-color: #6b5210 !important; }
.st-error { background: #2d1211 !important; color: #f5a29b !important; border-color: #6b201a !important; }
.st-unknown { background: #22262b !important; color: #9aa3ad !important; border-color: #2b3138 !important; }
}
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
.code-row .code { flex: 1; min-width: 0; }
button.copy {
flex: none; margin-top: 0.3rem; padding: 0.45rem 0.7rem; font-size: 0.8rem;
background: #eef1f5; color: #2563eb; border: 1px solid #cfd4da;
}
button.copy:hover { background: #e2e7ee; }
@media (prefers-color-scheme: dark) {
button.copy { background: #22262b !important; border-color: #2b3138 !important; }
button.copy:hover { background: #2b3138 !important; }
}
</style>
</head> </head>
<body> <body>
<main> <main>
+48
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"io/fs" "io/fs"
"path" "path"
"regexp"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -96,6 +97,53 @@ func TestReloadFormLivesOnlyOnTheStatusPage(t *testing.T) {
}) })
} }
// The panel's Content-Security-Policy is a plain default-src 'self' with no
// inline exemption (phase 14.A), which makes inline script and inline style a
// failure mode rather than a style question: an onclick= handler or a
// style="..." attribute added to a template does not error, it silently stops
// working in the browser. Behaviour belongs in static/panel.js (triggered from
// a data- attribute), appearance in static/panel.css.
func TestNoTemplateUsesInlineScriptOrStyle(t *testing.T) {
inlineHandler := regexp.MustCompile(`\son[a-z]+\s*=`)
inlineStyle := regexp.MustCompile(`\sstyle\s*=|<style[\s>]`)
scriptTag := regexp.MustCompile(`<script[^>]*>`)
forEachTemplate(t, func(name, body string) {
if m := inlineHandler.FindString(body); m != "" {
t.Errorf("%s has an inline event handler (%q); the CSP blocks it — move the behaviour into static/panel.js",
name, strings.TrimSpace(m))
}
if m := inlineStyle.FindString(body); m != "" {
t.Errorf("%s has an inline style (%q); the CSP blocks it — move the rule into static/panel.css",
name, strings.TrimSpace(m))
}
for _, tag := range scriptTag.FindAllString(body, -1) {
if !strings.Contains(tag, "src=") {
t.Errorf("%s has an inline script (%q); the CSP blocks it — put the code in static/panel.js", name, tag)
}
}
})
}
// default-src 'self' also means every asset a page pulls in must be one this
// server actually serves, so a typo in a /static path is a blocked request,
// not a 404 in the page's own colours.
func TestLayoutReferencesOnlyEmbeddedAssets(t *testing.T) {
body, err := fs.ReadFile(assetsFS, "templates/layout.html")
if err != nil {
t.Fatalf("read layout: %v", err)
}
refs := regexp.MustCompile(`(?:src|href)="/static/([^"]+)"`).FindAllStringSubmatch(string(body), -1)
if len(refs) == 0 {
t.Fatal("the layout references no static assets at all")
}
for _, m := range refs {
if _, err := fs.Stat(assetsFS, "static/"+m[1]); err != nil {
t.Errorf("layout references /static/%s, which is not embedded: %v", m[1], err)
}
}
}
func TestStatusPageRendersEveryCheck(t *testing.T) { func TestStatusPageRendersEveryCheck(t *testing.T) {
tmpl, err := loadTemplates() tmpl, err := loadTemplates()
if err != nil { if err != nil {