store, web: cite the source of what they rely on (assumptions part D)
Comments only, no behaviour changed. modernc.org/sqlite v1.60.1 (the _pragma list on every connection, busy_timeout first; extended result codes; a deferred BEGIN; multi-statement Exec) and SQLite 3.53.4's documentation (transactions, WAL, foreign keys, NOCASE, upsert, julianday, partial indexes); htmx 2.0.4 src/htmx.js; the Fetch and HTML standards for Sec-Fetch-Site, Origin, SameSite, __Host-, CSP and defer order; Go's html/template. Comments the source contradicts are corrected - the driver does not serialise writes (SetMaxOpenConns(1) does), htmx:load fires per swapped element, no-store does not keep a page out of history. Left for their own commits: D-S3 a migration two processes can both run, D-S20 recipient case, D-B3 Origin: null under Referrer-Policy: no-referrer, D-H5/H6/H7/H9 (audit M11, stage 4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,9 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// draft-ietf-httpbis-rfc6265bis § 4.1.3.2: a cookie named __Host- is accepted
|
||||
// only with Secure, Path=/ and no Domain, from a secure origin - so no other
|
||||
// host can set or overwrite it, and clearing it needs Secure as well.
|
||||
const (
|
||||
sessionCookieBase = "selfpost_session"
|
||||
sessionCookiePrefixed = "__Host-" + sessionCookieBase
|
||||
@@ -46,6 +49,9 @@ func (m *Module) sessionToken(r *http.Request) (string, bool) {
|
||||
case 1:
|
||||
return token, true
|
||||
default:
|
||||
// RFC 6265 § 5.4: the Cookie header carries every cookie that
|
||||
// matches, a parent domain's of the same name included. The prefixed
|
||||
// name cannot be planted that way (see sessionCookiePrefixed).
|
||||
logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+
|
||||
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
|
||||
r.Method, r.URL.Path, n, name)
|
||||
@@ -130,6 +136,9 @@ func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// setSessionCookie sets the session cookie. SameSite=Lax withholds it from a
|
||||
// cross-site request unless that is a top-level navigation with a safe method
|
||||
// (rfc6265bis § 5.8.3, § 5.6.7.1), so a cross-site POST arrives without it.
|
||||
func (m *Module) setSessionCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: m.sessionCookie(),
|
||||
|
||||
@@ -34,6 +34,10 @@ func (m *Module) RequireAuth(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// isSessionActivity: a GET from htmx is a background poll - every panel hx-get
|
||||
// is a load or a poll - and does not renew the session. htmx 2.0.4, src/htmx.js
|
||||
// getHeaders(): every request it issues carries HX-Request: true (and
|
||||
// loadHistoryFromServer() sets it too).
|
||||
func isSessionActivity(r *http.Request) bool {
|
||||
return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "")
|
||||
}
|
||||
|
||||
@@ -31,7 +31,10 @@ func newSessionStore(st *store.Store, idle time.Duration) *sessionStore {
|
||||
|
||||
// MaxAge is the session cookie's Max-Age in seconds, kept equal to the
|
||||
// sliding idle window so the browser drops the cookie no later than the
|
||||
// server would have expired it anyway.
|
||||
// server would have expired it anyway. RFC 6265 § 5.2.2: Max-Age sets the
|
||||
// expiry to now plus that many seconds, and a cookie set again under the same
|
||||
// name, domain and path replaces the old one (§ 5.3 step 11), so each renewal
|
||||
// slides it.
|
||||
func (s *sessionStore) MaxAge() int {
|
||||
return int(s.idle.Seconds())
|
||||
}
|
||||
|
||||
@@ -92,6 +92,8 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request)
|
||||
f.Username = strings.TrimSpace(r.PostFormValue("username"))
|
||||
f.Email = strings.TrimSpace(r.PostFormValue("email"))
|
||||
// An unticked checkbox is not sent at all: absent is off.
|
||||
// HTML Living Standard, form-control-infrastructure.html#constructing-entry-list:
|
||||
// a checkbox whose checkedness is false is skipped.
|
||||
f.Notify = r.PostFormValue("notify") != ""
|
||||
if f.Username == "" {
|
||||
f.Username = u.Username
|
||||
|
||||
@@ -395,6 +395,9 @@ func (h *Handlers) recalcApp(appID int64) error {
|
||||
|
||||
// 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.
|
||||
func noStore(w http.ResponseWriter) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
|
||||
@@ -102,6 +102,8 @@ func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
// An unticked checkbox is not sent at all: absent is off.
|
||||
// HTML Living Standard, form-control-infrastructure.html#constructing-entry-list:
|
||||
// a checkbox whose checkedness is false is skipped.
|
||||
notifications := r.PostFormValue("notify") != ""
|
||||
if err := h.store.SetSendLogRetentionDays(days); err != nil {
|
||||
logf("panel: settings: set send-log retention failed: %v", err)
|
||||
|
||||
@@ -19,8 +19,17 @@ import (
|
||||
// relative URL on the page, including the form actions;
|
||||
// - form-action 'self' keeps a form from being retargeted at another host.
|
||||
//
|
||||
// CSP Level 3, https://www.w3.org/TR/CSP3/ § 6.8.3 "Get fetch directive
|
||||
// fallback list": script, style, img, font, connect and object fall back to
|
||||
// default-src; base-uri (§ 6.3.1), form-action (§ 6.4.1) and frame-ancestors
|
||||
// (§ 6.4.2) are not in any list, so each is set here.
|
||||
//
|
||||
// This is a second line of defence: XSS is already prevented by
|
||||
// html/template's contextual auto-escaping (security.md).
|
||||
// html/template's contextual auto-escaping (security.md). Go 1.26,
|
||||
// html/template doc.go "Security Model": template authors are trusted, the
|
||||
// data is not. An attribute it does not know (hx-get, hx-swap, popovertarget)
|
||||
// is escaped as plain text (attr.go attrType()), so the hx-get paths are built
|
||||
// by the server, never taken from input.
|
||||
const contentSecurityPolicy = "default-src 'self'; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'none'; " +
|
||||
@@ -31,8 +40,10 @@ const contentSecurityPolicy = "default-src 'self'; " +
|
||||
// (CookieSecure, the same condition that gates the cookie's Secure attribute):
|
||||
// on the plain-HTTP development setup it would be meaningless at best and
|
||||
// would pin the browser to a scheme the dev instance does not speak at worst.
|
||||
// RFC 6797 § 8.1: a header received over insecure transport is ignored.
|
||||
//
|
||||
// Deliberately without includeSubDomains. The panel commonly lives on a
|
||||
// Deliberately without includeSubDomains (RFC 6797 § 6.1.2: only with it does
|
||||
// the policy reach the host's subdomains). The panel commonly lives on a
|
||||
// subdomain of a domain used for other things, but it may also sit at the
|
||||
// apex — and there the directive would force HTTPS on every unrelated
|
||||
// subdomain of that domain for a year, which is not SelfPost's call to make.
|
||||
@@ -51,6 +62,8 @@ func (s *Server) secure(next http.Handler) http.Handler {
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
// Equivalent to the CSP's frame-ancestors directive, kept for browsers
|
||||
// that predate it. The two never disagree: both mean "never framed".
|
||||
// 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
|
||||
@@ -65,7 +78,12 @@ func (s *Server) secure(next http.Handler) http.Handler {
|
||||
// being the name the browser actually used, which every reverse
|
||||
// proxy fragment this project ships preserves (Apache
|
||||
// ProxyPreserveHost On, nginx proxy_set_header Host $host, Caddy
|
||||
// and Traefik by default) — but a proxy that rewrites Host turns
|
||||
// and Traefik by default: httpd.apache.org/docs/2.4/mod/mod_proxy.html
|
||||
// #proxypreservehost; nginx.org ngx_http_proxy_module
|
||||
// #proxy_set_header, whose default is $proxy_host; Caddy 2
|
||||
// reverse_proxy § Headers > Defaults, "passes through incoming
|
||||
// headers - including Host"; Traefik service load balancer,
|
||||
// passHostHeader true by default) — but a proxy that rewrites Host turns
|
||||
// every form submission into a 403, and without these two values
|
||||
// side by side the symptom just looks like "the panel stopped
|
||||
// saving anything".
|
||||
@@ -87,6 +105,9 @@ func (s *Server) secure(next http.Handler) http.Handler {
|
||||
// domain, a forgotten staging host — counts as same-site and its forged POST
|
||||
// would arrive with the session cookie attached. Sec-Fetch-Site and Origin are
|
||||
// judged per *origin*, so they tell that neighbour apart from the panel.
|
||||
// draft-ietf-httpbis-rfc6265bis § 5.2 (same-site is the [SAMESITE] "same
|
||||
// site" of the two origins); W3C Fetch Metadata § 2.3 (Sec-Fetch-Site:
|
||||
// same-origin only if every URL of the request is same origin).
|
||||
//
|
||||
// Read-only methods are exempt: every GET route in the panel is a read (the
|
||||
// delete confirmation at GET /outbound/domains/{id}/delete only renders a form), so
|
||||
@@ -100,26 +121,40 @@ func originAllowed(r *http.Request) bool {
|
||||
// The browser's own account of where the request came from. It cannot be
|
||||
// set by page script (a forbidden header name), so when it is present it
|
||||
// is the answer: anything but same-origin — including "none", a
|
||||
// navigation with no initiator — is refused.
|
||||
// navigation with no initiator — is refused. Fetch Standard § 2.2.2: a
|
||||
// header name starting with `Sec-` is a forbidden request-header; Fetch
|
||||
// Metadata § 2.3: "none" is a navigation the user started directly.
|
||||
if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
|
||||
return site == "same-origin"
|
||||
}
|
||||
|
||||
// Older browsers send only Origin. 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.
|
||||
// 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.)
|
||||
// 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.
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
// Neither header. A client this old cannot be checked at all; it is
|
||||
// let through as the risk consciously accepted (single-admin panel,
|
||||
// the administrator picks the browser). Turning this return into
|
||||
// false is the whole of the stricter policy.
|
||||
// false is the whole of the stricter policy. By Fetch § 3.2 a browser
|
||||
// that follows it never sends a POST without Origin; Firefox did for a
|
||||
// same-origin form before 70 (MDN browser-compat-data 6.0.0,
|
||||
// http.headers.Origin).
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Host == "" {
|
||||
// Unparseable, or the opaque "null" origin (sandboxed iframe,
|
||||
// redirected cross-origin POST) — not this panel either way.
|
||||
// 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.)
|
||||
return false
|
||||
}
|
||||
return u.Host == r.Host
|
||||
|
||||
@@ -110,6 +110,12 @@ type Tag struct {
|
||||
// why a message was decided the way it was). It is the browser's own popover:
|
||||
// the tag is a button that names the sheet by ID, so ID is unique on the page.
|
||||
// Its content is a table of name and value.
|
||||
//
|
||||
// HTML Living Standard, popover.html: popovertarget on a button shows or hides
|
||||
// (popovertargetaction="hide") the element with that id, with no script; a
|
||||
// browser without the popover attribute (before Chrome 114, Firefox 125,
|
||||
// Safari 17 - MDN browser-compat-data 6.0.0, html.global_attributes.popover)
|
||||
// does not open it.
|
||||
type Sheet struct {
|
||||
ID string
|
||||
Tag Tag
|
||||
@@ -163,7 +169,9 @@ func StatusTag(status string) string {
|
||||
|
||||
// wbrAt escapes s and puts a <wbr> after every @ (template function wbr_at):
|
||||
// an address in a table cell breaks where it reads naturally, so the column can
|
||||
// shrink without splitting a name mid-word.
|
||||
// shrink without splitting a name mid-word. Go 1.26, text/template funcs.go
|
||||
// HTMLEscape(): HTMLEscapeString escapes < > & ' " and NUL, so the only markup
|
||||
// in the result is the <wbr> added here.
|
||||
func wbrAt(s string) template.HTML {
|
||||
return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(s), "@", "@<wbr>"))
|
||||
}
|
||||
@@ -283,6 +291,10 @@ type Box struct {
|
||||
Poll string
|
||||
// OOB marks the box to be swapped into the page by its ID when it comes
|
||||
// in a polled fragment beside the element that asked for the fragment.
|
||||
// htmx 2.0.4, src/htmx.js swap(): the response replaces only the target;
|
||||
// findAndSwapOobElements() takes every hx-swap-oob element out of it,
|
||||
// nested ones too (allowNestedOobSwaps defaults true), and oobSwap()
|
||||
// with "true" swaps it outerHTML over the element of its id.
|
||||
OOB bool
|
||||
// DomainPick: see Box.
|
||||
DomainPick bool
|
||||
|
||||
@@ -16,7 +16,10 @@ import (
|
||||
// a response carries no validator at all and the browser is free to guess how
|
||||
// long to keep it — which is how a replaced favicon keeps showing the old mark
|
||||
// long after a deploy. Hashing the content gives every asset a validator that
|
||||
// changes exactly when the asset does.
|
||||
// changes exactly when the asset does. RFC 9111 § 4.2.2: without explicit
|
||||
// freshness a cache may assign a heuristic lifetime; Go 1.26, net/http fs.go
|
||||
// ServeContent(): no Last-Modified for a zero modtime, and an ETag the caller
|
||||
// set answers If-None-Match.
|
||||
var staticETags = buildStaticETags()
|
||||
|
||||
func buildStaticETags() map[string]string {
|
||||
@@ -50,6 +53,8 @@ func buildStaticETags() map[string]string {
|
||||
// handful of small files on a single-operator panel that trade is the right way
|
||||
// round — correctness after a deploy matters more than saving the round trip.
|
||||
// http.ServeContent answers the conditional request from the ETag we set here.
|
||||
// RFC 9111 § 5.2.2.4: no-cache lets a cache store the response but not reuse
|
||||
// it without validating it first.
|
||||
func StaticHandler() http.Handler {
|
||||
files := http.FileServer(http.FS(assetsFS))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
// read from the field itself, so it can never drift from what is shown. Where
|
||||
// the clipboard API is absent (plain HTTP outside localhost) the value is
|
||||
// selected instead, ready for the operator's own Ctrl+C. The button's icon
|
||||
// stays; only its word changes for a moment.
|
||||
// stays; only its word changes for a moment. W3C Clipboard API, § Navigator
|
||||
// interface: navigator.clipboard is [SecureContext].
|
||||
document.addEventListener("click", function (ev) {
|
||||
var button = ev.target.closest("button[data-copy]");
|
||||
if (!button) {
|
||||
@@ -69,6 +70,9 @@
|
||||
// inline script. 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.
|
||||
// HTML Living Standard, form-control-infrastructure.html#form-submission-algorithm:
|
||||
// submit is fired with bubbles and cancelable true, and a cancelled one
|
||||
// ends the submission.
|
||||
document.addEventListener("submit", function (ev) {
|
||||
var form = ev.target.closest("form[data-confirm]");
|
||||
if (form && !window.confirm(form.dataset.confirm)) {
|
||||
@@ -140,6 +144,10 @@
|
||||
form.querySelectorAll(rule.target).forEach(function (target) {
|
||||
// hidden alone does not hide an element whose own stylesheet gives it a
|
||||
// display (Bulma's .box), so Bulma's is-hidden helper goes with it.
|
||||
// HTML Living Standard, rendering.html#hidden-elements: [hidden] is
|
||||
// display: none in the user agent's sheet, which an author rule such
|
||||
// as Bulma 1.0.4's .box { display: block } overrides; .is-hidden is
|
||||
// display: none !important.
|
||||
target.hidden = !show;
|
||||
target.classList.toggle("is-hidden", !show);
|
||||
if (!show && rule.clearWhenHidden) {
|
||||
@@ -169,19 +177,26 @@
|
||||
initShowWhen(document);
|
||||
});
|
||||
|
||||
// htmx:load fires for every element htmx puts into the page, so swapped-in
|
||||
// markup starts with its blocks shown or hidden the way a full page does.
|
||||
// htmx:load fires for each top-level element htmx puts into the page, so
|
||||
// swapped-in markup starts with its blocks shown or hidden the way a full
|
||||
// page does. htmx 2.0.4, src/htmx.js insertNodesBefore() queues
|
||||
// makeAjaxLoadTask(child) for each element of the swapped content, which
|
||||
// triggers htmx:load on it (detail.elt); initShowWhen looks inside it.
|
||||
document.addEventListener("htmx:load", function (ev) {
|
||||
initShowWhen(ev.detail.elt);
|
||||
});
|
||||
|
||||
// --- Adaptive monitoring polling ---------------------------------------
|
||||
// The four monitoring fragments carry data-poll and hx-trigger="load" for
|
||||
// the first fetch only. panel.js schedules the rest: 5 s while the operator
|
||||
// the first fetch only (htmx 2.0.4, src/htmx.js addTriggerHandler(): "load"
|
||||
// fires once, when the element is first processed). panel.js schedules the rest: 5 s while the operator
|
||||
// is active on the page, 30 s when the tab is visible but idle, and nothing
|
||||
// while the tab is hidden. hx-trigger="every Ns [expr]" could express some
|
||||
// of that, but the filter is evaluated with `new Function`, which the
|
||||
// panel's CSP (default-src 'self', no 'unsafe-eval') would silently break.
|
||||
// panel's CSP (default-src 'self', no 'unsafe-eval') would break: htmx
|
||||
// 2.0.4, src/htmx.js maybeGenerateConditional() compiles the filter with
|
||||
// Function(); the EvalError is caught, logged as htmx:syntax:error, and the
|
||||
// trigger is kept without its filter, so it would fire on every tick.
|
||||
var pollActiveMs = 5000;
|
||||
var pollIdleMs = 30000;
|
||||
// No pointer/keyboard/scroll input for this long → treat the tab as idle.
|
||||
@@ -238,6 +253,9 @@
|
||||
}
|
||||
// Swapped-in markup still carries hx-trigger="load"; strip it so htmx does
|
||||
// not issue a duplicate GET on top of the response we just received.
|
||||
// htmx 2.0.4, src/htmx.js swap(): htmx:afterSwap fires on the new
|
||||
// elements before doSettle() runs the settle tasks, and only those
|
||||
// (makeAjaxLoadTask -> processNode) act on hx-trigger.
|
||||
el.removeAttribute("hx-trigger");
|
||||
schedulePoll(el);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,11 @@
|
||||
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 has to make an exception for, so it
|
||||
is switched off here. */}}
|
||||
is switched off here. htmx 2.0.4, src/htmx.js: the ready handler runs
|
||||
mergeMetaConfig() before insertIndicatorStyles(), which adds the element
|
||||
only while includeIndicatorStyles is not false. Both scripts are defer:
|
||||
they run in this order once parsing ends and before DOMContentLoaded
|
||||
(HTML Living Standard, parsing.html#the-end). */}}
|
||||
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script src="/static/panel.js" defer></script>
|
||||
|
||||
Reference in New Issue
Block a user