feat(auth): X-Bn-Verified-Captcha trusted header + CaptchaVerified helper

The host verifies (and consumes) a request's cap-token against its
stateful captcha server before wasm dispatch and stamps this header;
guests enforce captcha by checking CaptchaVerified, fail closed.
Included in AllTrustedHeaders so the host's strip-then-stamp loop
makes it unforgeable, like the identity headers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-07 22:53:50 +08:00
parent 54716d705f
commit 9a4394a07c
2 changed files with 38 additions and 0 deletions

View File

@ -36,6 +36,13 @@ const (
HeaderVerifiedPublicUserID = "X-Bn-Verified-Public-User-Id" HeaderVerifiedPublicUserID = "X-Bn-Verified-Public-User-Id"
HeaderVerifiedPublicUsername = "X-Bn-Verified-Public-Username" HeaderVerifiedPublicUsername = "X-Bn-Verified-Public-Username"
HeaderVerifiedPublicEmail = "X-Bn-Verified-Public-Email" HeaderVerifiedPublicEmail = "X-Bn-Verified-Public-Email"
// Captcha proof. The host verifies (and consumes — replay-protected) the
// request's cap-token against the instance captcha server before dispatch
// and stamps "1" on success. A guest cannot hold the host's stateful
// verifier, so this header is the ONLY way to enforce captcha on a plugin
// HTTP endpoint; render the widget with the ninjatpl captcha tag.
HeaderVerifiedCaptcha = "X-Bn-Verified-Captcha"
) )
// AllTrustedHeaders lists every trusted identity header, canonical form. The // AllTrustedHeaders lists every trusted identity header, canonical form. The
@ -49,9 +56,16 @@ func AllTrustedHeaders() []string {
HeaderVerifiedPublicUserID, HeaderVerifiedPublicUserID,
HeaderVerifiedPublicUsername, HeaderVerifiedPublicUsername,
HeaderVerifiedPublicEmail, HeaderVerifiedPublicEmail,
HeaderVerifiedCaptcha,
} }
} }
// CaptchaVerified reports whether the host verified a captcha token for this
// request. Fail closed: enforcing handlers must reject when this is false.
func CaptchaVerified(h http.Header) bool {
return h.Get(HeaderVerifiedCaptcha) == "1"
}
// ContextFromTrustedHeaders rebuilds the request's auth context from the host's // ContextFromTrustedHeaders rebuilds the request's auth context from the host's
// verified identity headers. Trust model: see the const block above — these // verified identity headers. Trust model: see the const block above — these
// headers are host-controlled, so no token parsing or signature check happens // headers are host-controlled, so no token parsing or signature check happens

View File

@ -80,3 +80,27 @@ func TestTrustedHeaderMiddleware(t *testing.T) {
t.Fatalf("middleware did not install principal: %+v", got) t.Fatalf("middleware did not install principal: %+v", got)
} }
} }
func TestCaptchaVerifiedReadsTrustedHeader(t *testing.T) {
h := http.Header{}
if CaptchaVerified(h) {
t.Fatal("CaptchaVerified() = true with no header, want false")
}
h.Set(HeaderVerifiedCaptcha, "1")
if !CaptchaVerified(h) {
t.Fatal("CaptchaVerified() = false with host-stamped header, want true")
}
h.Set(HeaderVerifiedCaptcha, "true")
if CaptchaVerified(h) {
t.Fatal(`CaptchaVerified() = true for non-"1" value, want false`)
}
}
func TestAllTrustedHeadersIncludesCaptcha(t *testing.T) {
for _, h := range AllTrustedHeaders() {
if h == HeaderVerifiedCaptcha {
return
}
}
t.Fatalf("AllTrustedHeaders() missing %s — hosts strip-then-stamp from this list, so omitting it makes the header client-forgeable", HeaderVerifiedCaptcha)
}