Skip to content

Commit 50b7fe4

Browse files
committed
DC-150 Update: resolvedReturnUrl validates url as relative path before passing to router.replace()
1 parent bae743d commit 50b7fe4

5 files changed

Lines changed: 183 additions & 6 deletions

File tree

src/SEBT.Portal.Api/Controllers/Auth/OidcController.cs

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ public async Task<IActionResult> GetConfig(
8888
/// Completes OIDC login when the Next.js server has already exchanged the code and validated the id_token.
8989
/// Accepts a short-lived callbackToken (JWT signed with Oidc:CompleteLoginSigningKey) containing IdP claims;
9090
/// copies non-common IdP claims into the portal JWT and returns it.
91+
/// Step-up may echo <c>returnUrl</c> only when it is a safe relative path (see <see cref="TrySanitizeStepUpReturnUrl"/>).
9192
/// </summary>
9293
[HttpPost("complete-login")]
9394
[ProducesResponseType(StatusCodes.Status200OK)]
@@ -184,9 +185,48 @@ public async Task<IActionResult> CompleteLogin(
184185
}
185186

186187
var token = jwtService.GenerateToken(user, additionalClaims);
187-
return body.IsStepUp && !string.IsNullOrEmpty(body.ReturnUrl)
188-
? Ok(new { token, returnUrl = body.ReturnUrl })
189-
: Ok(new { token });
188+
if (!body.IsStepUp)
189+
return Ok(new { token });
190+
191+
var safeReturnUrl = TrySanitizeStepUpReturnUrl(body.ReturnUrl);
192+
if (safeReturnUrl != null)
193+
return Ok(new { token, returnUrl = safeReturnUrl });
194+
195+
if (!string.IsNullOrWhiteSpace(body.ReturnUrl))
196+
logger.LogWarning("Step-up complete-login: returnUrl rejected (must be a safe relative path).");
197+
198+
return Ok(new { token });
199+
}
200+
201+
private const int MaxStepUpReturnUrlLength = 4096;
202+
203+
/// <summary>
204+
/// Step-up post-login navigation: only same-document relative paths (for example <c>/profile/address</c>).
205+
/// Rejects absolute URLs and scheme-relative paths so the API never echoes an open redirect.
206+
/// </summary>
207+
private static string? TrySanitizeStepUpReturnUrl(string? returnUrl)
208+
{
209+
if (string.IsNullOrWhiteSpace(returnUrl))
210+
return null;
211+
var t = returnUrl.Trim();
212+
if (t.Length > MaxStepUpReturnUrlLength)
213+
return null;
214+
if (!t.StartsWith("/", StringComparison.Ordinal))
215+
return null;
216+
if (t.StartsWith("//", StringComparison.Ordinal))
217+
return null;
218+
var pathPart = t;
219+
var qIdx = t.IndexOf('?', StringComparison.Ordinal);
220+
if (qIdx >= 0)
221+
pathPart = t[..qIdx];
222+
if (pathPart.Contains("://", StringComparison.Ordinal))
223+
return null;
224+
if (t.Contains("\\", StringComparison.Ordinal))
225+
return null;
226+
if (t.Contains("\r", StringComparison.Ordinal) || t.Contains("\n", StringComparison.Ordinal)
227+
|| t.Contains("\0", StringComparison.Ordinal))
228+
return null;
229+
return t;
190230
}
191231

192232
/// <summary>

src/SEBT.Portal.Api/Models/CompleteLoginRequest.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ namespace SEBT.Portal.Api.Models;
66
/// Request body for completing OIDC login after the Next.js server has exchanged the code and validated the id_token.
77
/// CallbackToken is a short-lived JWT signed by the Next.js app (OIDC_COMPLETE_LOGIN_SIGNING_KEY) containing the IdP claims.
88
/// When IsStepUp is true, updates the user's IAL level and ID proofing status instead of creating a new session.
9+
/// When set, <c>returnUrl</c> must be a safe relative path (starts with <c>/</c>, not <c>//</c>, no scheme). Otherwise it is ignored.
910
/// </summary>
1011
public record CompleteLoginRequest(
1112
[property: JsonPropertyName("stateCode")] string? StateCode,
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import {
4+
isSafeOidcStepUpReturnPath,
5+
OIDC_RETURN_PATH_MAX_LENGTH,
6+
OidcCompleteLoginResponseSchema
7+
} from './schema'
8+
9+
describe('isSafeOidcStepUpReturnPath', () => {
10+
it('accepts relative path with query', () => {
11+
expect(isSafeOidcStepUpReturnPath('/profile/address')).toBe(true)
12+
expect(isSafeOidcStepUpReturnPath('/dash?q=1')).toBe(true)
13+
})
14+
15+
it('rejects absolute and scheme-relative URLs', () => {
16+
expect(isSafeOidcStepUpReturnPath('https://evil.example/')).toBe(false)
17+
expect(isSafeOidcStepUpReturnPath('//evil.example/path')).toBe(false)
18+
})
19+
20+
it('allows http in query but not in path segment', () => {
21+
expect(isSafeOidcStepUpReturnPath('/redirect?u=http://evil')).toBe(true)
22+
expect(isSafeOidcStepUpReturnPath('/http://evil')).toBe(false)
23+
})
24+
25+
it('rejects backslash and newlines in path', () => {
26+
expect(isSafeOidcStepUpReturnPath('/path\\evil')).toBe(false)
27+
expect(isSafeOidcStepUpReturnPath('/pa\nth')).toBe(false)
28+
})
29+
30+
it('rejects overlong strings', () => {
31+
expect(isSafeOidcStepUpReturnPath(`/${'a'.repeat(OIDC_RETURN_PATH_MAX_LENGTH)}`)).toBe(false)
32+
})
33+
})
34+
35+
describe('OidcCompleteLoginResponseSchema', () => {
36+
it('parses token with safe returnUrl', () => {
37+
const parsed = OidcCompleteLoginResponseSchema.safeParse({
38+
token: 'jwt',
39+
returnUrl: '/dashboard'
40+
})
41+
expect(parsed.success).toBe(true)
42+
expect(parsed.data?.returnUrl).toBe('/dashboard')
43+
})
44+
45+
it('rejects unsafe returnUrl', () => {
46+
const parsed = OidcCompleteLoginResponseSchema.safeParse({
47+
token: 'jwt',
48+
returnUrl: 'https://evil.example/'
49+
})
50+
expect(parsed.success).toBe(false)
51+
})
52+
53+
it('allows missing returnUrl', () => {
54+
const parsed = OidcCompleteLoginResponseSchema.safeParse({ token: 'jwt' })
55+
expect(parsed.success).toBe(true)
56+
})
57+
})

src/SEBT.Portal.Web/src/features/auth/api/oidc/schema.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,32 @@ export const OidcCallbackTokenResponseSchema = z.object({
2121

2222
export type OidcCallbackTokenResponse = z.infer<typeof OidcCallbackTokenResponseSchema>
2323

24-
/** Step-up echoes `returnUrl` from PKCE: same-origin path or absolute URL; not always `z.string().url()`. */
24+
/** Max length for post-OIDC relative return path (path + query). */
25+
export const OIDC_RETURN_PATH_MAX_LENGTH = 4096
26+
27+
/** Path segment (before `?`) for scheme checks; query may contain arbitrary values. */
28+
function oidcReturnPathSegment(v: string): string {
29+
const q = v.indexOf('?')
30+
return q < 0 ? v : v.slice(0, q)
31+
}
32+
33+
/** Same rules as API `TrySanitizeStepUpReturnUrl`: relative path only, no open redirects. */
34+
export function isSafeOidcStepUpReturnPath(v: string): boolean {
35+
const t = v.trim()
36+
if (t.length === 0 || t.length > OIDC_RETURN_PATH_MAX_LENGTH) return false
37+
if (!t.startsWith('/')) return false
38+
if (t.startsWith('//')) return false
39+
if (oidcReturnPathSegment(t).includes('://')) return false
40+
if (t.includes('\\')) return false
41+
if (/[\r\n\0]/.test(t)) return false
42+
return true
43+
}
44+
2545
const returnUrlAfterOidcSchema = z
2646
.string()
2747
.optional()
28-
.refine((v) => v == null || v === '' || v.startsWith('/') || /^https?:\/\//i.test(v), {
29-
message: 'returnUrl must be a path or http(s) URL'
48+
.refine((v) => v == null || v === '' || isSafeOidcStepUpReturnPath(v), {
49+
message: 'returnUrl must be a safe relative path'
3050
})
3151

3252
export const OidcCompleteLoginResponseSchema = z.object({

test/SEBT.Portal.Tests/Unit/Controllers/OidcControllerTests.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,65 @@ public async Task CompleteLogin_WhenStepUpAndNoExistingUser_Returns400()
190190
await _userRepository.DidNotReceive().UpdateUserAsync(Arg.Any<User>(), Arg.Any<CancellationToken>());
191191
}
192192

193+
[Fact]
194+
public async Task CompleteLogin_WhenStepUpAndSafeReturnUrl_Returns200WithReturnUrl()
195+
{
196+
const string signingKey = "complete-login-signing-key-at-least-32-characters-long";
197+
_config["Oidc:CompleteLoginSigningKey"].Returns(signingKey);
198+
199+
var callbackToken = CreateValidCallbackToken(signingKey, email: "user@example.com");
200+
var body = new CompleteLoginRequest(
201+
CoStateKey,
202+
callbackToken,
203+
IsStepUp: true,
204+
ReturnUrl: "/profile/address?q=1");
205+
206+
var user = new User { Id = 1, Email = "user@example.com" };
207+
_userRepository.GetUserByEmailAsync("user@example.com", Arg.Any<CancellationToken>())
208+
.Returns(user);
209+
_userRepository.UpdateUserAsync(Arg.Any<User>(), Arg.Any<CancellationToken>())
210+
.Returns(Task.CompletedTask);
211+
212+
const string portalJwt = "portal-jwt-returned-by-service";
213+
_jwtService.GenerateToken(Arg.Any<User>(), Arg.Any<IReadOnlyDictionary<string, string>?>())
214+
.Returns(portalJwt);
215+
216+
var result = await _controller.CompleteLogin(body, CancellationToken.None);
217+
218+
var okResult = Assert.IsType<OkObjectResult>(result);
219+
var valueType = okResult.Value!.GetType();
220+
Assert.Equal("/profile/address?q=1", valueType.GetProperty("returnUrl")!.GetValue(okResult.Value) as string);
221+
Assert.Equal(portalJwt, valueType.GetProperty("token")!.GetValue(okResult.Value) as string);
222+
}
223+
224+
[Fact]
225+
public async Task CompleteLogin_WhenStepUpAndExternalReturnUrl_OmitsReturnUrlFromResponse()
226+
{
227+
const string signingKey = "complete-login-signing-key-at-least-32-characters-long";
228+
_config["Oidc:CompleteLoginSigningKey"].Returns(signingKey);
229+
230+
var callbackToken = CreateValidCallbackToken(signingKey, email: "user@example.com");
231+
var body = new CompleteLoginRequest(
232+
CoStateKey,
233+
callbackToken,
234+
IsStepUp: true,
235+
ReturnUrl: "https://evil.example/phish");
236+
237+
var user = new User { Id = 1, Email = "user@example.com" };
238+
_userRepository.GetUserByEmailAsync("user@example.com", Arg.Any<CancellationToken>())
239+
.Returns(user);
240+
_userRepository.UpdateUserAsync(Arg.Any<User>(), Arg.Any<CancellationToken>())
241+
.Returns(Task.CompletedTask);
242+
243+
_jwtService.GenerateToken(Arg.Any<User>(), Arg.Any<IReadOnlyDictionary<string, string>?>())
244+
.Returns("portal-jwt");
245+
246+
var result = await _controller.CompleteLogin(body, CancellationToken.None);
247+
248+
var okResult = Assert.IsType<OkObjectResult>(result);
249+
Assert.Null(okResult.Value!.GetType().GetProperty("returnUrl"));
250+
}
251+
193252
private static string CreateValidCallbackToken(string signingKey, string email)
194253
{
195254
return CreateCallbackTokenWithClaims(signingKey, new Claim("email", email));

0 commit comments

Comments
 (0)