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.