web: Referrer-Policy same-origin; a password shown once emptied on pagehide
test / test (push) Canceled after 0s
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:
@@ -131,6 +131,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
copy, names a renewal waiting in the mount, and warns when it has waited
|
copy, names a renewal waiting in the mount, and warns when it has waited
|
||||||
longer than the daily check allows; the expiry notice is not sent while a
|
longer than the daily check allows; the expiry notice is not sent while a
|
||||||
renewal waits on time (assumptions A-65).
|
renewal waits on time (assumptions A-65).
|
||||||
|
- **The panel works in browsers that do not say where a request comes
|
||||||
|
from.** The panel's `Referrer-Policy: no-referrer` made a browser send a
|
||||||
|
form's `Origin` as `null`, which the panel refuses unless the browser also
|
||||||
|
sends `Sec-Fetch-Site`: in Safari before 16.4, or in any browser on a
|
||||||
|
panel served over plain HTTP at a name, every form - signing in
|
||||||
|
included - was refused. The policy is now `same-origin`, which still
|
||||||
|
sends nothing to other sites (assumptions D-B3).
|
||||||
|
- **A password shown once is emptied when its page is left,** so a browser
|
||||||
|
that keeps the page for its Back button shows an empty field; the
|
||||||
|
meaningless `Pragma` header goes (assumptions D-B13).
|
||||||
- **Words for what happens to mail match Postfix's.** A message refused with
|
- **Words for what happens to mail match Postfix's.** A message refused with
|
||||||
a temporary error while the panel or the spam filter does not answer was
|
a temporary error while the panel or the spam filter does not answer was
|
||||||
called deferred, which in Postfix's words is mail waiting in its queue;
|
called deferred, which in Postfix's words is mail waiting in its queue;
|
||||||
|
|||||||
@@ -776,7 +776,7 @@ are in [plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement.
|
|||||||
on every response: `Content-Security-Policy: default-src 'self'; object-src
|
on every response: `Content-Security-Policy: default-src 'self'; object-src
|
||||||
'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'`,
|
'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'`,
|
||||||
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
|
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
|
||||||
`Referrer-Policy: no-referrer`, and `Strict-Transport-Security` when the cookie
|
`Referrer-Policy: same-origin`, and `Strict-Transport-Security` when the cookie
|
||||||
is `Secure`. Because the policy has no exemptions, no template carries an
|
is `Secure`. Because the policy has no exemptions, no template carries an
|
||||||
inline `<script>`, an event-handler attribute or `style="…"` — the page's
|
inline `<script>`, an event-handler attribute or `style="…"` — the page's
|
||||||
behaviour is `htmx.min.js` plus `panel.js` (copy and *fill* buttons, the
|
behaviour is `htmx.min.js` plus `panel.js` (copy and *fill* buttons, the
|
||||||
|
|||||||
@@ -989,12 +989,16 @@ those after it move up one patch number each.
|
|||||||
connection is silent in all three milters (libmilter read: sendmail
|
connection is silent in all three milters (libmilter read: sendmail
|
||||||
8.17.1.9 fetched into dev/upstream); the expiry notice waits for a renewal
|
8.17.1.9 fetched into dev/upstream); the expiry notice waits for a renewal
|
||||||
that is on time.
|
that is on time.
|
||||||
- [ ] **The panel in browsers without Sec-Fetch-Site** (D-B3, D-B13): under
|
- [x] **The panel in browsers without Sec-Fetch-Site** (D-B3, D-B13): under
|
||||||
`Referrer-Policy: no-referrer` a form POST carries `Origin: null`, which
|
`Referrer-Policy: no-referrer` a form POST carries `Origin: null`, which
|
||||||
the origin check refuses - every POST, sign-in included, fails in Safari
|
the origin check refuses - every POST, sign-in included, fails in Safari
|
||||||
before 16.4 and over plain HTTP at a name; `same-origin` instead, with a
|
before 16.4 and over plain HTTP at a name; `same-origin` instead, with a
|
||||||
header test and one POST on the stand. A shown-once secret is blanked on
|
header test and one POST on the stand. A shown-once secret is blanked on
|
||||||
`pagehide`; `Pragma` goes. - **Opus**
|
`pagehide`; `Pragma` goes. - **Opus**
|
||||||
|
*Done 2026-10-15.* Measured the same day in Chrome 152 (the desktop
|
||||||
|
app's browser): a `no-store` page is not restored from the back/forward
|
||||||
|
cache (pageshow persisted=false, a fresh load); the `pagehide` handler is
|
||||||
|
for browsers that do restore one. Firefox and Safari not measured.
|
||||||
- [ ] **Two processes opening an old database** (D-S3): `user_version` is
|
- [ ] **Two processes opening an old database** (D-S3): `user_version` is
|
||||||
read outside the transaction, so the panel and dmarc-ingest can both
|
read outside the transaction, so the panel and dmarc-ingest can both
|
||||||
run one migration; `_txlock=immediate` and the version re-read inside
|
run one migration; `_txlock=immediate` and the version re-read inside
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
|||||||
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
|
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
|
||||||
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||||
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||||
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`; next stage 2c → `2.5.2`) | 41/81 | [audit-2026-10-12.md](audit-2026-10-12.md) |
|
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`; next stage 2c → `2.5.2`) | 42/81 | [audit-2026-10-12.md](audit-2026-10-12.md) |
|
||||||
|
|
||||||
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
||||||
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
|
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
|
||||||
|
|||||||
@@ -393,14 +393,15 @@ func (h *Handlers) recalcApp(appID int64) error {
|
|||||||
return h.recalcRateLimit(store.RateLimitScopeApp, appID)
|
return h.recalcRateLimit(store.RateLimitScopeApp, appID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// noStore keeps a page that shows a secret out of every cache and out of the
|
// noStore keeps a page that shows a secret out of every HTTP cache, private
|
||||||
// browser's history copy: the password on it is shown once.
|
// and shared (RFC 9111 § 5.2.2.5): the password on it is shown once. A
|
||||||
// RFC 9111 § 5.2.2.5: no-store keeps it out of every HTTP cache, private and
|
// browser's history - the back/forward cache - is not what it governs: § 6,
|
||||||
// shared. The history part is not what it governs (§ 6), and Pragma has no
|
// a history mechanism "might honor" no-store. So the page's field is also
|
||||||
// defined meaning in a response (§ 5.4): assumptions-2026-10-14, D-B13.
|
// 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) {
|
func noStore(w http.ResponseWriter) {
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
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,
|
// 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{
|
rec := postFormAs(s.h.HandleApplicationCreate, globalPrincipal, "/outbound/domains/1/applications/new", s.paths, url.Values{
|
||||||
"login": {"prod-server"}, "mode": {"list"}, "addresses": {"alerts@example.org"},
|
"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"))
|
t.Fatalf("create = %d, Cache-Control %q", rec.Code, rec.Header().Get("Cache-Control"))
|
||||||
}
|
}
|
||||||
password := s.sasl["prod-server"]
|
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's page.`, `alerts@example.org`, `465 SSL/TLS · 587 STARTTLS`,
|
`If this one is lost, generate another from the application's page.`, `alerts@example.org`, `465 SSL/TLS · 587 STARTTLS`,
|
||||||
`href="/outbound/domains/1">Done — back to example.org</a>`)
|
`href="/outbound/domains/1">Done — back to example.org</a>`)
|
||||||
lacks(t, "created", rec.Body.String(), `stopped working`)
|
lacks(t, "created", rec.Body.String(), `stopped working`)
|
||||||
|
|||||||
@@ -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
|
// CSP3 § 6.4.2.2: 'none' is DENY, and where frame-ancestors is
|
||||||
// enforced X-Frame-Options is ignored.
|
// enforced X-Frame-Options is ignored.
|
||||||
h.Set("X-Frame-Options", "DENY")
|
h.Set("X-Frame-Options", "DENY")
|
||||||
// The panel's behaviour never depends on the referrer, so the strictest
|
// Panel URLs carry domain names and record identifiers that need not
|
||||||
// value is free: panel URLs carry domain names and record identifiers
|
// leak to anything the administrator clicks through to: same-origin
|
||||||
// that need not leak to anything the administrator clicks through to.
|
// sends no referrer to another site. Not no-referrer, which the
|
||||||
h.Set("Referrer-Policy", "no-referrer")
|
// 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 {
|
if s.cfg.CookieSecure {
|
||||||
h.Set("Strict-Transport-Security", strictTransportSecurity)
|
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
|
// 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
|
// HTTP at a name other than localhost: Fetch Metadata § 3 appends its
|
||||||
// headers only for a potentially trustworthy URL. Fetch Standard § 3.2: a
|
// headers only for a potentially trustworthy URL. Fetch Standard § 3.2: a
|
||||||
// request that is neither GET nor HEAD carries Origin. (Which value it
|
// request that is neither GET nor HEAD carries Origin, and under this
|
||||||
// carries under this panel's Referrer-Policy: assumptions-2026-10-14, D-B3.)
|
// 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
|
// 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
|
// proxy and does not know its own external scheme, while Origin will say
|
||||||
// https.
|
// https.
|
||||||
@@ -154,7 +162,8 @@ func originAllowed(r *http.Request) bool {
|
|||||||
// Fetch Standard § 2.2.5 "serializing a request origin": "null" for a
|
// Fetch Standard § 2.2.5 "serializing a request origin": "null" for a
|
||||||
// redirect-tainted request; HTML § 7.1.1: an opaque origin serialises
|
// redirect-tainted request; HTML § 7.1.1: an opaque origin serialises
|
||||||
// to "null"; url.Parse("null") has no Host. (Fetch § 3.2 also sends
|
// 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 false
|
||||||
}
|
}
|
||||||
return u.Host == r.Host
|
return u.Host == r.Host
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ func TestSecurityHeaders(t *testing.T) {
|
|||||||
"Content-Security-Policy": contentSecurityPolicy,
|
"Content-Security-Policy": contentSecurityPolicy,
|
||||||
"X-Content-Type-Options": "nosniff",
|
"X-Content-Type-Options": "nosniff",
|
||||||
"X-Frame-Options": "DENY",
|
"X-Frame-Options": "DENY",
|
||||||
"Referrer-Policy": "no-referrer",
|
"Referrer-Policy": "same-origin",
|
||||||
"Strict-Transport-Security": strictTransportSecurity,
|
"Strict-Transport-Security": strictTransportSecurity,
|
||||||
}
|
}
|
||||||
for name, value := range want {
|
for name, value := range want {
|
||||||
|
|||||||
@@ -405,6 +405,10 @@ type CopyField struct {
|
|||||||
Value string
|
Value string
|
||||||
Rows int
|
Rows int
|
||||||
Help Text
|
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
|
// Fact is the input of facts (as a slice): a label and its value. Mono sets the
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ func NewOutAppCreated(m Meta, domainID int64, domain, login, password string, fr
|
|||||||
Title: "New password for", Code: login},
|
Title: "New password for", Code: login},
|
||||||
Credential: Box{Icon: "ti-eye", Title: "Shown once", End: Plain("Not stored — copy it now"), Variant: BoxCredential},
|
Credential: Box{Icon: "ti-eye", Title: "Shown once", End: Plain("Not stored — copy it now"), Variant: BoxCredential},
|
||||||
Login: CopyField{Label: "Login", Value: login},
|
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}},
|
Done: Foot{Button: &Action{Label: "Done — back to " + domain, Href: DomainHref(domainID), Primary: true}},
|
||||||
Connect: Box{Icon: "ti-plug", Title: "Connect with"},
|
Connect: Box{Icon: "ti-plug", Title: "Connect with"},
|
||||||
Facts: []Fact{
|
Facts: []Fact{
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ func TestOutAppCreatedShowsLoginAndPasswordOnce(t *testing.T) {
|
|||||||
pageHas(t, "New password", out,
|
pageHas(t, "New password", out,
|
||||||
`<h1 class="title is-3">New password for <span class="sp-mono">prod-server</span></h1>`,
|
`<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`,
|
`<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.`,
|
`The previous password stopped working when this one was generated.`,
|
||||||
`href="/outbound/domains/1">Done — back to example.org</a>`,
|
`href="/outbound/domains/1">Done — back to example.org</a>`,
|
||||||
`<h2>Connect with</h2>`, `mail.example.org`, `465 SSL/TLS · 587 STARTTLS`, `*@example.org`,
|
`<h2>Connect with</h2>`, `mail.example.org`, `465 SSL/TLS · 587 STARTTLS`, `*@example.org`,
|
||||||
|
|||||||
@@ -63,6 +63,22 @@
|
|||||||
input.focus();
|
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 --------------------------------
|
// --- Confirmation on destructive forms --------------------------------
|
||||||
// Forms that delete something or invalidate a working credential carry a
|
// Forms that delete something or invalidate a working credential carry a
|
||||||
// data-confirm message. The prompt lives here rather than in an inline
|
// data-confirm message. The prompt lives here rather than in an inline
|
||||||
|
|||||||
@@ -155,7 +155,7 @@
|
|||||||
CopyField): one line, or a textarea when Rows is set — a DKIM key. The
|
CopyField): one line, or a textarea when Rows is set — a DKIM key. The
|
||||||
button is wired by panel.js through data-copy. */}}
|
button is wired by panel.js through data-copy. */}}
|
||||||
{{define "copy_field"}}<div class="field"><label class="label">{{.Label}}</label>
|
{{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>
|
{{if .Help}}<p class="help">{{template "inline" .Help}}</p>
|
||||||
{{end}}</div>{{end}}
|
{{end}}</div>{{end}}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -137,3 +138,41 @@ func writeCertWithoutItsKey(certPath string, notAfter time.Time) error {
|
|||||||
}
|
}
|
||||||
return os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644)
|
return os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testFormPostWithTheBrowsersOrigin: a browser on a plain-HTTP panel at a
|
||||||
|
// name sends no Sec-Fetch-Site (Fetch Metadata § 3: only for a potentially
|
||||||
|
// trustworthy URL), and its form POST - a navigation - carries Origin by the
|
||||||
|
// page's Referrer-Policy (Fetch Standard § 3.2): its own origin under the
|
||||||
|
// panel's same-origin, which passes; "null" under no-referrer, which the
|
||||||
|
// panel sent until 2.5.2 and which the origin check refuses (assumptions
|
||||||
|
// D-B3). The client here is not a browser: it sends what one would.
|
||||||
|
func testFormPostWithTheBrowsersOrigin(t *testing.T, sc *scenario) {
|
||||||
|
resp, _, err := sc.panel.get("/server/health")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := resp.Header.Get("Referrer-Policy"); got != "same-origin" {
|
||||||
|
t.Fatalf("Referrer-Policy %q, want same-origin", got)
|
||||||
|
}
|
||||||
|
post := func(origin string) int {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest(http.MethodPost, panelBaseURL+"/server/health/recheck", strings.NewReader(""))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Origin", origin)
|
||||||
|
resp, err := sc.panel.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
if code := post(panelBaseURL); code != http.StatusOK {
|
||||||
|
t.Fatalf("POST with the page's own Origin: status %d, want 200 after the redirect", code)
|
||||||
|
}
|
||||||
|
if code := post("null"); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("POST with Origin: null: status %d, want 403", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -174,6 +174,9 @@ func TestE2E(t *testing.T) {
|
|||||||
run("health_shows_what_is_served", func(t *testing.T) {
|
run("health_shows_what_is_served", func(t *testing.T) {
|
||||||
testHealthShowsWhatIsServed(t, sc)
|
testHealthShowsWhatIsServed(t, sc)
|
||||||
})
|
})
|
||||||
|
run("form_post_with_the_browsers_origin", func(t *testing.T) {
|
||||||
|
testFormPostWithTheBrowsersOrigin(t, sc)
|
||||||
|
})
|
||||||
|
|
||||||
run("inbound_ui_absent_when_disabled", func(t *testing.T) {
|
run("inbound_ui_absent_when_disabled", func(t *testing.T) {
|
||||||
resp, body, err := sc.panel.get("/overview")
|
resp, body, err := sc.panel.get("/overview")
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ func (c *panelClient) applicationID(domainID, login string) (string, error) {
|
|||||||
// before returning.
|
// before returning.
|
||||||
func extractCodeRow(body, label string) (string, bool) {
|
func extractCodeRow(body, label string) (string, bool) {
|
||||||
pattern := `<label class="label">` + regexp.QuoteMeta(label) + `</label>\s*<div class="field has-addons"><div class="control is-expanded">` +
|
pattern := `<label class="label">` + regexp.QuoteMeta(label) + `</label>\s*<div class="field has-addons"><div class="control is-expanded">` +
|
||||||
`(?:<input class="input" readonly value="([^"]*)">|<textarea class="textarea" rows="\d+" readonly>([^<]*)</textarea>)`
|
`(?:<input class="input" readonly value="([^"]*)"(?: data-secret)?>|<textarea class="textarea" rows="\d+" readonly(?: data-secret)?>([^<]*)</textarea>)`
|
||||||
m := regexp.MustCompile(pattern).FindStringSubmatch(body)
|
m := regexp.MustCompile(pattern).FindStringSubmatch(body)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return "", false
|
return "", false
|
||||||
|
|||||||
Reference in New Issue
Block a user