A community based topic aggregation platform built on atproto
1package integration 2 3import ( 4 "Coves/internal/atproto/oauth" 5 "context" 6 "crypto/sha256" 7 "encoding/base64" 8 "net/http" 9 "net/http/httptest" 10 "net/url" 11 "testing" 12 "time" 13 14 oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth" 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 "github.com/go-chi/chi/v5" 17 "github.com/pressly/goose/v3" 18 "github.com/stretchr/testify/assert" 19 "github.com/stretchr/testify/require" 20) 21 22// TestOAuth_SessionFixationAttackPrevention tests that the mobile redirect binding 23// prevents session fixation attacks where an attacker plants a mobile_redirect_uri 24// cookie, then the user does a web login, and credentials get sent to attacker's deep link. 25// 26// Attack scenario: 27// 1. Attacker tricks user into visiting /oauth/mobile/login?redirect_uri=evil://steal 28// 2. This plants a mobile_redirect_uri cookie (lives 10 minutes) 29// 3. User later does normal web OAuth login via /oauth/login 30// 4. HandleCallback sees the stale mobile_redirect_uri cookie 31// 5. WITHOUT THE FIX: Callback sends sealed token, DID, session_id to attacker's deep link 32// 6. WITH THE FIX: Binding mismatch is detected, mobile cookies cleared, user gets web session 33func TestOAuth_SessionFixationAttackPrevention(t *testing.T) { 34 if testing.Short() { 35 t.Skip("Skipping OAuth session fixation test in short mode") 36 } 37 38 // Setup test database 39 db := setupTestDB(t) 40 defer func() { 41 if err := db.Close(); err != nil { 42 t.Logf("Failed to close database: %v", err) 43 } 44 }() 45 46 // Run migrations 47 require.NoError(t, goose.SetDialect("postgres")) 48 require.NoError(t, goose.Up(db, "../../internal/db/migrations")) 49 50 // Setup OAuth client and store 51 store := SetupOAuthTestStore(t, db) 52 client := SetupOAuthTestClient(t, store) 53 require.NotNil(t, client, "OAuth client should be initialized") 54 55 // Setup handler 56 handler := oauth.NewOAuthHandler(client, store) 57 58 // Setup router 59 r := chi.NewRouter() 60 r.Get("/oauth/callback", handler.HandleCallback) 61 62 t.Run("attack scenario - planted mobile cookie without binding", func(t *testing.T) { 63 ctx := context.Background() 64 65 // Step 1: Simulate a successful OAuth callback (like a user did web login) 66 // We'll create a mock session to simulate what ProcessCallback would return 67 testDID := "did:plc:test123456" 68 parsedDID, err := syntax.ParseDID(testDID) 69 require.NoError(t, err) 70 71 sessionID := "test-session-" + time.Now().Format("20060102150405") 72 testSession := oauthlib.ClientSessionData{ 73 AccountDID: parsedDID, 74 SessionID: sessionID, 75 HostURL: "http://localhost:3001", 76 AccessToken: "test-access-token", 77 Scopes: []string{"atproto"}, 78 } 79 80 // Save the session (simulating successful OAuth flow) 81 err = store.SaveSession(ctx, testSession) 82 require.NoError(t, err) 83 84 // Step 2: Attacker planted a mobile_redirect_uri cookie (without binding) 85 // This simulates the cookie being planted earlier by attacker 86 attackerRedirectURI := "evil://steal" 87 req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test&iss=http://localhost:3001", nil) 88 89 // Plant the attacker's cookie (URL escaped as it would be in real scenario) 90 req.AddCookie(&http.Cookie{ 91 Name: "mobile_redirect_uri", 92 Value: url.QueryEscape(attackerRedirectURI), 93 Path: "/oauth", 94 }) 95 // NOTE: No mobile_redirect_binding cookie! This is the attack scenario. 96 97 rec := httptest.NewRecorder() 98 99 // Step 3: Try to process the callback 100 // This would fail because ProcessCallback needs real OAuth code/state 101 // For this test, we're verifying the handler's security checks work 102 // even before ProcessCallback is called 103 104 // The handler will try to call ProcessCallback which will fail 105 // But we're testing that even if it succeeded, the mobile redirect 106 // validation would prevent the attack 107 handler.HandleCallback(rec, req) 108 109 // Step 4: Verify the attack was prevented 110 // The handler should reject the request due to missing binding 111 // Since ProcessCallback will fail first (no real OAuth code), we expect 112 // a 400 error, but the important thing is it doesn't redirect to evil://steal 113 114 assert.NotEqual(t, http.StatusFound, rec.Code, 115 "Should not redirect when ProcessCallback fails") 116 assert.NotContains(t, rec.Header().Get("Location"), "evil://", 117 "Should never redirect to attacker's URI") 118 }) 119 120 t.Run("legitimate mobile flow - with valid binding", func(t *testing.T) { 121 ctx := context.Background() 122 123 // Setup a legitimate mobile session 124 testDID := "did:plc:mobile123" 125 parsedDID, err := syntax.ParseDID(testDID) 126 require.NoError(t, err) 127 128 sessionID := "mobile-session-" + time.Now().Format("20060102150405") 129 testSession := oauthlib.ClientSessionData{ 130 AccountDID: parsedDID, 131 SessionID: sessionID, 132 HostURL: "http://localhost:3001", 133 AccessToken: "mobile-access-token", 134 Scopes: []string{"atproto"}, 135 } 136 137 // Save the session 138 err = store.SaveSession(ctx, testSession) 139 require.NoError(t, err) 140 141 // Create request with BOTH mobile_redirect_uri AND valid binding 142 // Use Universal Link URI that's in the allowlist 143 legitRedirectURI := "https://coves.social/app/oauth/callback" 144 csrfToken := "valid-csrf-token-for-mobile" 145 req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test&iss=http://localhost:3001", nil) 146 147 // Add mobile redirect URI cookie 148 req.AddCookie(&http.Cookie{ 149 Name: "mobile_redirect_uri", 150 Value: url.QueryEscape(legitRedirectURI), 151 Path: "/oauth", 152 }) 153 154 // Add CSRF token (required for mobile flow) 155 req.AddCookie(&http.Cookie{ 156 Name: "oauth_csrf", 157 Value: csrfToken, 158 Path: "/oauth", 159 }) 160 161 // Add VALID binding cookie (this is what prevents the attack) 162 // In real flow, this would be set by HandleMobileLogin 163 // The binding now includes the CSRF token for double-submit validation 164 mobileBinding := generateMobileRedirectBindingForTest(csrfToken, legitRedirectURI) 165 req.AddCookie(&http.Cookie{ 166 Name: "mobile_redirect_binding", 167 Value: mobileBinding, 168 Path: "/oauth", 169 }) 170 171 rec := httptest.NewRecorder() 172 handler.HandleCallback(rec, req) 173 174 // This will also fail at ProcessCallback (no real OAuth code) 175 // but we're verifying the binding validation logic is in place 176 // In a real integration test with PDS, this would succeed 177 assert.NotEqual(t, http.StatusFound, rec.Code, 178 "Should not redirect when ProcessCallback fails (expected in mock test)") 179 }) 180 181 t.Run("binding mismatch - attacker tries wrong binding", func(t *testing.T) { 182 ctx := context.Background() 183 184 // Setup session 185 testDID := "did:plc:bindingtest" 186 parsedDID, err := syntax.ParseDID(testDID) 187 require.NoError(t, err) 188 189 sessionID := "binding-test-" + time.Now().Format("20060102150405") 190 testSession := oauthlib.ClientSessionData{ 191 AccountDID: parsedDID, 192 SessionID: sessionID, 193 HostURL: "http://localhost:3001", 194 AccessToken: "binding-test-token", 195 Scopes: []string{"atproto"}, 196 } 197 198 err = store.SaveSession(ctx, testSession) 199 require.NoError(t, err) 200 201 // Attacker tries to plant evil redirect with a binding from different URI 202 attackerRedirectURI := "evil://steal" 203 attackerCSRF := "attacker-csrf-token" 204 req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test&iss=http://localhost:3001", nil) 205 206 req.AddCookie(&http.Cookie{ 207 Name: "mobile_redirect_uri", 208 Value: url.QueryEscape(attackerRedirectURI), 209 Path: "/oauth", 210 }) 211 212 req.AddCookie(&http.Cookie{ 213 Name: "oauth_csrf", 214 Value: attackerCSRF, 215 Path: "/oauth", 216 }) 217 218 // Use binding from a DIFFERENT CSRF token and URI (attacker's attempt to forge) 219 // Even if attacker knows the redirect URI, they don't know the user's CSRF token 220 wrongBinding := generateMobileRedirectBindingForTest("different-csrf", "https://coves.social/app/oauth/callback") 221 req.AddCookie(&http.Cookie{ 222 Name: "mobile_redirect_binding", 223 Value: wrongBinding, 224 Path: "/oauth", 225 }) 226 227 rec := httptest.NewRecorder() 228 handler.HandleCallback(rec, req) 229 230 // Should fail due to binding mismatch (even before ProcessCallback) 231 // The binding validation happens after ProcessCallback in the real code, 232 // but the mismatch would be caught and cookies cleared 233 assert.NotContains(t, rec.Header().Get("Location"), "evil://", 234 "Should never redirect to attacker's URI on binding mismatch") 235 }) 236 237 t.Run("CSRF token value mismatch - attacker tries different CSRF", func(t *testing.T) { 238 ctx := context.Background() 239 240 // Setup session 241 testDID := "did:plc:csrftest" 242 parsedDID, err := syntax.ParseDID(testDID) 243 require.NoError(t, err) 244 245 sessionID := "csrf-test-" + time.Now().Format("20060102150405") 246 testSession := oauthlib.ClientSessionData{ 247 AccountDID: parsedDID, 248 SessionID: sessionID, 249 HostURL: "http://localhost:3001", 250 AccessToken: "csrf-test-token", 251 Scopes: []string{"atproto"}, 252 } 253 254 err = store.SaveSession(ctx, testSession) 255 require.NoError(t, err) 256 257 // This tests the P1 security fix: CSRF token VALUE must be validated, not just presence 258 // Attack scenario: 259 // 1. User starts mobile login with CSRF token A and redirect URI X 260 // 2. Binding = hash(A + X) is stored in cookie 261 // 3. Attacker somehow gets user to have CSRF token B in cookie (different from A) 262 // 4. Callback receives CSRF token B, redirect URI X, binding = hash(A + X) 263 // 5. hash(B + X) != hash(A + X), so attack is detected 264 265 originalCSRF := "original-csrf-token-set-at-login" 266 redirectURI := "https://coves.social/app/oauth/callback" 267 // Binding was created with original CSRF token 268 originalBinding := generateMobileRedirectBindingForTest(originalCSRF, redirectURI) 269 270 // But attacker managed to change the CSRF cookie 271 attackerCSRF := "attacker-replaced-csrf" 272 273 req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test&iss=http://localhost:3001", nil) 274 275 req.AddCookie(&http.Cookie{ 276 Name: "mobile_redirect_uri", 277 Value: url.QueryEscape(redirectURI), 278 Path: "/oauth", 279 }) 280 281 // Attacker's CSRF token (different from what created the binding) 282 req.AddCookie(&http.Cookie{ 283 Name: "oauth_csrf", 284 Value: attackerCSRF, 285 Path: "/oauth", 286 }) 287 288 // Original binding (created with original CSRF token) 289 req.AddCookie(&http.Cookie{ 290 Name: "mobile_redirect_binding", 291 Value: originalBinding, 292 Path: "/oauth", 293 }) 294 295 rec := httptest.NewRecorder() 296 handler.HandleCallback(rec, req) 297 298 // Should fail because hash(attackerCSRF + redirectURI) != hash(originalCSRF + redirectURI) 299 // This is the key security fix - CSRF token VALUE is now validated 300 assert.NotEqual(t, http.StatusFound, rec.Code, 301 "Should not redirect when CSRF token doesn't match binding") 302 }) 303} 304 305// generateMobileRedirectBindingForTest generates a binding for testing 306// This mirrors the actual logic in handlers_security.go: 307// binding = base64(sha256(csrfToken + "|" + redirectURI)[:16]) 308func generateMobileRedirectBindingForTest(csrfToken, mobileRedirectURI string) string { 309 combined := csrfToken + "|" + mobileRedirectURI 310 hash := sha256.Sum256([]byte(combined)) 311 return base64.URLEncoding.EncodeToString(hash[:16]) 312}