Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions internal/server/lambda.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions internal/server/lambda_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down
Loading