Skip to content

Commit b7275db

Browse files
committed
some more fixes
1 parent 06ff3cc commit b7275db

3 files changed

Lines changed: 116 additions & 1 deletion

File tree

internal/convox/commands.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ var Commands = []Command{
216216
Permissions: []string{
217217
rbac.Convox(rbac.ResourceEnv, rbac.ActionSet),
218218
rbac.Convox(rbac.ResourceRelease, rbac.ActionCreate),
219+
rbac.Convox(rbac.ResourceRelease, rbac.ActionPromote), // Required when --promote flag is used
219220
},
220221
AllowedFlags: []string{"promote", "replace"},
221222
Description: "Set env var(s)",
@@ -225,6 +226,7 @@ var Commands = []Command{
225226
Permissions: []string{
226227
rbac.Convox(rbac.ResourceEnv, rbac.ActionUnset),
227228
rbac.Convox(rbac.ResourceRelease, rbac.ActionCreate),
229+
rbac.Convox(rbac.ResourceRelease, rbac.ActionPromote), // Required when --promote flag is used
228230
},
229231
AllowedFlags: []string{"promote"},
230232
Description: "Unset env var(s)",
@@ -234,6 +236,7 @@ var Commands = []Command{
234236
Permissions: []string{
235237
rbac.Convox(rbac.ResourceEnv, rbac.ActionSet),
236238
rbac.Convox(rbac.ResourceRelease, rbac.ActionCreate),
239+
rbac.Convox(rbac.ResourceRelease, rbac.ActionPromote), // Required when --promote flag is used
237240
},
238241
AllowedFlags: []string{"promote"},
239242
Description: "Edit env interactively",

internal/gateway/proxy/mfa_verification.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import (
1414
"github.com/DocSpring/rack-gateway/internal/gateway/rbac"
1515
)
1616

17-
// verifyMFAIfRequired checks if MFA is required for the route and verifies inline MFA if provided
17+
// verifyMFAIfRequired checks if MFA is required for the route and verifies inline MFA if provided.
18+
// IMPORTANT: Step-up window is checked FIRST to support multi-step CLI operations.
19+
// When CLI runs commands like "env set --promote", it embeds the same MFA code in ALL requests.
20+
// TOTP replay protection would reject the same code on subsequent requests.
21+
// By checking step-up window first, subsequent requests reuse the step-up from the first verification.
1822
func (h *Handler) verifyMFAIfRequired(
1923
r *http.Request,
2024
w http.ResponseWriter,
@@ -33,6 +37,14 @@ func (h *Handler) verifyMFAIfRequired(
3337
return nil
3438
}
3539

40+
// Check step-up window FIRST - if already valid, skip inline MFA verification.
41+
// This prevents TOTP replay errors when CLI sends the same MFA code for multiple
42+
// API calls in a single operation (e.g., env set + release promote).
43+
if authUser.Session != nil && h.isStepUpValid(authUser) {
44+
return nil
45+
}
46+
47+
// Only verify inline MFA if step-up window is expired or not set
3648
if authUser.MFAType != "" && authUser.MFAValue != "" {
3749
return h.verifyInlineMFA(r, w, authUser, rackConfig, start)
3850
}

internal/gateway/proxy/mfa_verification_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,18 @@ package proxy
33
import (
44
"encoding/base64"
55
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
68
"testing"
9+
"time"
710

811
"github.com/stretchr/testify/assert"
912
"github.com/stretchr/testify/require"
13+
14+
"github.com/DocSpring/rack-gateway/internal/gateway/auth"
15+
"github.com/DocSpring/rack-gateway/internal/gateway/config"
16+
"github.com/DocSpring/rack-gateway/internal/gateway/db"
17+
"github.com/DocSpring/rack-gateway/internal/gateway/rbac"
1018
)
1119

1220
func TestParseWebAuthnAssertion_CLIFormat(t *testing.T) {
@@ -193,3 +201,95 @@ func TestParseWebAuthnAssertion_PreservesAssertionFormat(t *testing.T) {
193201
assert.Equal(t, "dGVzdC1hdXRoLWRhdGE", response["authenticatorData"])
194202
assert.Equal(t, "dGVzdC1jbGllbnQtZGF0YS1qc29u", response["clientDataJSON"])
195203
}
204+
205+
// TestVerifyMFAIfRequired_StepUpWindowCheckedFirst verifies that the step-up window
206+
// is checked BEFORE attempting inline MFA verification. This is critical for multi-step
207+
// CLI operations (like "env set --promote") where the same MFA code is sent for all requests.
208+
// The first request verifies the MFA code and sets the step-up window. Subsequent requests
209+
// should skip MFA verification and reuse the step-up window, avoiding TOTP replay errors.
210+
func TestVerifyMFAIfRequired_StepUpWindowCheckedFirst(t *testing.T) {
211+
t.Parallel()
212+
213+
// Create a minimal handler with no MFA service to test the logic flow
214+
h := &Handler{}
215+
216+
// Create a test session with a recent step-up timestamp
217+
recentStepUp := time.Now().Add(-5 * time.Minute) // 5 minutes ago, within 10-minute window
218+
session := &db.UserSession{
219+
ID: 1,
220+
RecentStepUpAt: &recentStepUp,
221+
}
222+
223+
// Create auth user WITH inline MFA credentials (simulating CLI with embedded MFA code)
224+
authUser := &auth.User{
225+
Email: "test@example.com",
226+
Session: session,
227+
MFAType: "totp",
228+
MFAValue: "123456", // Inline MFA code that would normally be verified
229+
}
230+
231+
// Test that isStepUpValid returns true for recent step-up
232+
require.True(t, h.isStepUpValid(authUser), "step-up should be valid when within window")
233+
234+
// Test that isStepUpValid returns false for expired step-up
235+
expiredStepUp := time.Now().Add(-15 * time.Minute) // 15 minutes ago, outside 10-minute window
236+
authUser.Session.RecentStepUpAt = &expiredStepUp
237+
require.False(t, h.isStepUpValid(authUser), "step-up should be invalid when outside window")
238+
239+
// Test that isStepUpValid returns false when RecentStepUpAt is nil
240+
authUser.Session.RecentStepUpAt = nil
241+
require.False(t, h.isStepUpValid(authUser), "step-up should be invalid when RecentStepUpAt is nil")
242+
}
243+
244+
// TestVerifyMFAIfRequired_SkipsInlineMFAWhenStepUpValid tests that verifyMFAIfRequired
245+
// returns early without calling verifyInlineMFA when step-up window is valid.
246+
// This test uses nil mfaService to ensure verifyInlineMFA would panic if called.
247+
func TestVerifyMFAIfRequired_SkipsInlineMFAWhenStepUpValid(t *testing.T) {
248+
t.Parallel()
249+
250+
// Handler with nil mfaService - verifyInlineMFA would fail if called
251+
h := &Handler{
252+
mfaService: nil, // This will cause early return
253+
sessionManager: nil,
254+
}
255+
256+
recentStepUp := time.Now().Add(-5 * time.Minute)
257+
session := &db.UserSession{
258+
ID: 1,
259+
RecentStepUpAt: &recentStepUp,
260+
}
261+
262+
authUser := &auth.User{
263+
Email: "test@example.com",
264+
Session: session,
265+
MFAType: "totp",
266+
MFAValue: "123456",
267+
}
268+
269+
req := httptest.NewRequest(http.MethodPost, "/apps/test/releases/R1/promote", nil)
270+
w := httptest.NewRecorder()
271+
rackConfig := &config.RackConfig{Name: "default"}
272+
273+
// Should return nil because mfaService is nil (early return)
274+
err := h.verifyMFAIfRequired(req, w, authUser, rbac.ResourceRelease, rbac.ActionPromote, rackConfig, time.Now())
275+
require.NoError(t, err, "should return nil when mfaService is nil")
276+
}
277+
278+
// TestVerifyMFAIfRequired_NoMFANeeded tests that MFANone permissions skip all MFA checks.
279+
func TestVerifyMFAIfRequired_NoMFANeeded(t *testing.T) {
280+
t.Parallel()
281+
282+
h := &Handler{}
283+
284+
authUser := &auth.User{
285+
Email: "test@example.com",
286+
}
287+
288+
req := httptest.NewRequest(http.MethodGet, "/apps", nil)
289+
w := httptest.NewRecorder()
290+
rackConfig := &config.RackConfig{Name: "default"}
291+
292+
// ActionList on ResourceApp should be MFANone (read operation)
293+
err := h.verifyMFAIfRequired(req, w, authUser, rbac.ResourceApp, rbac.ActionList, rackConfig, time.Now())
294+
require.NoError(t, err, "read operations should not require MFA")
295+
}

0 commit comments

Comments
 (0)