Confused Deputy and Open Redirect: Attacks That Exploit Trust Relationships
Confused Deputy and Open Redirect
The Assumption
If a token is valid and not expired, the request carrying it is authorized. If the redirect URI is on our domain, the redirect is safe. Two assumptions. Both wrong.
The confused deputy attack does not require an invalid token. It requires a valid token used in an unintended context. The open redirect attack does not require a malicious domain in the redirect. It requires insufficient validation of the redirect URI parameter.
The Attack
Confused Deputy (Cross-Boundary)
The tenant-specific confused deputy was covered in CH8. This variant is different: it exploits boundaries between resource types, not tenants.
Alice has a valid token with scopes project:read project:write. She sends a request to DELETE /api/billing/subscription. The billing endpoint checks: is the token valid? (Yes.) Is the user authenticated? (Yes.) Does the user have write permissions? (Yes, project:write.) The billing endpoint serves the request and cancels Alice’s subscription.
The flaw: project:write does not mean billing:write. The endpoint checked for “write” authority without checking the resource type. The token’s scopes were intended for project operations, not billing operations.
Defense: Scope-specific authorization, not generic read/write checks.
Open Redirect in OAuth2
The OAuth2 authorization code flow redirects the user back to the client after authentication. The redirect_uri parameter tells the authorization server where to send the authorization code.
Attack:
-
Attacker constructs an authorization URL with a crafted
redirect_uri:https://auth.saas.example/authorize?client_id=frontend-shell&redirect_uri=https://app.saas.example/callback/../../../evil.example/steal&response_type=code&state=xyz -
The authorization server validates the
redirect_uri. If it uses prefix matching instead of exact matching,https://app.saas.example/callback/../../../evil.example/stealmay pass validation (it starts with the registered base URI). -
After authentication, the authorization server redirects the user (with the authorization code) to the attacker-controlled URL.
-
The attacker receives the authorization code and exchanges it for tokens.
Even without path traversal, open redirect vulnerabilities within the registered redirect URI can be chained: https://app.saas.example/callback?next=https://evil.example if the callback page performs client-side redirect to the next parameter.
The Spec or Mechanism
Redirect URI Validation (RFC 6749 Section 3.1.2.2)
The authorization server MUST require the client to register their complete redirect URI. The authorization server MUST compare using exact string matching.
Spring Authorization Server enforces exact string matching by default. The registered redirect_uri must match the request’s redirect_uri character for character. No prefix matching, no pattern matching, no path traversal normalization.
Scope-Based Authorization
OAuth2 scopes are the mechanism for confused deputy prevention. A token’s scope limits what operations it authorizes. The resource server must enforce scopes at the endpoint level, not just check for “authenticated.”
The Implementation
Scope-Specific Authorization
// VULNERABLE: Generic write check, confused deputy possible
@Bean
public SecurityFilterChain vulnerableChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated() // Only checks authentication
)
.build();
}
// HARDENED: Scope-specific authorization per resource type
@Bean
public SecurityFilterChain hardenedChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(tenantAwareJwtConverter()))
)
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/projects/**")
.hasAuthority("SCOPE_project:read")
.requestMatchers(HttpMethod.POST, "/api/projects/**")
.hasAuthority("SCOPE_project:write")
.requestMatchers(HttpMethod.DELETE, "/api/projects/**")
.hasAuthority("SCOPE_project:write")
.requestMatchers(HttpMethod.GET, "/api/billing/**")
.hasAuthority("SCOPE_billing:read")
.requestMatchers(HttpMethod.POST, "/api/billing/**")
.hasAuthority("SCOPE_billing:write")
.requestMatchers(HttpMethod.DELETE, "/api/billing/**")
.hasAuthority("SCOPE_billing:admin")
.anyRequest().denyAll()
)
.build();
}
Open Redirect Prevention
// Spring Authorization Server: redirect URI validation is exact by default
// No additional configuration needed for the standard case.
// But if you customize redirect URI validation, ensure exact matching:
@Bean
public RegisteredClientRepository registeredClientRepository(
PasswordEncoder passwordEncoder) {
RegisteredClient frontendShell = RegisteredClient
.withId(UUID.randomUUID().toString())
.clientId("frontend-shell")
.clientSecret(passwordEncoder.encode("secret"))
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
// Register EXACT redirect URIs, not patterns
.redirectUri("https://app.saas.example/callback")
// Do NOT register broad patterns:
// .redirectUri("https://app.saas.example/**") ← NEVER DO THIS
.build();
return new InMemoryRegisteredClientRepository(frontendShell);
}
Client-Side Redirect Validation
After the OAuth2 callback, the client may redirect the user to a “return URL.” This must be validated:
@GetMapping("/callback")
public String handleCallback(@RequestParam String code,
@RequestParam(required = false) String returnUrl) {
// Exchange code for tokens
TokenPair tokens = exchangeCode(code);
// Validate the return URL to prevent open redirect
if (returnUrl != null && !isAllowedRedirect(returnUrl)) {
return "redirect:/dashboard"; // Safe default
}
return "redirect:" + (returnUrl != null ? returnUrl : "/dashboard");
}
private boolean isAllowedRedirect(String url) {
try {
URI uri = new URI(url);
// Must be relative (no host) or on our domain
if (uri.getHost() != null) {
return uri.getHost().equals("app.saas.example");
}
// Relative paths: reject path traversal
if (url.contains("..") || url.contains("//")) {
return false;
}
// Must start with / (absolute path within our domain)
return url.startsWith("/");
} catch (URISyntaxException e) {
return false;
}
}
The Verification
@SpringBootTest
@AutoConfigureMockMvc
class ConfusedDeputyAndOpenRedirectTest {
@Autowired
private MockMvc mockMvc;
@Test
void projectScopeCannotAccessBilling() throws Exception {
Jwt jwt = Jwt.withTokenValue("test")
.header("alg", "RS256")
.claim("sub", "alice")
.claim("tenant_id", "acme-corp")
.claim("scope", List.of("project:read", "project:write"))
.claim("aud", List.of("core-api"))
.claim("iss", "https://auth.saas.example")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(300))
.build();
// Can access projects
mockMvc.perform(get("/api/projects")
.with(jwt().jwt(jwt)))
.andExpect(status().isOk());
// Cannot access billing
mockMvc.perform(delete("/api/billing/subscription")
.with(jwt().jwt(jwt)))
.andExpect(status().isForbidden());
}
@Test
void exactRedirectUriEnforced() throws Exception {
// Valid redirect URI
mockMvc.perform(get("/oauth2/authorize")
.param("client_id", "frontend-shell")
.param("redirect_uri", "https://app.saas.example/callback")
.param("response_type", "code"))
.andExpect(status().is3xxRedirection());
// Modified redirect URI (path traversal attempt)
mockMvc.perform(get("/oauth2/authorize")
.param("client_id", "frontend-shell")
.param("redirect_uri", "https://app.saas.example/callback/../evil")
.param("response_type", "code"))
.andExpect(status().isBadRequest());
// Completely different domain
mockMvc.perform(get("/oauth2/authorize")
.param("client_id", "frontend-shell")
.param("redirect_uri", "https://evil.example/steal")
.param("response_type", "code"))
.andExpect(status().isBadRequest());
}
@Test
void clientSideRedirectRejectsExternalUrl() throws Exception {
MockHttpSession session = authenticatedSession();
mockMvc.perform(get("/callback")
.session(session)
.param("code", "valid-code")
.param("returnUrl", "https://evil.example/phishing"))
.andExpect(redirectedUrl("/dashboard")); // Falls back to safe default
}
}
The first test is the confused deputy regression test: a token with project scopes cannot access billing resources. The second test validates exact redirect URI matching at the authorization server. Together, they prove that both the token-level and protocol-level confused deputy attacks are prevented.