hub v0.152.0: operator access tokens with scopes (09 §3 decision 251, operator 2026-10-15 11:38)
gates / gates (push) Successful in 6m44s
gates / gates (push) Successful in 6m44s
- Configuration → Access tokens: named, scoped (group read/write), expiring (≤365 d), shown once, stored SHA-256 only, every use logged by name. Bearer fhp_… on the web surface only; /api/v1 refuses it. - Route table = the code's single source (web/routes.go), pinned to ServeHTTP's switch; session-only: secrets shown/minted, deletes/resets, password, tokens. - R-952 closed: /api/v1 route table names each caller, pinned to the switch; no-credential refusal test. - Fail-open fixed: an empty operator password served the UI open (main skipped RequireAuth). Live hub never open. - 05 §17, 04 §6.2, 13, 09 §3 251; register 155→157 (+R-1054, R-1055, R-1056, −R-952); STATUS. - website: prowlarr-logo.svg (byte copy from felhom.eu-media) — the site gate refused every push. Not deployed — waits for the operator's yes. Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
1 parent
e92501f5a1
commit
d62cc08b2e
37 files changed
+2567
-21
No files matched your search
@@ -245,6 +245,15 @@ func (h *Handler) requireGlobalKey(w http.ResponseWriter, r *http.Request) bool
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1")
|
||||
|
||||
// An operator access token (`05` §17, prefix fhp_) never reaches the box API: not a box route, not a global-key
|
||||
// route. Refused here, before any handler, so no route can accept one by accident. Pinned by
|
||||
// TestAPI_AccessTokenIsRefusedEverywhere.
|
||||
if auth := r.Header.Get("Authorization"); strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")), store.APITokenPrefix) {
|
||||
h.logger.Printf("[WARN] access token refused on the box API: %s %s", r.Method, r.URL.Path)
|
||||
http.Error(w, "Forbidden: access tokens are not accepted on /api/v1", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.Method == http.MethodPost && path == "/report":
|
||||
h.handleReport(w, r)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
// The box-API route table (hub v0.152.0, R-952, `05` §17.6). EVERY case of ServeHTTP's route switch has one entry,
|
||||
// in the same order and with the same match, naming WHO may call it. TestAPIRouteTable_MatchesSwitch reads the
|
||||
// switch from handler.go's syntax tree and fails on a route added without an entry here; TestAPIRoutes_NoCredential
|
||||
// sends every non-public route a request with no credential and requires a refusal. The table documents and pins;
|
||||
// each handler still does its own check (the table is not consulted at run time).
|
||||
//
|
||||
// An operator access token (fhp_…) is refused on EVERY route here, before the switch (handler.go ServeHTTP).
|
||||
|
||||
type apiAuth string
|
||||
|
||||
const (
|
||||
authPublic apiAuth = "public" // no credential by design — the reason is on the entry
|
||||
authCustomer apiAuth = "customer" // the household's controller key (or the global key)
|
||||
authHost apiAuth = "host" // the box's own host key, self-scoped (or the global key)
|
||||
authGlobal apiAuth = "global" // the operator/global key only
|
||||
authPassphrase apiAuth = "passphrase" // X-Retrieval-Password of that household
|
||||
authAppliance apiAuth = "appliance" // the appliance's own bearer token
|
||||
authRecovery apiAuth = "recovery" // only while the operator has armed recovery mode for the host
|
||||
)
|
||||
|
||||
type apiRoute struct {
|
||||
Method string
|
||||
Exact, Prefix, Suffix, Contains string
|
||||
Auth apiAuth
|
||||
Why string // for authPublic
|
||||
}
|
||||
|
||||
var apiRoutes = []apiRoute{
|
||||
{Method: "POST", Exact: "/report", Auth: authCustomer},
|
||||
{Method: "GET", Exact: "/wait", Auth: authCustomer},
|
||||
{Method: "POST", Exact: "/appliance/register", Auth: authPublic, Why: "a secret-free ISO box registers itself; per-IP rate-limited, answers no enumeration"},
|
||||
{Method: "GET", Exact: "/appliance/poll", Auth: authAppliance},
|
||||
{Method: "POST", Exact: "/host-report", Auth: authHost},
|
||||
{Method: "POST", Exact: "/host-enroll", Auth: authPassphrase},
|
||||
{Method: "POST", Exact: "/admin/hosts", Auth: authGlobal},
|
||||
{Method: "PUT", Prefix: "/hosts/", Suffix: "/escrow", Auth: authHost},
|
||||
{Method: "GET", Prefix: "/hosts/", Suffix: "/escrow", Auth: authHost},
|
||||
{Method: "GET", Prefix: "/hosts/", Suffix: "/escrow/retained", Auth: authHost},
|
||||
{Method: "PUT", Prefix: "/hosts/", Suffix: "/recovery-credential", Auth: authHost},
|
||||
{Method: "GET", Prefix: "/admin/hosts/", Suffix: "/recovery-credential", Auth: authGlobal},
|
||||
{Method: "PUT", Prefix: "/admin/hosts/", Suffix: "/recovery-mode", Auth: authGlobal},
|
||||
{Method: "DELETE", Prefix: "/admin/hosts/", Suffix: "/recovery-mode", Auth: authGlobal},
|
||||
{Method: "POST", Prefix: "/hosts/", Suffix: "/re-enroll", Auth: authRecovery},
|
||||
{Method: "GET", Prefix: "/hosts/", Suffix: "/restore-directive", Auth: authHost},
|
||||
{Method: "POST", Prefix: "/hosts/", Suffix: "/wg", Auth: authHost},
|
||||
{Method: "POST", Prefix: "/hosts/", Suffix: "/pbs/consume-token", Auth: authHost},
|
||||
{Method: "POST", Prefix: "/hosts/", Suffix: "/os-report", Auth: authHost},
|
||||
{Method: "GET", Prefix: "/hosts/", Suffix: "/desired-state", Auth: authHost},
|
||||
{Method: "GET", Prefix: "/hosts/", Suffix: "/jobs", Auth: authHost},
|
||||
{Method: "DELETE", Prefix: "/hosts/", Contains: "/jobs/", Auth: authHost},
|
||||
{Method: "PUT", Prefix: "/admin/hosts/", Suffix: "/desired-state", Auth: authGlobal},
|
||||
{Method: "POST", Prefix: "/admin/hosts/", Suffix: "/jobs", Auth: authGlobal},
|
||||
{Method: "PUT", Exact: "/admin/wg/endpoint", Auth: authGlobal},
|
||||
{Method: "GET", Exact: "/admin/wg/endpoint", Auth: authGlobal},
|
||||
{Method: "POST", Exact: "/admin/wg/peers", Auth: authGlobal},
|
||||
{Method: "DELETE", Exact: "/admin/wg/peers", Auth: authGlobal},
|
||||
{Method: "GET", Exact: "/admin/wg/peers", Auth: authGlobal},
|
||||
{Method: "PUT", Exact: "/admin/wg/operator-peer", Auth: authGlobal},
|
||||
{Method: "GET", Exact: "/admin/wg/operator-peer", Auth: authGlobal},
|
||||
{Method: "POST", Exact: "/claim/reset-request", Auth: authCustomer},
|
||||
{Method: "POST", Exact: "/event", Auth: authCustomer},
|
||||
{Method: "POST", Exact: "/mail", Auth: authCustomer},
|
||||
{Method: "POST", Exact: "/notify", Auth: authCustomer},
|
||||
{Method: "POST", Exact: "/preferences", Auth: authCustomer},
|
||||
{Method: "GET", Exact: "/customers", Auth: authGlobal},
|
||||
{Method: "GET", Prefix: "/customers/", Auth: authGlobal},
|
||||
{Method: "GET", Prefix: "/recovery/", Auth: authPassphrase},
|
||||
{Method: "GET", Prefix: "/config/", Auth: authPassphrase},
|
||||
{Method: "POST", Prefix: "/offsite/consume-password/", Auth: authCustomer},
|
||||
{Method: "POST", Prefix: "/offsite/register-key/", Auth: authCustomer},
|
||||
{Method: "POST", Prefix: "/offsite/confirm-key/", Auth: authCustomer},
|
||||
{Method: "POST", Prefix: "/offsite/move-aside/", Auth: authCustomer},
|
||||
{Method: "POST", Prefix: "/offsite/window-open/", Auth: authCustomer},
|
||||
{Method: "POST", Prefix: "/offsite/window-close/", Auth: authCustomer},
|
||||
{Method: "POST", Prefix: "/offsite/abandon-request/", Auth: authCustomer},
|
||||
{Method: "POST", Prefix: "/offsite/abandon-cancel/", Auth: authCustomer},
|
||||
{Method: "POST", Prefix: "/offsite/abandon-status/", Auth: authCustomer},
|
||||
{Method: "GET", Prefix: "/artifacts/", Auth: authPassphrase},
|
||||
{Method: "GET", Exact: "/assets/manifest", Auth: authCustomer},
|
||||
{Method: "GET", Prefix: "/assets/file/", Auth: authCustomer},
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// apiSwitchRoutes reads ServeHTTP's route switch from handler.go: one apiRoute (method + match) per case, in order.
|
||||
func apiSwitchRoutes(t *testing.T) []apiRoute {
|
||||
t.Helper()
|
||||
f, err := parser.ParseFile(token.NewFileSet(), "handler.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sw *ast.SwitchStmt
|
||||
for _, d := range f.Decls {
|
||||
if fd, ok := d.(*ast.FuncDecl); ok && fd.Name.Name == "ServeHTTP" && fd.Recv != nil {
|
||||
for _, st := range fd.Body.List {
|
||||
if s, ok := st.(*ast.SwitchStmt); ok && s.Tag == nil {
|
||||
sw = s
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if sw == nil {
|
||||
t.Fatal("ServeHTTP route switch not found in handler.go")
|
||||
}
|
||||
lit := func(e ast.Expr) string {
|
||||
bl, ok := e.(*ast.BasicLit)
|
||||
if !ok {
|
||||
t.Fatalf("route predicate argument is not a literal at %v", e.Pos())
|
||||
}
|
||||
v, _ := strconv.Unquote(bl.Value)
|
||||
return v
|
||||
}
|
||||
var conj func(e ast.Expr) []ast.Expr
|
||||
conj = func(e ast.Expr) []ast.Expr {
|
||||
if b, ok := e.(*ast.BinaryExpr); ok && b.Op == token.LAND {
|
||||
return append(conj(b.X), conj(b.Y)...)
|
||||
}
|
||||
return []ast.Expr{e}
|
||||
}
|
||||
var out []apiRoute
|
||||
for _, c := range sw.Body.List {
|
||||
cc := c.(*ast.CaseClause)
|
||||
if cc.List == nil {
|
||||
continue // default: 404
|
||||
}
|
||||
if len(cc.List) != 1 {
|
||||
t.Fatalf("case at %v has %d expressions; one route per case", cc.Pos(), len(cc.List))
|
||||
}
|
||||
var r apiRoute
|
||||
for _, p := range conj(cc.List[0]) {
|
||||
switch x := p.(type) {
|
||||
case *ast.BinaryExpr:
|
||||
if x.Op != token.EQL {
|
||||
t.Fatalf("unrecognised predicate at %v", x.Pos())
|
||||
}
|
||||
if sel, ok := x.Y.(*ast.SelectorExpr); ok && strings.HasPrefix(sel.Sel.Name, "Method") {
|
||||
r.Method = strings.ToUpper(strings.TrimPrefix(sel.Sel.Name, "Method"))
|
||||
} else {
|
||||
r.Exact = lit(x.Y)
|
||||
}
|
||||
case *ast.CallExpr:
|
||||
switch x.Fun.(*ast.SelectorExpr).Sel.Name {
|
||||
case "HasPrefix":
|
||||
r.Prefix = lit(x.Args[1])
|
||||
case "HasSuffix":
|
||||
r.Suffix = lit(x.Args[1])
|
||||
case "Contains":
|
||||
r.Contains = lit(x.Args[1])
|
||||
default:
|
||||
t.Fatalf("unrecognised predicate at %v", x.Pos())
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unrecognised predicate at %v", p.Pos())
|
||||
}
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestAPIRouteTable_MatchesSwitch is R-952 for /api/v1: a route added to the switch without deciding who may call it
|
||||
// fails here. RED-PROOF: delete the GET /customers entry from apiRoutes → FAIL at index 36.
|
||||
func TestAPIRouteTable_MatchesSwitch(t *testing.T) {
|
||||
sw := apiSwitchRoutes(t)
|
||||
if len(sw) < 40 {
|
||||
t.Fatalf("parsed only %d routes — the parser lost the switch", len(sw))
|
||||
}
|
||||
n := len(sw)
|
||||
if len(apiRoutes) > n {
|
||||
n = len(apiRoutes)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
var a, b apiRoute
|
||||
if i < len(sw) {
|
||||
a = sw[i]
|
||||
}
|
||||
if i < len(apiRoutes) {
|
||||
b = apiRoutes[i]
|
||||
b.Auth, b.Why = "", ""
|
||||
if apiRoutes[i].Auth == "" {
|
||||
t.Errorf("route %d has no Auth class", i)
|
||||
}
|
||||
if apiRoutes[i].Auth == authPublic && apiRoutes[i].Why == "" {
|
||||
t.Errorf("public route %d has no reason", i)
|
||||
}
|
||||
}
|
||||
if a != b {
|
||||
t.Fatalf("route %d: switch has %+v, table has %+v — classify it in routes.go in the switch's order", i, a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func apiSample(r apiRoute) string {
|
||||
if r.Exact != "" {
|
||||
return r.Exact
|
||||
}
|
||||
id := "c1"
|
||||
if strings.Contains(r.Prefix, "hosts/") {
|
||||
id = "h1"
|
||||
}
|
||||
p := r.Prefix + id
|
||||
if r.Contains != "" {
|
||||
p += r.Contains + "j1"
|
||||
}
|
||||
return p + r.Suffix
|
||||
}
|
||||
|
||||
// TestAPIRoutes_NoCredential: every non-public route, called with no credential for a customer and a host that
|
||||
// EXIST (so a 404 cannot stand in for a refusal), answers 401 or 403.
|
||||
// RED-PROOF: make requireGlobalKey return true → GET /customers answers 200.
|
||||
func TestAPIRoutes_NoCredential(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
|
||||
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
|
||||
n := 0
|
||||
for _, r := range apiRoutes {
|
||||
if r.Auth == authPublic {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
// A body naming the existing household and host, so a route that reads its subject from the body
|
||||
// reaches its credential check instead of a 400.
|
||||
rr := do(h, r.Method, apiSample(r), "", `{"customer_id":"c1","host_id":"h1"}`)
|
||||
if rr.Code != http.StatusUnauthorized && rr.Code != http.StatusForbidden {
|
||||
t.Errorf("%s %s (%s) with no credential: %d %q, want 401/403", r.Method, apiSample(r), r.Auth, rr.Code, strings.TrimSpace(rr.Body.String()))
|
||||
}
|
||||
}
|
||||
if n < 40 {
|
||||
t.Fatalf("only %d routes checked", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPI_AccessTokenIsRefusedEverywhere: an operator access token reaches no box-API route — not a box route, not a
|
||||
// global-key route, not the public one. RED-PROOF: remove the fhp_ check in ServeHTTP → the public register route
|
||||
// answers something other than 403.
|
||||
func TestAPI_AccessTokenIsRefusedEverywhere(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
|
||||
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
|
||||
for _, r := range apiRoutes {
|
||||
rr := do(h, r.Method, apiSample(r), store.APITokenPrefix+"abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrst", "{}")
|
||||
if rr.Code != http.StatusForbidden || !strings.Contains(rr.Body.String(), "access tokens are not accepted") {
|
||||
t.Errorf("%s %s with an access token: %d %q", r.Method, apiSample(r), rr.Code, strings.TrimSpace(rr.Body.String()))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user