Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,16 +117,16 @@ strict protobuf contract declared in `plugin.contracts.json`.
Hosts may optionally set `hostpolicy.Config.PreviewAccess` to a
`func(*http.Request, hostpolicy.Tenant) bool` that verifies scoped preview
authority on every request. The host must verify its credential's audience,
exact origin, tenant, exact content/asset path, and current grant. The plugin
exact origin, tenant, exact content/asset path and raw query, and current grant. The plugin
does not issue credentials or cache callback approvals.

The callback is considered only after the existing HTTPS, current canonical
domain ownership, and valid tenant password-hash checks, for tenants resolved
with kind `preview`. Callback access requires a complete 60-byte `$2a$`, `$2b$`
or `$2y$` hash with canonical bcrypt-base64 salt and checksum encoding; a cost
header alone does not establish configured password protection. Its ceiling
allows only GET/HEAD with canonical unencoded content paths and no query or
request body. API, admin, auth, internal, control,
allows only GET/HEAD with canonical unencoded content paths and no request body.
Queries remain denied by default. API, admin, auth, internal, control,
health, metrics, hidden paths and path aliases are excluded. It receives a
request clone; changes to it cannot change the downstream target. An approval
uses the same credential-stripping and private/no-store/noindex response path
Expand All @@ -135,6 +135,17 @@ always retain password verification. A nil callback or a denial retains the
existing Basic challenge. Compose host policy outside every content wrapper;
this optional callback does not replace the host's editor or API gates.

A host may declare `PreviewAccessQueryTargets`, a finite map of tenant IDs to
exact queried targets, such as tenant 234's `/contact?interest=voice-study`.
Declarations require a callback and accept at most 16 targets per tenant; each
target is at most 2048 bytes with at most eight unique sorted literal query keys
and a 512-byte query. Names and values use unreserved ASCII, excluding encoded
aliases, duplicate keys, empty values and fragments. CMS privately copies the
declarations and rejects every unlisted queried variant before invoking the
callback. The callback must still verify the complete target against both the
current grant and current tenant policy. A declared target alone grants no
access, and a bare allowed path never implicitly allows its query variants.

## Persistence and backup

CMS page documents are durable application state. Operators backing this plugin
Expand Down
62 changes: 48 additions & 14 deletions hostpolicy/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,24 +48,31 @@ type Config struct {
// PreviewAccess optionally verifies host-owned, scoped preview authority on
// every eligible request. It is considered only for protected preview tenants,
// after transport/canonical policy checks, for canonical content GET/HEAD
// targets without queries. Basic authentication and all privileged routes keep
// their existing gates. The callback must verify the exact origin, path, tenant,
// targets without queries, or explicitly declared query targets below. Basic
// authentication and all privileged routes keep their existing gates. The
// callback must verify the exact origin, path AND raw query, tenant,
// audience and current grant; it grants no CMS/admin authority. Nil or false
// preserves Basic authentication. Its request clone must not be retained.
PreviewAccess func(*http.Request, Tenant) bool
// PreviewAccessQueryTargets is an optional finite per-tenant ceiling, derived
// from validated host declarations. Only exact canonical GET/HEAD targets may
// reach PreviewAccess. A nil/empty map retains the default query denial; these
// declarations confer no authority without the callback's current grant check.
PreviewAccessQueryTargets map[int64][]string
}
type success struct{ until time.Time }
type attempts struct {
count int
until time.Time
}
type Gate struct {
cfg Config
next http.Handler
mu sync.Mutex
cache map[[32]byte]success
failures map[string]attempts
kdf chan struct{}
cfg Config
next http.Handler
mu sync.Mutex
cache map[[32]byte]success
failures map[string]attempts
kdf chan struct{}
previewQueries map[int64]map[string]struct{}
}

var hostPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$`)
Expand Down Expand Up @@ -131,7 +138,25 @@ func New(cfg Config, next http.Handler) (*Gate, error) {
}
cfg.Policies = copyPolicies
cfg.TrustedProxies = append([]netip.Prefix(nil), cfg.TrustedProxies...)
return &Gate{cfg: cfg, next: next, cache: map[[32]byte]success{}, failures: map[string]attempts{}, kdf: make(chan struct{}, 2)}, nil
queries := map[int64]map[string]struct{}{}
for tenantID, targets := range cfg.PreviewAccessQueryTargets {
if cfg.PreviewAccess == nil || tenantID <= 0 || len(targets) == 0 || len(targets) > 16 {
return nil, errors.New("invalid preview query target declaration")
}
queries[tenantID] = map[string]struct{}{}
for _, target := range targets {
if !canonicalPreviewQueryTarget(target) {
return nil, errors.New("invalid preview query target declaration")
}
if _, duplicate := queries[tenantID][target]; duplicate {
return nil, errors.New("duplicate preview query target declaration")
}
queries[tenantID][target] = struct{}{}
}
}
// Keep only the private immutable copy, never caller-owned maps/slices.
cfg.PreviewAccessQueryTargets = nil
return &Gate{cfg: cfg, next: next, cache: map[[32]byte]success{}, failures: map[string]attempts{}, kdf: make(chan struct{}, 2), previewQueries: queries}, nil
}

func normalize(raw string) string {
Expand Down Expand Up @@ -218,7 +243,7 @@ func (g *Gate) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
username, password, ok := r.BasicAuth()
if !ok && g.cfg.PreviewAccess != nil && previewAccessRequest(r, tenant) && validPreviewPasswordHash(hash) && g.cfg.PreviewAccess(r.Clone(r.Context()), tenant) {
if !ok && g.cfg.PreviewAccess != nil && g.previewAccessRequest(r, tenant) && validPreviewPasswordHash(hash) && g.cfg.PreviewAccess(r.Clone(r.Context()), tenant) {
g.servePrivate(private, r)
return
}
Expand Down Expand Up @@ -306,12 +331,17 @@ func validPreviewPasswordHash(hash string) bool {
// previewAccessRequest is a ceiling on the host callback, not a content
// allowlist. In particular, encoded or normalized aliases must never turn an
// approved content path into an application's control endpoint.
func previewAccessRequest(r *http.Request, tenant Tenant) bool {
func (g *Gate) previewAccessRequest(r *http.Request, tenant Tenant) bool {
if tenant.Kind != "preview" || (r.Method != http.MethodGet && r.Method != http.MethodHead) || r.URL == nil ||
r.URL.RawQuery != "" || r.URL.ForceQuery || r.URL.RawPath != "" || r.URL.Fragment != "" || r.URL.RawFragment != "" ||
r.URL.ForceQuery || r.URL.RawPath != "" || r.URL.Fragment != "" || r.URL.RawFragment != "" ||
r.URL.Opaque != "" || r.URL.User != nil || r.ContentLength != 0 || len(r.TransferEncoding) != 0 {
return false
}
if r.URL.RawQuery != "" {
if _, declared := g.previewQueries[tenant.ID][r.URL.Path+"?"+r.URL.RawQuery]; !declared {
return false
}
}
if values := r.Header.Values("Authorization"); len(values) > 1 {
return false
} else if len(values) == 1 {
Expand All @@ -320,8 +350,12 @@ func previewAccessRequest(r *http.Request, tenant Tenant) bool {
return false
}
}
target := r.URL.Path
if !strings.HasPrefix(target, "/") || strings.Contains(target, "//") || strings.ContainsAny(target, "\\%;") || r.URL.EscapedPath() != target {
return previewAccessPath(r.URL)
}

func previewAccessPath(u *url.URL) bool {
target := u.Path
if !strings.HasPrefix(target, "/") || strings.Contains(target, "//") || strings.ContainsAny(target, "\\%;") || u.EscapedPath() != target {
return false
}
clean := path.Clean(target)
Expand Down
10 changes: 7 additions & 3 deletions hostpolicy/preview_access_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"golang.org/x/crypto/bcrypt"
)

func previewAccessFixture(t *testing.T, callback func(*http.Request, Tenant) bool, transport TransportMode) (*Gate, map[string]Tenant) {
func previewAccessFixture(t *testing.T, callback func(*http.Request, Tenant) bool, transport TransportMode, queryTargets ...map[int64][]string) (*Gate, map[string]Tenant) {
t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte("human-review-fixture"), 10)
if err != nil {
Expand All @@ -23,20 +23,24 @@ func previewAccessFixture(t *testing.T, callback func(*http.Request, Tenant) boo
"a.preview.test": {1, "a", "preview"}, "a.alternate.test": {1, "a", "vanity"},
"b.preview.test": {2, "b", "preview"}, "unconfigured.preview.test": {3, "unconfigured", "preview"},
}
var queries map[int64][]string
if len(queryTargets) == 1 {
queries = queryTargets[0]
}
gate, err := New(Config{AdminHost: "admin.test", PlatformHost: "platform.test", Transport: transport,
Resolve: func(_ context.Context, host string) (Tenant, bool) { tenant, ok := hosts[host]; return tenant, ok },
Policies: map[string]Policy{
"a": {Primary: "primary.test", RedirectAliases: []string{"www.primary.test"}, PasswordHash: string(hash)},
"b": {PasswordHash: string(hash)},
}, PreviewAccess: callback,
}, PreviewAccess: callback, PreviewAccessQueryTargets: queries,
}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.Host, ".preview.test") && (r.Header.Get("Authorization") != "" || r.Header.Get("Proxy-Authorization") != "") {
t.Error("preview credential reached content handler")
}
w.Header().Set("Cache-Control", "public, max-age=31536000")
w.Header().Set("X-Robots-Tag", "index")
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
w.Write([]byte(r.Method + " " + r.Host + " " + r.URL.Path))
w.Write([]byte(r.Method + " " + r.Host + " " + r.URL.RequestURI()))
}))
if err != nil {
t.Fatal(err)
Expand Down
42 changes: 42 additions & 0 deletions hostpolicy/preview_query.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package hostpolicy

import (
"net/url"
"path"
"strings"
)

func canonicalPreviewQueryTarget(target string) bool {
if len(target) > 2048 {
return false
}
u, err := url.Parse(target)
if err != nil || u.Scheme != "" || u.Host != "" || u.User != nil || u.Opaque != "" || u.RawPath != "" || u.Fragment != "" || u.RawFragment != "" || u.ForceQuery || u.String() != target || path.Clean(u.Path) != u.Path || !previewAccessPath(u) || u.RawQuery == "" || len(u.RawQuery) > 512 {
return false
}
pairs := strings.Split(u.RawQuery, "&")
if len(pairs) > 8 {
return false
}
previous := ""
for _, pair := range pairs {
key, value, found := strings.Cut(pair, "=")
if !found || !previewQueryAtom(key, 64) || !previewQueryAtom(value, 128) || key <= previous {
return false
}
previous = key
}
return true
}

func previewQueryAtom(value string, max int) bool {
if len(value) == 0 || len(value) > max {
return false
}
for _, c := range value {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || strings.ContainsRune("-._~", c)) {
return false
}
}
return true
}
110 changes: 110 additions & 0 deletions hostpolicy/preview_query_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package hostpolicy

import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestPreviewQueryTargetCeilingIsFiniteTenantBoundAndImmutable(t *testing.T) {
target := "/contact?interest=voice-study"
declarations := map[int64][]string{1: {target}}
calls := 0
current := true
gate, _ := previewAccessFixture(t, func(r *http.Request, tenant Tenant) bool {
calls++
return current && tenant.ID == 1 && r.Host == "a.preview.test" && r.Header.Get("Authorization") == "Bearer fixture-a" && r.URL.RequestURI() == target
}, TransportStrict, declarations)
for _, method := range []string{"GET", "HEAD"} {
r := httptest.NewRequest(method, "https://a.preview.test"+target, nil)
r.Header.Set("Authorization", "Bearer fixture-a")
w := httptest.NewRecorder()
gate.ServeHTTP(w, r)
if w.Code != 200 || w.Body.String() != method+" a.preview.test "+target || w.Header().Get("Cache-Control") != "private, no-store" || !strings.Contains(w.Header().Get("X-Robots-Tag"), "noindex") {
t.Fatal("declared query lost its exact target, private response or stripped credentials")
}
}
// Caller-owned declarations cannot change the constructed gate's ceiling.
declarations[1][0] = "/contact?interest=booking"
declarations[2] = []string{target}
before := calls
for _, bad := range []string{
"/contact?interest=booking", "/contact?interest=voice-study&other=1", "/contact?interest=voice-study&interest=voice-study",
"/contact?%69nterest=voice-study", "/contact?interest=%76oice-study", "/contact?interest=voice+study", "/contact?interest=voice-study&",
"/contact?interest=voice-study;other=1", "/contact?", "/contact", "/%63ontact?interest=voice-study", "/admin?interest=voice-study",
} {
r := httptest.NewRequest("GET", "https://a.preview.test"+bad, nil)
r.Header.Set("Authorization", "Bearer fixture-a")
w := httptest.NewRecorder()
gate.ServeHTTP(w, r)
if w.Code != 401 {
t.Fatalf("undeclared query opened: %s", bad)
}
}
// A bare allowed content path can still reach the callback, which denies it;
// all queried variants above must be rejected before callback invocation.
if calls != before+1 {
t.Fatalf("query ceiling called the callback for undeclared variants: %d", calls-before)
}
r := httptest.NewRequest("GET", "https://b.preview.test"+target, nil)
r.Header.Set("Authorization", "Bearer fixture-a")
w := httptest.NewRecorder()
gate.ServeHTTP(w, r)
if w.Code != 401 || calls != before+1 {
t.Fatal("query target leaked into another tenant's ceiling")
}
for _, alter := range []func(*http.Request){
func(r *http.Request) { r.Method = "POST" },
func(r *http.Request) { r.ContentLength = 1 },
func(r *http.Request) { r.TransferEncoding = []string{"chunked"} },
func(r *http.Request) { r.URL.Fragment = "control" },
func(r *http.Request) { r.URL.ForceQuery = true },
func(r *http.Request) { r.Header.Add("Authorization", "Bearer duplicate") },
} {
r := httptest.NewRequest("GET", "https://a.preview.test"+target, nil)
r.Header.Set("Authorization", "Bearer fixture-a")
alter(r)
w := httptest.NewRecorder()
gate.ServeHTTP(w, r)
if w.Code != 401 || calls != before+1 {
t.Fatal("ambiguous or mutating query request reached callback")
}
}
current = false
r = httptest.NewRequest("GET", "https://a.preview.test"+target, nil)
r.Header.Set("Authorization", "Bearer fixture-a")
w = httptest.NewRecorder()
gate.ServeHTTP(w, r)
if w.Code != 401 || calls != before+2 {
t.Fatal("declared query bypassed fresh callback revocation")
}
}

func TestPreviewQueryTargetDeclarationsRefuseInvalidConfiguration(t *testing.T) {
gate, _ := previewAccessFixture(t, func(*http.Request, Tenant) bool { return true }, TransportStrict)
for _, target := range []string{
"/contact", "/contact?", "/contact?interest=", "/contact?interest", "/contact?interest=voice-study&", "/contact?interest=voice-study&interest=voice-study",
"/contact?interest=%76oice-study", "/contact?%69nterest=voice-study", "/contact?interest=voice+study", "/contact?interest=voice-study#x",
"/contact?interest=voice-study;other=1", "/contact?z=1&a=2", "/contact?interest=" + strings.Repeat("a", 129), "/admin?interest=voice-study", "/contact/?interest=voice-study",
} {
cfg := gate.cfg
cfg.PreviewAccessQueryTargets = map[int64][]string{1: {target}}
if _, err := New(cfg, gate.next); err == nil {
t.Fatalf("unsafe query declaration accepted: %s", target)
}
}
for _, declaration := range []map[int64][]string{{0: {"/contact?interest=voice-study"}}, {1: {}}, {1: {"/contact?interest=voice-study", "/contact?interest=voice-study"}}} {
cfg := gate.cfg
cfg.PreviewAccessQueryTargets = declaration
if _, err := New(cfg, gate.next); err == nil {
t.Fatal("invalid or duplicate tenant query declaration accepted")
}
}
cfg := gate.cfg
cfg.PreviewAccess = nil
cfg.PreviewAccessQueryTargets = map[int64][]string{1: {"/contact?interest=voice-study"}}
if _, err := New(cfg, gate.next); err == nil {
t.Fatal("query declaration accepted without current-grant callback")
}
}
Loading