From 9a4394a07c418440e383d4ce0f89c862328acd73 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Tue, 7 Jul 2026 22:53:50 +0800 Subject: [PATCH] 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 --- auth/trustedheaders.go | 14 ++++++++++++++ auth/trustedheaders_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/auth/trustedheaders.go b/auth/trustedheaders.go index 5ce34b6..dd1a21b 100644 --- a/auth/trustedheaders.go +++ b/auth/trustedheaders.go @@ -36,6 +36,13 @@ const ( HeaderVerifiedPublicUserID = "X-Bn-Verified-Public-User-Id" HeaderVerifiedPublicUsername = "X-Bn-Verified-Public-Username" 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 @@ -49,9 +56,16 @@ func AllTrustedHeaders() []string { HeaderVerifiedPublicUserID, HeaderVerifiedPublicUsername, 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 // verified identity headers. Trust model: see the const block above — these // headers are host-controlled, so no token parsing or signature check happens diff --git a/auth/trustedheaders_test.go b/auth/trustedheaders_test.go index 27a09c5..ebb31cf 100644 --- a/auth/trustedheaders_test.go +++ b/auth/trustedheaders_test.go @@ -80,3 +80,27 @@ func TestTrustedHeaderMiddleware(t *testing.T) { 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) +}