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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,29 @@ API route metadata is returned only when the caller passes `authorized: true`.
That keeps route discovery behind the host's authz check while preserving the
strict protobuf contract declared in `plugin.contracts.json`.

### Scoped access to private preview content

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
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,
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
as human Basic access, and grants no CMS or platform authority. Basic requests
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.

## Persistence and backup

CMS page documents are durable application state. Operators backing this plugin
Expand Down
84 changes: 83 additions & 1 deletion hostpolicy/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ package hostpolicy
import (
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"golang.org/x/crypto/bcrypt"
"net"
"net/http"
"net/netip"
"net/url"
"path"
"regexp"
"strings"
"sync"
Expand Down Expand Up @@ -43,6 +45,14 @@ type Config struct {
Resolve func(context.Context, string) (Tenant, bool)
TrustedProxies []netip.Prefix
Transport TransportMode
// 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,
// 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
}
type success struct{ until time.Time }
type attempts struct {
Expand All @@ -59,6 +69,7 @@ type Gate struct {
}

var hostPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$`)
var previewBcryptEncoding = base64.NewEncoding("./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789").WithPadding(base64.NoPadding).Strict()

func New(cfg Config, next http.Handler) (*Gate, error) {
if cfg.Resolve == nil || next == nil {
Expand Down Expand Up @@ -207,6 +218,10 @@ 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) {
g.servePrivate(private, r)
return
}
if !ok || username != tenant.Slug || len(password) == 0 || len(password) > 72 {
challenge(private)
return
Expand Down Expand Up @@ -261,6 +276,73 @@ func (g *Gate) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
}
g.servePrivate(private, r)
}

// Cost validates the header, not the encoded payload. Callback authority needs
// a complete supported hash; human Basic retains the existing bcrypt verifier.
// The caller has already enforced the configured cost bounds.
func validPreviewPasswordHash(hash string) bool {
if len(hash) != 60 || hash[6] != '$' {
return false
}
switch hash[:4] {
case "$2a$", "$2b$", "$2y$":
default:
return false
}
for _, field := range []struct {
encoded string
size int
}{{hash[7:29], 16}, {hash[29:], 23}} {
decoded, err := previewBcryptEncoding.DecodeString(field.encoded)
if err != nil || len(decoded) != field.size || previewBcryptEncoding.EncodeToString(decoded) != field.encoded {
return false
}
}
return true
}

// 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 {
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.Opaque != "" || r.URL.User != nil || r.ContentLength != 0 || len(r.TransferEncoding) != 0 {
return false
}
if values := r.Header.Values("Authorization"); len(values) > 1 {
return false
} else if len(values) == 1 {
fields := strings.Fields(values[0])
if len(fields) > 0 && strings.EqualFold(fields[0], "Basic") {
return false
}
}
target := r.URL.Path
if !strings.HasPrefix(target, "/") || strings.Contains(target, "//") || strings.ContainsAny(target, "\\%;") || r.URL.EscapedPath() != target {
return false
}
clean := path.Clean(target)
if clean != target && clean+"/" != target {
return false
}
for _, segment := range strings.Split(target, "/") {
if strings.HasPrefix(segment, ".") {
return false
}
}
lower := strings.ToLower(target)
for _, prefix := range []string{"/api", "/admin", "/auth", "/login", "/logout", "/register", "/oauth", "/__", "/internal", "/control", "/debug", "/healthz", "/readyz", "/livez", "/metrics"} {
if strings.HasPrefix(lower, prefix) {
return false
}
}
return true
}

func (g *Gate) servePrivate(private *privateWriter, r *http.Request) {
safe := r.Clone(r.Context())
safe.Header = r.Header.Clone()
safe.Header.Del("Authorization")
Expand All @@ -276,7 +358,7 @@ func (g *Gate) secure(r *http.Request) bool {
if g.cfg.Transport == TransportAppPlatformHTTPS {
// App Platform upgrades every external HTTP request before forwarding it
// to this HTTP listener. This explicit provider invariant says nothing
// about tenant authority: the protected Host still always needs Basic.
// about tenant authority: the protected Host still needs verified access.
return true
}
if r.TLS != nil {
Expand Down
Loading
Loading