From 278a5e7da7c93494694f119f60802486b92d15af Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 21 Jul 2026 22:01:50 +0200 Subject: [PATCH] fix(server): decode base64 Lambda Function URL request bodies before parsing AWS Lambda Function URLs base64-encode POST bodies whenever the Content-Type isn't recognized as plain text, which is the case for the one-click revoke confirmation form's application/x-www-form-urlencoded POST added in #889. handleLambdaHTTPEvent never checked request.IsBase64Encoded, so resolveApprovalToken received a base64 blob instead of "token=...", and every revoke via the email link 401'd in Lambda mode (the primary deploy mode). The HTTP/Fargate adapter was unaffected since it builds its own request with IsBase64Encoded always false. Decode the body once, centrally, right after the raw event is unmarshaled and before anything downstream (OIDC, static files, the API router) reads it. A decode failure returns the same 400 response already used for a malformed event instead of silently proceeding with garbage. --- internal/server/lambda.go | 23 ++++++++ internal/server/lambda_coverage_test.go | 70 +++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/internal/server/lambda.go b/internal/server/lambda.go index cacbd90bb..a620a17fe 100644 --- a/internal/server/lambda.go +++ b/internal/server/lambda.go @@ -108,6 +108,29 @@ func (app *Application) handleLambdaHTTPEvent(ctx context.Context, rawEvent json }, nil } + // Lambda Function URL base64-encodes the request body whenever its + // Content-Type isn't recognized as text (the inbound mirror of the + // isTextContentType decision this file already makes for outbound + // responses). Decode it here, once, before anything downstream (OIDC, + // static files, the API router) reads request.Body -- otherwise handlers + // that parse JSON or form-urlencoded bodies (e.g. resolveApprovalToken) + // see a base64 blob instead of plaintext. + if request.IsBase64Encoded { + decoded, err := base64.StdEncoding.DecodeString(request.Body) + if err != nil { + log.Printf("Failed to base64-decode request body: %v", err) + headers := lambdaSecurityHeaders() + headers["Content-Type"] = "application/json" + return &events.LambdaFunctionURLResponse{ + StatusCode: 400, + Body: `{"error": "Invalid request format"}`, + Headers: headers, + }, nil + } + request.Body = string(decoded) + request.IsBase64Encoded = false + } + // Intercept OIDC issuer endpoints (discovery + JWKS, served under // /oidc/) before both the static-file fallback and the main API // router. api.HandleOIDC does its own auth-less dispatch so the diff --git a/internal/server/lambda_coverage_test.go b/internal/server/lambda_coverage_test.go index baadf2994..2cd7d9359 100644 --- a/internal/server/lambda_coverage_test.go +++ b/internal/server/lambda_coverage_test.go @@ -2,11 +2,17 @@ package server import ( "context" + "encoding/base64" "encoding/json" + "strings" "testing" "github.com/LeanerCloud/CUDly/internal/api" + "github.com/LeanerCloud/CUDly/internal/config" + "github.com/LeanerCloud/CUDly/internal/mocks" "github.com/LeanerCloud/CUDly/internal/testutil" + "github.com/aws/aws-lambda-go/events" + "github.com/stretchr/testify/mock" ) func TestIsTextContentType(t *testing.T) { @@ -108,6 +114,70 @@ func TestHandleLambdaHTTPEvent_StaticPath(t *testing.T) { testutil.AssertEqual(t, 200, resp.StatusCode) } +// TestHandleLambdaHTTPEvent_DecodesBase64FormBody is a regression test for the +// Lambda Function URL body-decode gap (follow-up to #889): AWS delivers POST +// bodies base64-encoded whenever the Content-Type isn't recognized as plain +// text -- the inbound mirror of the isTextContentType decision this file +// already makes for outbound responses. Before the fix, handleLambdaHTTPEvent +// passed the raw base64 blob straight through to the API router, so +// resolveApprovalToken (internal/api/router.go) could never recover the +// "token=..." pair from the one-click revoke confirmation form's +// x-www-form-urlencoded POST body: every revoke via the email link 401'd in +// Lambda Function URL mode (the primary deploy mode), even though the same +// flow worked under the HTTP/Fargate adapter (which never base64-encodes the +// body it builds). +// +// This drives the real failing scenario end-to-end: a base64-encoded, +// form-urlencoded POST body hitting POST /api/purchases/revoke/{execID}. +// With no session, revokeViaEmailToken (internal/api/handler_purchases.go) +// 401s with "sign in or use the revocation link..." when the token fails to +// resolve, or 401s with the DIFFERENT message "sign in with the account's +// contact email..." from authorizeApprovalAction once the token resolves and +// the (session-only) actor lookup fails. Only the second message is reachable +// once the token has actually been parsed out of the decoded body, so +// asserting it proves the fix; asserting the pre-fix code fails this test +// caught the bug (verified manually before landing the fix). +func TestHandleLambdaHTTPEvent_DecodesBase64FormBody(t *testing.T) { + execID := "11111111-1111-1111-1111-111111111111" + exec := &config.PurchaseExecution{ + ExecutionID: execID, + Status: "completed", + } + + mockStore := new(mocks.MockConfigStore) + mockStore.On("GetExecutionByID", mock.Anything, execID).Return(exec, nil) + + app := &Application{ + API: api.NewHandler(api.HandlerConfig{ConfigStore: mockStore}), + } + + encodedBody := base64.StdEncoding.EncodeToString([]byte("token=body-token")) + request := events.LambdaFunctionURLRequest{ + RawPath: "/api/purchases/revoke/" + execID, + Headers: map[string]string{"content-type": "application/x-www-form-urlencoded"}, + RequestContext: events.LambdaFunctionURLRequestContext{ + HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{ + Method: "POST", + Path: "/api/purchases/revoke/" + execID, + }, + }, + Body: encodedBody, + IsBase64Encoded: true, + } + rawEvent, err := json.Marshal(request) + testutil.AssertNoError(t, err) + + ctx := testutil.TestContext(t) + resp, err := app.handleLambdaHTTPEvent(ctx, rawEvent) + testutil.AssertNoError(t, err) + testutil.AssertEqual(t, 401, resp.StatusCode) + testutil.AssertContains(t, resp.Body, "sign in with the account's contact email") + testutil.AssertTrue(t, !strings.Contains(resp.Body, "revocation link from the notification email"), + "token must have been resolved from the decoded body, not left empty") + + mockStore.AssertExpectations(t) +} + func TestHandleLambdaEvent_UnknownEventRouteToScheduled(t *testing.T) { // "unknown" event type routes to handleLambdaScheduledEvent, which // needs a parseable action. Empty object will fail ParseScheduledEvent.