web: Referrer-Policy same-origin; a password shown once emptied on pagehide
test / test (push) Canceled after 0s

D-B3. Fetch Standard § 3.2 "append a request `Origin` header": under
Referrer-Policy no-referrer a request that is not GET/HEAD and not CORS -
a form POST, a navigation - gets Origin "null". originAllowed() refuses
"null" whenever Sec-Fetch-Site is absent, and Fetch Metadata § 3 sends
Sec-Fetch-Site only to a potentially trustworthy URL: every form,
sign-in included, failed in Safari before 16.4 and in any browser on a
plain-HTTP panel at a name. same-origin nulls Origin only for a request
not same origin with its URL and still sends no referrer to another
site. The comments of the origin check say so.

D-B13. noStore keeps the page out of HTTP caches (RFC 9111 § 5.2.2.5);
a history mechanism "might honor" it (§ 6), and Pragma in a response
"was never specified" (§ 5.4) - it goes. CopyField.Secret marks the
password of out-app-created data-secret, and panel.js empties such
fields on pagehide (HTML § 7.5.9, "unload a Document", step 10.2: fired
whether or not the document is kept for history traversal).

Measured 2026-10-15 in Chrome 152 (the desktop app's browser), a local
probe page with Cache-Control: no-store and a data-secret field: Back
reloads it (pageshow persisted=false, a new load), so this browser keeps
no-store pages out of its back/forward cache; the handler, fired on the
same page, empties the field. Firefox and Safari not measured.

Tests: the header value; the created page has the password data-secret
and the login not, no Pragma. Stand (2026-10-15, make e2e, exit 0): new
form_post_with_the_browsers_origin - Referrer-Policy: same-origin on a
page; a POST carrying the page's own Origin and no Sec-Fetch-Site, what
a browser sends now, passes; one carrying Origin: null, what it sent
before, is 403. (A first run failed where the e2e client and a view test
scraped the old input markup; both read data-secret now.) go vet and go
test ./... pass on the server (24 packages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-15 01:04:45 +03:00
co-authored by Claude Opus 5.5
parent 98e09fef4a
commit 9ca50d33fd
16 changed files with 109 additions and 23 deletions
+7 -6
View File
@@ -393,14 +393,15 @@ func (h *Handlers) recalcApp(appID int64) error {
return h.recalcRateLimit(store.RateLimitScopeApp, appID)
}
// noStore keeps a page that shows a secret out of every cache and out of the
// browser's history copy: the password on it is shown once.
// RFC 9111 § 5.2.2.5: no-store keeps it out of every HTTP cache, private and
// shared. The history part is not what it governs (§ 6), and Pragma has no
// defined meaning in a response (§ 5.4): assumptions-2026-10-14, D-B13.
// noStore keeps a page that shows a secret out of every HTTP cache, private
// and shared (RFC 9111 § 5.2.2.5): the password on it is shown once. A
// browser's history - the back/forward cache - is not what it governs: § 6,
// a history mechanism "might honor" no-store. So the page's field is also
// marked secret, and panel.js empties it when the page is left (pagehide).
// No Pragma: in a response it "was never specified" (§ 5.4). Assumptions
// D-B13.
func noStore(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
}
// blankAppForm is the application form as it opens: any address of the domain,
@@ -314,11 +314,11 @@ func TestPasswordIsShownOnceAndNeverCached(t *testing.T) {
rec := postFormAs(s.h.HandleApplicationCreate, globalPrincipal, "/outbound/domains/1/applications/new", s.paths, url.Values{
"login": {"prod-server"}, "mode": {"list"}, "addresses": {"alerts@example.org"},
})
if rec.Code != http.StatusCreated || rec.Header().Get("Cache-Control") != "no-store" || rec.Header().Get("Pragma") != "no-cache" {
if rec.Code != http.StatusCreated || rec.Header().Get("Cache-Control") != "no-store" || rec.Header().Get("Pragma") != "" {
t.Fatalf("create = %d, Cache-Control %q", rec.Code, rec.Header().Get("Cache-Control"))
}
password := s.sasl["prod-server"]
has(t, "created", rec.Body.String(), `<h2>Shown once</h2>`, `value="`+password+`"`, `value="prod-server"`,
has(t, "created", rec.Body.String(), `<h2>Shown once</h2>`, `value="`+password+`" data-secret>`, `value="prod-server">`,
`If this one is lost, generate another from the application&#39;s page.`, `alerts@example.org`, `465 SSL/TLS · 587 STARTTLS`,
`href="/outbound/domains/1">Done — back to example.org</a>`)
lacks(t, "created", rec.Body.String(), `stopped working`)
+16 -7
View File
@@ -65,10 +65,17 @@ func (s *Server) secure(next http.Handler) http.Handler {
// CSP3 § 6.4.2.2: 'none' is DENY, and where frame-ancestors is
// enforced X-Frame-Options is ignored.
h.Set("X-Frame-Options", "DENY")
// The panel's behaviour never depends on the referrer, so the strictest
// value is free: panel URLs carry domain names and record identifiers
// that need not leak to anything the administrator clicks through to.
h.Set("Referrer-Policy", "no-referrer")
// Panel URLs carry domain names and record identifiers that need not
// leak to anything the administrator clicks through to: same-origin
// sends no referrer to another site. Not no-referrer, which the
// origin check below depends on: under it a browser sends a form
// POST - a navigation - with Origin: null, refused whenever
// Sec-Fetch-Site is absent (Safari before 16.4, any browser over
// plain HTTP at a name). Fetch Standard § 3.2 "append a request
// `Origin` header": "no-referrer" sets the serialised origin to null;
// "same-origin" does so only when the request is not same origin
// with its URL (assumptions D-B3).
h.Set("Referrer-Policy", "same-origin")
if s.cfg.CookieSecure {
h.Set("Strict-Transport-Security", strictTransportSecurity)
}
@@ -131,8 +138,9 @@ func originAllowed(r *http.Request) bool {
// Older browsers send only Origin, and so does a current one over plain
// HTTP at a name other than localhost: Fetch Metadata § 3 appends its
// headers only for a potentially trustworthy URL. Fetch Standard § 3.2: a
// request that is neither GET nor HEAD carries Origin. (Which value it
// carries under this panel's Referrer-Policy: assumptions-2026-10-14, D-B3.)
// request that is neither GET nor HEAD carries Origin, and under this
// panel's Referrer-Policy, same-origin, it is the page's own origin for a
// form of the panel (§ 3.2; assumptions-2026-10-14, D-B3).
// Compare hosts, not full origins: the panel speaks plain HTTP behind the
// proxy and does not know its own external scheme, while Origin will say
// https.
@@ -154,7 +162,8 @@ func originAllowed(r *http.Request) bool {
// Fetch Standard § 2.2.5 "serializing a request origin": "null" for a
// redirect-tainted request; HTML § 7.1.1: an opaque origin serialises
// to "null"; url.Parse("null") has no Host. (Fetch § 3.2 also sends
// "null" under Referrer-Policy no-referrer: D-B3 of the same register.)
// "null" under Referrer-Policy no-referrer, which this panel therefore
// does not set: D-B3 of the same register.)
return false
}
return u.Host == r.Host
+1 -1
View File
@@ -93,7 +93,7 @@ func TestSecurityHeaders(t *testing.T) {
"Content-Security-Policy": contentSecurityPolicy,
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"Referrer-Policy": "same-origin",
"Strict-Transport-Security": strictTransportSecurity,
}
for name, value := range want {
+4
View File
@@ -405,6 +405,10 @@ type CopyField struct {
Value string
Rows int
Help Text
// Secret marks a value shown once: panel.js empties the field when the
// page is left, so a page the browser restores from its back/forward
// cache does not show it again.
Secret bool
}
// Fact is the input of facts (as a slice): a label and its value. Mono sets the
+1 -1
View File
@@ -270,7 +270,7 @@ func NewOutAppCreated(m Meta, domainID int64, domain, login, password string, fr
Title: "New password for", Code: login},
Credential: Box{Icon: "ti-eye", Title: "Shown once", End: Plain("Not stored — copy it now"), Variant: BoxCredential},
Login: CopyField{Label: "Login", Value: login},
Password: CopyField{Label: "Password", Value: password, Help: help},
Password: CopyField{Label: "Password", Value: password, Help: help, Secret: true},
Done: Foot{Button: &Action{Label: "Done — back to " + domain, Href: DomainHref(domainID), Primary: true}},
Connect: Box{Icon: "ti-plug", Title: "Connect with"},
Facts: []Fact{
+1 -1
View File
@@ -400,7 +400,7 @@ func TestOutAppCreatedShowsLoginAndPasswordOnce(t *testing.T) {
pageHas(t, "New password", out,
`<h1 class="title is-3">New password for <span class="sp-mono">prod-server</span></h1>`,
`<div class="box sp-credential">`, `<h2>Shown once</h2>`, `Not stored — copy it now`, `ti-eye`,
`<input class="input" readonly value="prod-server">`, `<input class="input" readonly value="kT7v-Qm2x-Lp9d-Ue4s-Hn6b-Rz3w">`,
`<input class="input" readonly value="prod-server">`, `<input class="input" readonly value="kT7v-Qm2x-Lp9d-Ue4s-Hn6b-Rz3w" data-secret>`,
`The previous password stopped working when this one was generated.`,
`href="/outbound/domains/1">Done — back to example.org</a>`,
`<h2>Connect with</h2>`, `mail.example.org`, `465 SSL/TLS · 587 STARTTLS`, `*@example.org`,
+16
View File
@@ -63,6 +63,22 @@
input.focus();
});
// --- Secrets shown once ------------------------------------------------
// A password shown once is in a field marked data-secret. Cache-Control:
// no-store keeps the page out of HTTP caches, but a browser may still keep
// the page in its back/forward cache and show it again on Back (RFC 9111
// § 6: a history mechanism "might honor" no-store). Emptying the field when
// the page is left means a restored page shows nothing to copy. HTML Living
// Standard, § 7.5.9 "Unloading documents", "unload a Document", step 10.2:
// pagehide is fired whether or not the document is kept for later history
// traversal (its salvageable state, the event's persisted).
window.addEventListener("pagehide", function () {
var secrets = document.querySelectorAll("[data-secret]");
for (var i = 0; i < secrets.length; i++) {
secrets[i].value = "";
}
});
// --- 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
+1 -1
View File
@@ -155,7 +155,7 @@
CopyField): one line, or a textarea when Rows is set — a DKIM key. The
button is wired by panel.js through data-copy. */}}
{{define "copy_field"}}<div class="field"><label class="label">{{.Label}}</label>
<div class="field has-addons"><div class="control is-expanded">{{if .Rows}}<textarea class="textarea" rows="{{.Rows}}" readonly>{{.Value}}</textarea>{{else}}<input class="input" readonly value="{{.Value}}">{{end}}</div><div class="control"><button type="button" class="button" data-copy><i class="ti ti-copy"></i>Copy</button></div></div>
<div class="field has-addons"><div class="control is-expanded">{{if .Rows}}<textarea class="textarea" rows="{{.Rows}}" readonly{{if .Secret}} data-secret{{end}}>{{.Value}}</textarea>{{else}}<input class="input" readonly value="{{.Value}}"{{if .Secret}} data-secret{{end}}>{{end}}</div><div class="control"><button type="button" class="button" data-copy><i class="ti ti-copy"></i>Copy</button></div></div>
{{if .Help}}<p class="help">{{template "inline" .Help}}</p>
{{end}}</div>{{end}}