From 2c353b14664df7f56fc15318fa3379a983bebf37 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Fri, 29 May 2026 11:10:07 -0700 Subject: [PATCH 1/2] fix(HttpApi): strip space delimiter when decoding bearer/http credentials `securityDecode` sliced `schemeLength` off the `Authorization` header, but the header is `" "`, so the single space separating the scheme from the credential was left attached (e.g. `Bearer abc123` decoded to `" abc123"`). Slice `schemeLength + 1` to skip the delimiter. Co-authored-by: Cursor --- .changeset/wild-suns-bearer-space.md | 7 ++++ .../src/unstable/httpapi/HttpApiBuilder.ts | 5 ++- .../unstable/httpapi/HttpApiSecurity.test.ts | 35 +++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 .changeset/wild-suns-bearer-space.md create mode 100644 packages/effect/test/unstable/httpapi/HttpApiSecurity.test.ts diff --git a/.changeset/wild-suns-bearer-space.md b/.changeset/wild-suns-bearer-space.md new file mode 100644 index 0000000000..b8fce1e48e --- /dev/null +++ b/.changeset/wild-suns-bearer-space.md @@ -0,0 +1,7 @@ +--- +"effect": patch +--- + +Fix `HttpApiSecurity` bearer/http credential gaining a leading space + +`securityDecode` sliced `schemeLength` off the `Authorization` header, but the header is `" "`, so it left the delimiter space attached to the credential (e.g. `Bearer abc123` decoded to `" abc123"`). It now slices `schemeLength + 1` to skip the space. diff --git a/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts b/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts index 67ed47e3a8..93a051232f 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts @@ -446,7 +446,10 @@ export const securityDecode = case "Http": { return Effect.map( HttpServerRequest, - (request) => Redacted.make((request.headers.authorization ?? "").slice(self.schemeLength)) as any + // The `Authorization` header is `" "`, so skip the + // scheme name plus the single space delimiter that separates it from + // the credential. Slicing only `schemeLength` leaves a leading space. + (request) => Redacted.make((request.headers.authorization ?? "").slice(self.schemeLength + 1)) as any ) } case "ApiKey": { diff --git a/packages/effect/test/unstable/httpapi/HttpApiSecurity.test.ts b/packages/effect/test/unstable/httpapi/HttpApiSecurity.test.ts new file mode 100644 index 0000000000..e3951ab13b --- /dev/null +++ b/packages/effect/test/unstable/httpapi/HttpApiSecurity.test.ts @@ -0,0 +1,35 @@ +import { describe, it } from "@effect/vitest" +import { strictEqual } from "@effect/vitest/utils" +import { Effect, Redacted } from "effect" +import { HttpClientRequest, HttpServerRequest } from "effect/unstable/http" +import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi" + +const decode = (security: HttpApiSecurity.HttpApiSecurity, authorization: string) => + HttpApiBuilder.securityDecode(security).pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(new Request("http://localhost/", { headers: { authorization } })) + ), + Effect.provideService(HttpServerRequest.ParsedSearchParams, {}) + ) + +describe("HttpApiSecurity", () => { + describe("securityDecode", () => { + it.effect("decodes a bearer token without a leading space", () => + Effect.gen(function*() { + const token = "abc123" + // build the header exactly as a client does + const { headers } = HttpClientRequest.get("http://localhost/").pipe( + HttpClientRequest.bearerToken(token) + ) + const credential = yield* decode(HttpApiSecurity.bearer, headers.authorization!) + strictEqual(Redacted.value(credential), token) + })) + + it.effect("decodes a custom http scheme without a leading space", () => + Effect.gen(function*() { + const credential = yield* decode(HttpApiSecurity.http({ scheme: "Token" }), "Token abc123") + strictEqual(Redacted.value(credential), "abc123") + })) + }) +}) From 4963a1fbaebe29ef4f794e3c79600805801dc48a Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 2 Jun 2026 09:06:06 +1200 Subject: [PATCH 2/2] cleanup --- .changeset/wild-suns-bearer-space.md | 4 +-- .../src/unstable/httpapi/HttpApiBuilder.ts | 4 +-- .../unstable/httpapi/HttpApiSecurity.test.ts | 27 ++++++++++--------- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/.changeset/wild-suns-bearer-space.md b/.changeset/wild-suns-bearer-space.md index b8fce1e48e..71a6805b40 100644 --- a/.changeset/wild-suns-bearer-space.md +++ b/.changeset/wild-suns-bearer-space.md @@ -2,6 +2,4 @@ "effect": patch --- -Fix `HttpApiSecurity` bearer/http credential gaining a leading space - -`securityDecode` sliced `schemeLength` off the `Authorization` header, but the header is `" "`, so it left the delimiter space attached to the credential (e.g. `Bearer abc123` decoded to `" abc123"`). It now slices `schemeLength + 1` to skip the space. +Fix `HttpApiSecurity` bearer/http credential decoding diff --git a/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts b/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts index 93a051232f..8ca5780fa5 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts @@ -446,9 +446,7 @@ export const securityDecode = case "Http": { return Effect.map( HttpServerRequest, - // The `Authorization` header is `" "`, so skip the - // scheme name plus the single space delimiter that separates it from - // the credential. Slicing only `schemeLength` leaves a leading space. + // schemeLength + space (request) => Redacted.make((request.headers.authorization ?? "").slice(self.schemeLength + 1)) as any ) } diff --git a/packages/effect/test/unstable/httpapi/HttpApiSecurity.test.ts b/packages/effect/test/unstable/httpapi/HttpApiSecurity.test.ts index e3951ab13b..1fbfcfb8d7 100644 --- a/packages/effect/test/unstable/httpapi/HttpApiSecurity.test.ts +++ b/packages/effect/test/unstable/httpapi/HttpApiSecurity.test.ts @@ -4,31 +4,34 @@ import { Effect, Redacted } from "effect" import { HttpClientRequest, HttpServerRequest } from "effect/unstable/http" import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi" -const decode = (security: HttpApiSecurity.HttpApiSecurity, authorization: string) => - HttpApiBuilder.securityDecode(security).pipe( - Effect.provideService( - HttpServerRequest.HttpServerRequest, - HttpServerRequest.fromWeb(new Request("http://localhost/", { headers: { authorization } })) - ), - Effect.provideService(HttpServerRequest.ParsedSearchParams, {}) - ) - describe("HttpApiSecurity", () => { describe("securityDecode", () => { it.effect("decodes a bearer token without a leading space", () => Effect.gen(function*() { const token = "abc123" - // build the header exactly as a client does const { headers } = HttpClientRequest.get("http://localhost/").pipe( HttpClientRequest.bearerToken(token) ) - const credential = yield* decode(HttpApiSecurity.bearer, headers.authorization!) + const credential = yield* HttpApiBuilder.securityDecode(HttpApiSecurity.bearer).pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(new Request("http://localhost/", { headers })) + ), + Effect.provideService(HttpServerRequest.ParsedSearchParams, {}) + ) + strictEqual(Redacted.value(credential), token) })) it.effect("decodes a custom http scheme without a leading space", () => Effect.gen(function*() { - const credential = yield* decode(HttpApiSecurity.http({ scheme: "Token" }), "Token abc123") + const credential = yield* HttpApiBuilder.securityDecode(HttpApiSecurity.http({ scheme: "Token" })).pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(new Request("http://localhost/", { headers: { authorization: "Token abc123" } })) + ), + Effect.provideService(HttpServerRequest.ParsedSearchParams, {}) + ) strictEqual(Redacted.value(credential), "abc123") })) })