Skip to content

Commit c10e127

Browse files
authored
Merge pull request #594 from LeanerCloud/fix/token-actions-no-existence-oracle
fix(api): answer 401 before looking up the execution on the email-link routes
2 parents 8b23cbe + 62dc19a commit c10e127

7 files changed

Lines changed: 144 additions & 45 deletions

‎internal/api/coverage_extras_test.go‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,17 +120,17 @@ func TestHandler_cancelPurchase_InvalidUUID(t *testing.T) {
120120
// TestHandler_cancelPurchase_EmptyToken_FallsThroughToSession asserts that
121121
// the token-empty branch no longer short-circuits with "cancellation token
122122
// is required" — the empty-token path is now the dispatch into the
123-
// session-authed cancel flow (issue #46). Without an execution to load,
124-
// GetExecutionByID is the first thing that runs; with no config wired,
125-
// the call surfaces a downstream error rather than the legacy 400.
123+
// session-authed cancel flow (issue #46). With no session the caller gets a
124+
// 401 before any execution is loaded (issue #435), not the legacy 400.
126125
func TestHandler_cancelPurchase_EmptyToken_FallsThroughToSession(t *testing.T) {
127126
execID := "11111111-1111-1111-1111-111111111111"
128127
mockConfig := new(MockConfigStore)
129-
mockConfig.On("GetExecutionByID", mock.Anything, execID).Return(nil, errors.New("store error"))
130128
h := &Handler{config: mockConfig}
131129
_, err := h.cancelPurchase(context.Background(), nil, execID, "")
132-
assert.Error(t, err)
133-
assert.Contains(t, err.Error(), "failed to get execution")
130+
ce, ok := IsClientError(err)
131+
require.True(t, ok, "expected a client error, got: %v", err)
132+
assert.Equal(t, 401, ce.code)
133+
mockConfig.AssertNotCalled(t, "GetExecutionByID", mock.Anything, execID)
134134
}
135135

136136
func TestHandler_cancelPurchase_PurchaseError(t *testing.T) {

‎internal/api/handler_purchases.go‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,10 @@ func (h *Handler) approvePurchase(ctx context.Context, req *events.LambdaFunctio
596596
return nil, err
597597
}
598598

599+
if err := h.requireSessionBeforeLookup(ctx, req); err != nil {
600+
return nil, err
601+
}
602+
599603
execution, err := h.loadApproveExecution(ctx, execID)
600604
if err != nil {
601605
return nil, err
@@ -636,6 +640,18 @@ func (h *Handler) approvePurchase(ctx context.Context, req *events.LambdaFunctio
636640
return h.approvePurchaseViaSession(ctx, req, execution)
637641
}
638642

643+
// requireSessionBeforeLookup answers 401 to a caller with no valid session
644+
// before the execution is loaded. Every branch of the email-link
645+
// approve/cancel/revoke routes needs a session (the token alone never
646+
// authorizes), so an unauthenticated caller gets the same response for a
647+
// missing and an existing execution ID (issue #435).
648+
func (h *Handler) requireSessionBeforeLookup(ctx context.Context, req *events.LambdaFunctionURLRequest) error {
649+
if h.tryGetSession(ctx, req) == nil {
650+
return NewClientError(401, "sign in with the account's contact email to approve or cancel this purchase")
651+
}
652+
return nil
653+
}
654+
639655
// tokenActionError maps the email-link approve/cancel failures that are the
640656
// caller's fault to a 4xx: a wrong token is 403 (as on the RI exchange
641657
// approve path), an expired token is 410, and an execution
@@ -1206,12 +1222,13 @@ func (h *Handler) cancelPurchase(ctx context.Context, req *events.LambdaFunction
12061222
return nil, err
12071223
}
12081224

1209-
execution, err := h.config.GetExecutionByID(ctx, execID)
1210-
if errors.Is(err, config.ErrNotFound) {
1211-
return nil, NewClientError(404, "execution not found")
1225+
if err := h.requireSessionBeforeLookup(ctx, req); err != nil {
1226+
return nil, err
12121227
}
1228+
1229+
execution, err := h.getExecutionOr404(ctx, execID)
12131230
if err != nil {
1214-
return nil, fmt.Errorf("failed to get execution: %w", err)
1231+
return nil, err
12151232
}
12161233

12171234
// Three-mode dispatch:
@@ -1424,6 +1441,10 @@ func (h *Handler) revokeViaEmailToken(ctx context.Context, req *events.LambdaFun
14241441
return renderRevokeConfirmPage(execID, token), nil
14251442
}
14261443

1444+
if err := h.requireSessionBeforeLookup(ctx, req); err != nil {
1445+
return nil, err
1446+
}
1447+
14271448
execution, err := h.getExecutionOr404(ctx, execID)
14281449
if err != nil {
14291450
return nil, err

‎internal/api/handler_purchases_test.go‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3406,15 +3406,14 @@ func TestHandler_cancelPurchase_Session_RejectsMissingSession(t *testing.T) {
34063406
mockConfig.On("GetExecutionByID", mock.Anything, cancelExecID).Return(exec, nil)
34073407

34083408
handler := &Handler{config: mockConfig, auth: new(MockAuthService)}
3409-
// No Authorization header → CSRF fires first (issue #404: cancelPurchaseViaSession
3410-
// now enforces CSRF before requireSession). A tokenless request can't provide a
3411-
// valid CSRF binding, so we get a 403 "CSRF validation failed".
3409+
// No Authorization header → 401 before the execution is loaded (issue #435),
3410+
// so a missing and an existing ID are indistinguishable.
34123411
_, err := handler.cancelPurchase(context.Background(), &events.LambdaFunctionURLRequest{}, cancelExecID, "")
34133412
require.Error(t, err)
34143413
ce, ok := IsClientError(err)
34153414
require.True(t, ok, "expected a clientError, got: %v", err)
3416-
assert.Equal(t, 403, ce.code)
3417-
assert.Contains(t, ce.Error(), "CSRF validation failed")
3415+
assert.Equal(t, 401, ce.code)
3416+
mockConfig.AssertNotCalled(t, "GetExecutionByID", mock.Anything, cancelExecID)
34183417
}
34193418

34203419
// TestHandler_cancelPurchase_DeepLink_AdminBypassesContactEmailGate is the
@@ -5721,8 +5720,10 @@ func TestHandler_revokePurchase_NotFound(t *testing.T) {
57215720
mockStore := new(MockConfigStore)
57225721
mockStore.On("GetExecutionByID", ctx, execID).Return(nil, nil)
57235722

5724-
handler := &Handler{config: mockStore}
5725-
req := &events.LambdaFunctionURLRequest{}
5723+
mockAuth := new(MockAuthService)
5724+
mockAuth.On("ValidateSession", mock.Anything, "sess-tok").Return(&Session{Email: "admin@example.com"}, nil)
5725+
handler := &Handler{config: mockStore, auth: mockAuth}
5726+
req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"authorization": "Bearer sess-tok"}}
57265727
_, err := handler.revokeViaEmailToken(ctx, req, execID, "some-token")
57275728
require.Error(t, err)
57285729
ce, ok := IsClientError(err)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
"time"
8+
9+
"github.com/aws/aws-lambda-go/events"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/mock"
12+
"github.com/stretchr/testify/require"
13+
14+
"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
15+
)
16+
17+
// A caller with no session must get the same answer for a missing and an
18+
// existing execution on the email-link routes, so the routes are not an
19+
// existence oracle (issue #435).
20+
func TestHandleRequest_TokenActions_UnauthenticatedCannotTellMissingFromExisting(t *testing.T) {
21+
future := time.Now().Add(time.Hour)
22+
hash := config.HashApprovalToken(tokenErrRawToken)
23+
24+
headerVariants := map[string]map[string]string{
25+
"no-header": {},
26+
"invalid-bearer": {"authorization": "Bearer bad-tok"},
27+
"bogus-x-auth": {"x-authorization": "Basic bad-tok"},
28+
}
29+
for _, action := range []string{"approve", "cancel", "revoke"} {
30+
for _, token := range []string{"", tokenErrRawToken, "wrong"} {
31+
for variant, headers := range headerVariants {
32+
t.Run(action+"/token="+token+"/"+variant, func(t *testing.T) {
33+
call := func(h *Handler) *events.LambdaFunctionURLResponse {
34+
req := tokenErrRequest(action, token)
35+
req.Headers = headers
36+
resp, err := h.HandleRequest(context.Background(), req)
37+
require.NoError(t, err)
38+
return resp
39+
}
40+
41+
exec := &config.PurchaseExecution{Status: "pending", ApprovalToken: hash, ApprovalTokenExpiresAt: &future}
42+
existingH := tokenErrHandler(t, exec, nil)
43+
missingH := tokenErrHandler(t, nil, config.ErrNotFound)
44+
for _, h := range []*Handler{existingH, missingH} {
45+
h.auth.(*MockAuthService).On("ValidateSession", mock.Anything, "bad-tok").Return(nil, errors.New("invalid session")).Maybe()
46+
}
47+
existing := call(existingH)
48+
missing := call(missingH)
49+
50+
assert.Equal(t, 401, existing.StatusCode, existing.Body)
51+
assert.Equal(t, existing.StatusCode, missing.StatusCode, "missing: %s", missing.Body)
52+
assert.Equal(t, existing.Body, missing.Body)
53+
})
54+
}
55+
}
56+
}
57+
}

‎internal/api/openapi.yaml‎

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -544,18 +544,16 @@ paths:
544544
$ref: '#/components/responses/BadRequest'
545545
'401':
546546
description: >
547-
Token mode only: a token was supplied but the caller is not
548-
signed in. A request with no credentials at all gets 403 (CSRF
549-
validation failed), not 401, because the session path validates
550-
CSRF before it requires a session.
547+
The caller has no valid session, with or without a token. This
548+
is answered before the execution is looked up, so an existing
549+
and a missing execution ID get the same response.
551550
content:
552551
application/json:
553552
schema:
554553
$ref: '#/components/schemas/Error'
555554
'403':
556555
description: >
557-
CSRF validation failed (this includes a request with no
558-
credentials), the session lacks approve permission or
556+
CSRF validation failed for a signed-in session, the session lacks approve permission or
559557
exceeds its Constraints, the caller is not the account's contact
560558
email, no contact email is configured on the execution's
561559
accounts, 4-eyes mode rejects the approver, or (token mode) the
@@ -649,18 +647,16 @@ paths:
649647
$ref: '#/components/responses/BadRequest'
650648
'401':
651649
description: >
652-
Token mode only: a token was supplied but the caller is not
653-
signed in. A request with no credentials at all gets 403 (CSRF
654-
validation failed), not 401, because the session path validates
655-
CSRF before it requires a session.
650+
The caller has no valid session, with or without a token. This
651+
is answered before the execution is looked up, so an existing
652+
and a missing execution ID get the same response.
656653
content:
657654
application/json:
658655
schema:
659656
$ref: '#/components/schemas/Error'
660657
'403':
661658
description: >
662-
CSRF validation failed (this includes a request with no
663-
credentials), the session lacks cancel permission, the caller is
659+
CSRF validation failed for a signed-in session, the session lacks cancel permission, the caller is
664660
not the account's contact email, no contact email is
665661
configured on the execution's accounts, or (token mode) the
666662
approval token is invalid.
@@ -758,8 +754,9 @@ paths:
758754
$ref: '#/components/responses/BadRequest'
759755
'401':
760756
description: >
761-
No session and no token, or token mode without a signed-in
762-
contact-email session.
757+
The caller has no valid session, with or without a token. This
758+
is answered before the execution is looked up, so an existing
759+
and a missing execution ID get the same response.
763760
content:
764761
application/json:
765762
schema:

‎internal/server/app_rate_limiter_integration_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ func TestApplicationRateLimiter(t *testing.T) {
122122
if i%2 == 1 {
123123
action = "cancel"
124124
}
125-
want := http.StatusNotFound
125+
want := http.StatusUnauthorized
126126
if i >= 30 {
127127
want = http.StatusTooManyRequests
128128
}

‎internal/server/lambda_coverage_test.go‎

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/base64"
66
"encoding/json"
7+
"errors"
78
"strings"
89
"testing"
910

@@ -129,14 +130,13 @@ func TestHandleLambdaHTTPEvent_StaticPath(t *testing.T) {
129130
//
130131
// This drives the real failing scenario end-to-end: a base64-encoded,
131132
// form-urlencoded POST body hitting POST /api/purchases/revoke/{execID}.
132-
// With no session, revokeViaEmailToken (internal/api/handler_purchases.go)
133-
// 401s with "sign in or use the revocation link..." when the token fails to
134-
// resolve, or 401s with the DIFFERENT message "sign in with the account's
135-
// contact email..." from authorizeApprovalAction once the token resolves and
136-
// the (session-only) actor lookup fails. Only the second message is reachable
137-
// once the token has actually been parsed out of the decoded body, so
138-
// asserting it proves the fix; asserting the pre-fix code fails this test
139-
// caught the bug (verified manually before landing the fix).
133+
// With a session that lacks cancel permission, revokeViaEmailToken answers 403
134+
// "permission denied" when the token fails to resolve, or falls through to the
135+
// token branch and answers the DIFFERENT 403 "no per-account contact email"
136+
// from authorizeApprovalAction once the token has been parsed out of the
137+
// decoded body. Only the second message is reachable when the token resolves,
138+
// so asserting it proves the fix. (A request with no session never reaches
139+
// either: it gets 401 before the lookup, issue #435.)
140140
func TestHandleLambdaHTTPEvent_DecodesBase64FormBody(t *testing.T) {
141141
execID := "11111111-1111-1111-1111-111111111111"
142142
exec := &config.PurchaseExecution{
@@ -146,15 +146,19 @@ func TestHandleLambdaHTTPEvent_DecodesBase64FormBody(t *testing.T) {
146146

147147
mockStore := new(mocks.MockConfigStore)
148148
mockStore.On("GetExecutionByID", mock.Anything, execID).Return(exec, nil)
149+
mockStore.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{}, nil)
149150

150151
app := &Application{
151-
API: api.NewHandler(api.HandlerConfig{ConfigStore: mockStore}),
152+
API: api.NewHandler(api.HandlerConfig{ConfigStore: mockStore, AuthService: sessionOnlyAuth{}}),
152153
}
153154

154155
encodedBody := base64.StdEncoding.EncodeToString([]byte("token=body-token"))
155156
request := events.LambdaFunctionURLRequest{
156157
RawPath: "/api/purchases/revoke/" + execID,
157-
Headers: map[string]string{"content-type": "application/x-www-form-urlencoded"},
158+
Headers: map[string]string{
159+
"content-type": "application/x-www-form-urlencoded",
160+
"authorization": "Bearer sess-tok",
161+
},
158162
RequestContext: events.LambdaFunctionURLRequestContext{
159163
HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{
160164
Method: "POST",
@@ -170,9 +174,9 @@ func TestHandleLambdaHTTPEvent_DecodesBase64FormBody(t *testing.T) {
170174
ctx := testutil.TestContext(t)
171175
resp, err := app.handleLambdaHTTPEvent(ctx, rawEvent)
172176
testutil.AssertNoError(t, err)
173-
testutil.AssertEqual(t, 401, resp.StatusCode)
174-
testutil.AssertContains(t, resp.Body, "sign in with the account's contact email")
175-
testutil.AssertTrue(t, !strings.Contains(resp.Body, "revocation link from the notification email"),
177+
testutil.AssertEqual(t, 403, resp.StatusCode)
178+
testutil.AssertContains(t, resp.Body, "no per-account contact email")
179+
testutil.AssertTrue(t, !strings.Contains(resp.Body, "requires cancel-any"),
176180
"token must have been resolved from the decoded body, not left empty")
177181

178182
mockStore.AssertExpectations(t)
@@ -191,3 +195,22 @@ func TestHandleLambdaEvent_UnknownEventRouteToScheduled(t *testing.T) {
191195
// Unknown action → error from ParseScheduledEvent
192196
testutil.AssertError(t, err)
193197
}
198+
199+
// sessionOnlyAuth accepts the bearer "sess-tok" as a signed-in user with no
200+
// purchase permissions; any other method panics via the nil embedded interface.
201+
type sessionOnlyAuth struct{ api.AuthServiceInterface }
202+
203+
func (sessionOnlyAuth) ValidateSession(_ context.Context, token string) (*api.Session, error) {
204+
if token != "sess-tok" {
205+
return nil, errors.New("invalid session")
206+
}
207+
return &api.Session{UserID: "user-1", Email: "user@example.com"}, nil
208+
}
209+
210+
func (sessionOnlyAuth) HasPermissionAPI(context.Context, string, string, string) (bool, error) {
211+
return false, nil
212+
}
213+
214+
func (sessionOnlyAuth) GetAllowedAccountsAPI(context.Context, string) ([]string, error) {
215+
return nil, nil
216+
}

0 commit comments

Comments
 (0)