diff --git a/README.md b/README.md index c5e7daf..ed1cb2e 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ 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 @@ -125,8 +125,8 @@ 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 @@ -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 diff --git a/hostpolicy/policy.go b/hostpolicy/policy.go index d5f7dda..4f2d6d5 100644 --- a/hostpolicy/policy.go +++ b/hostpolicy/policy.go @@ -48,11 +48,17 @@ 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 { @@ -60,12 +66,13 @@ type attempts struct { 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])?$`) @@ -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 { @@ -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 } @@ -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 { @@ -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) diff --git a/hostpolicy/preview_access_test.go b/hostpolicy/preview_access_test.go index b5ded75..7c4ef65 100644 --- a/hostpolicy/preview_access_test.go +++ b/hostpolicy/preview_access_test.go @@ -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 { @@ -23,12 +23,16 @@ 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") @@ -36,7 +40,7 @@ func previewAccessFixture(t *testing.T, callback func(*http.Request, Tenant) boo 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) diff --git a/hostpolicy/preview_query.go b/hostpolicy/preview_query.go new file mode 100644 index 0000000..d7b9d72 --- /dev/null +++ b/hostpolicy/preview_query.go @@ -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 +} diff --git a/hostpolicy/preview_query_test.go b/hostpolicy/preview_query_test.go new file mode 100644 index 0000000..cf4686c --- /dev/null +++ b/hostpolicy/preview_query_test.go @@ -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") + } +}