Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Ivy/Auth/DefaultAuthApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ public class OAuthFlowView(AuthOption option) : ViewBase

var state = this.UseState(() => registry.RegisterPending(args.ConnectionId, option.Id ?? ""));

var oauthUriBuilder = new UriBuilder($"{args.BaseUrl}/ivy/auth/oauth-login")
var oauthUriBuilder = new UriBuilder($"{args.BaseUrl}ivy/auth/oauth-login")
{
Query = $"optionId={Uri.EscapeDataString(option.Id ?? "")}&callbackId={Uri.EscapeDataString(state.Value)}&connectionId={Uri.EscapeDataString(args.ConnectionId)}"
};
Expand Down
2 changes: 1 addition & 1 deletion src/Ivy/Auth/IAuthTokenHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ namespace Ivy;

public interface IAuthTokenHandler
{
Task InitializeAsync(IAuthTokenHandlerSession authSession, string requestScheme, string requestHost, CancellationToken cancellationToken = default)
Task InitializeAsync(IAuthTokenHandlerSession authSession, string requestScheme, string requestHost, string? basePath = null, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
Expand Down
7 changes: 5 additions & 2 deletions src/Ivy/Core/Auth/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
[FromQuery] string callbackId,
[FromQuery] string connectionId,
[FromServices] AppSessionStore sessionStore,
[FromServices] ServerArgs serverArgs,
[FromServices] ILogger<AuthController> logger)
{
if (string.IsNullOrWhiteSpace(optionId) || string.IsNullOrWhiteSpace(callbackId) || string.IsNullOrWhiteSpace(connectionId))
Expand Down Expand Up @@ -58,7 +59,7 @@
scheme = forwardedProto.ToString();
}
var host = HttpContext.Request.Host.Value ?? throw new InvalidOperationException("Host not found in request");
var callback = WebhookEndpoint.CreateAuthCallback(callbackId, scheme, host);
var callback = WebhookEndpoint.CreateAuthCallback(callbackId, scheme, host, serverArgs.BasePath);

try
{
Expand All @@ -85,6 +86,7 @@
[FromServices] IOAuthCallbackRegistry registry,
[FromServices] IAuthProvider authProvider,
[FromServices] AppSessionStore sessionStore,
[FromServices] ServerArgs serverArgs,
[FromServices] ILogger<AuthController> logger)
{
var effectiveId = callbackId ?? state;
Expand Down Expand Up @@ -140,7 +142,8 @@
cookies.AddCookiesForBrokeredSessions(tempSession.BrokeredSessions);
cookies.WriteToResponse(Response);

return Redirect("/?oauthLogin=1");
var redirectUrl = serverArgs.BasePath != null ? $"{serverArgs.BasePath}/?oauthLogin=1" : "/?oauthLogin=1";
return Redirect(redirectUrl);

Check warning

Code scanning / CodeQL

URL redirection from remote source Medium

Untrusted URL redirection due to
user-provided value
.

Copilot Autofix

AI about 18 hours ago

In general, to fix untrusted URL redirection, ensure that any untrusted component (here, serverArgs.BasePath) cannot cause the redirect to point to an external host. The redirect target should either be a purely relative path or be validated against a whitelist or host check before passing it to Redirect(...).

For this specific method, the safest approach without changing intended functionality is to normalize serverArgs.BasePath into a strictly relative application path and then construct the final URL from that normalized path. We should prevent BasePath from being interpreted as an absolute URI or protocol-relative URL and ensure it always produces something like "/", "/some-base/", etc. A straightforward way is:

  1. Start from a default "/" if BasePath is null or empty.
  2. Ensure the base path starts with a single /.
  3. Remove any scheme/host information and ignore inputs that look like absolute URLs or protocol-relative URLs.
  4. Build the redirect as normalizedBasePath + (normalizedBasePath.EndsWith("/") ? "" : "/") + "?oauthLogin=1".

This can be implemented directly in OAuthCallback around lines 145–146, replacing the simple string interpolation with a small normalization block, without touching other methods or adding dependencies. No new imports are needed; string operations are sufficient.

Suggested changeset 1
src/Ivy/Core/Auth/AuthController.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/Ivy/Core/Auth/AuthController.cs b/src/Ivy/Core/Auth/AuthController.cs
--- a/src/Ivy/Core/Auth/AuthController.cs
+++ b/src/Ivy/Core/Auth/AuthController.cs
@@ -142,7 +142,34 @@
             cookies.AddCookiesForBrokeredSessions(tempSession.BrokeredSessions);
             cookies.WriteToResponse(Response);
 
-            var redirectUrl = serverArgs.BasePath != null ? $"{serverArgs.BasePath}/?oauthLogin=1" : "/?oauthLogin=1";
+            // Ensure redirect URL remains within this application by normalizing BasePath to a relative path
+            var basePath = serverArgs.BasePath;
+            if (string.IsNullOrWhiteSpace(basePath))
+            {
+                basePath = "/";
+            }
+
+            // Disallow absolute or protocol-relative URLs in BasePath
+            if (basePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
+                basePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
+                basePath.StartsWith("//", StringComparison.Ordinal))
+            {
+                basePath = "/";
+            }
+
+            // Normalize to a rooted, application-local path
+            if (!basePath.StartsWith("/"))
+            {
+                basePath = "/" + basePath;
+            }
+
+            // Avoid double slashes before query
+            if (!basePath.EndsWith("/"))
+            {
+                basePath += "/";
+            }
+
+            var redirectUrl = $"{basePath}?oauthLogin=1";
             return Redirect(redirectUrl);
         }
         catch (Exception ex)
EOF
@@ -142,7 +142,34 @@
cookies.AddCookiesForBrokeredSessions(tempSession.BrokeredSessions);
cookies.WriteToResponse(Response);

var redirectUrl = serverArgs.BasePath != null ? $"{serverArgs.BasePath}/?oauthLogin=1" : "/?oauthLogin=1";
// Ensure redirect URL remains within this application by normalizing BasePath to a relative path
var basePath = serverArgs.BasePath;
if (string.IsNullOrWhiteSpace(basePath))
{
basePath = "/";
}

// Disallow absolute or protocol-relative URLs in BasePath
if (basePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
basePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
basePath.StartsWith("//", StringComparison.Ordinal))
{
basePath = "/";
}

// Normalize to a rooted, application-local path
if (!basePath.StartsWith("/"))
{
basePath = "/" + basePath;
}

// Avoid double slashes before query
if (!basePath.EndsWith("/"))
{
basePath += "/";
}

var redirectUrl = $"{basePath}?oauthLogin=1";
return Redirect(redirectUrl);
}
catch (Exception ex)
Copilot is powered by AI and may make mistakes. Always verify output.
}
catch (Exception ex)
{
Expand Down
4 changes: 2 additions & 2 deletions src/Ivy/Core/Auth/CheckedAuthTokenHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ public class CheckedAuthTokenHandler(IAuthTokenHandler innerAuthTokenHandler) :
{
protected readonly IAuthTokenHandler _innerAuthTokenHandler = innerAuthTokenHandler;

public Task InitializeAsync(IAuthTokenHandlerSession authSession, string requestScheme, string requestHost, CancellationToken cancellationToken = default)
public Task InitializeAsync(IAuthTokenHandlerSession authSession, string requestScheme, string requestHost, string? basePath = null, CancellationToken cancellationToken = default)
{
var checkedSession = authSession.WithCheckedAccess()
.WithTokenAccess(AuthSessionAccessMode.ReadWrite)
.WithSessionDataAccess(AuthSessionAccessMode.ReadWrite)
.WithBrokeredSessionsAccess(AuthSessionAccessMode.ReadWrite)
.Build();
return _innerAuthTokenHandler.InitializeAsync(checkedSession, requestScheme, requestHost, cancellationToken);
return _innerAuthTokenHandler.InitializeAsync(checkedSession, requestScheme, requestHost, basePath, cancellationToken);
}

public Task<AuthToken?> RefreshAccessTokenAsync(IAuthTokenHandlerSession authSession, CancellationToken cancellationToken = default)
Expand Down
13 changes: 10 additions & 3 deletions src/Ivy/Core/Server/AppHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ public override async Task OnConnectedAsync()
requestScheme = forwardedProto.ToString();
}

// Get base path from X-Forwarded-Prefix header (for reverse proxy), or fall back to server.Args
var basePath = server.Args?.BasePath;
if (httpContext.Request.Headers.TryGetValue("X-Forwarded-Prefix", out var forwardedPrefix) && !string.IsNullOrEmpty(forwardedPrefix.ToString()))
{
basePath = forwardedPrefix.ToString().Trim('/');
}

var machineId = AppRouter.GetMachineId(httpContext);

// Resolve route before auth so we can avoid reload loop on error page (skip LogoutAsync) and avoid overriding to Auth app
Expand All @@ -134,7 +141,7 @@ public override async Task OnConnectedAsync()

var oldSession = authSession.TakeSnapshot();
await TimeoutHelper.WithTimeoutAsync(
ct => authProvider.InitializeAsync(authSession, requestScheme, request.Host.Value!, ct),
ct => authProvider.InitializeAsync(authSession, requestScheme, request.Host.Value!, basePath, ct),
Context.ConnectionAborted);
if (authSession.HasChangedSince(oldSession))
{
Expand Down Expand Up @@ -193,7 +200,7 @@ await TimeoutHelper.WithTimeoutAsync(

if (routeResult.AppDescriptor.Title is { } title && routeResult.AppId != AppIds.AppShell && parentId == null)
{
clientProvider.SetTitle(title, server.Args.Metadata.Title);
clientProvider.SetTitle(title, server.Args?.Metadata?.Title);
}

appServices.AddSingleton(routeResult.AppRepository);
Expand Down Expand Up @@ -699,7 +706,7 @@ private async Task BrokeredTokenRefreshLoopAsync(string connectionId, string pro
var appContext = session.AppServices.GetService<AppContext>();
if (appContext != null)
{
await handler.InitializeAsync(providerSession, appContext.Scheme, appContext.Host, cancellationToken);
await handler.InitializeAsync(providerSession, appContext.Scheme, appContext.Host, appContext.BasePath, cancellationToken);
}

var client = session.AppServices.GetRequiredService<IClientProvider>();
Expand Down
14 changes: 8 additions & 6 deletions src/Ivy/Core/WebhookEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,21 @@ private WebhookEndpoint(string id, string baseUrl)
BaseUrl = baseUrl;
}

public static WebhookEndpoint CreateWebhook(string id, string scheme, string host)
public static WebhookEndpoint CreateWebhook(string id, string scheme, string host, string? basePath = null)
{
return new(id, BuildWebhookBaseUrl(scheme, host));
return new(id, BuildWebhookBaseUrl(scheme, host, basePath));
}

public static WebhookEndpoint CreateAuthCallback(string id, string scheme, string host)
public static WebhookEndpoint CreateAuthCallback(string id, string scheme, string host, string? basePath = null)
{
return new(id, BuildAuthCallbackBaseUrl(scheme, host));
return new(id, BuildAuthCallbackBaseUrl(scheme, host, basePath));
}

public static string BuildWebhookBaseUrl(string scheme, string host) => $"{scheme}://{host}/ivy/webhook";
public static string BuildWebhookBaseUrl(string scheme, string host, string? basePath = null)
=> basePath != null ? $"{scheme}://{host}{basePath}/ivy/webhook" : $"{scheme}://{host}/ivy/webhook";

public static string BuildAuthCallbackBaseUrl(string scheme, string host) => $"{scheme}://{host}/ivy/auth/callback";
public static string BuildAuthCallbackBaseUrl(string scheme, string host, string? basePath = null)
=> basePath != null ? $"{scheme}://{host}{basePath}/ivy/auth/callback" : $"{scheme}://{host}/ivy/auth/callback";

public Uri GetUri(bool includeIdInPath = true) => includeIdInPath
? new Uri($"{BaseUrl}/{Id}")
Expand Down
2 changes: 1 addition & 1 deletion src/Ivy/Hooks/UseWebhook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public static WebhookEndpoint UseWebhook(this IViewContext context, Func<HttpReq

context.UseEffect(() => webhookController.Register(webhookId.Value, handler), [EffectTrigger.OnMount()]);

return WebhookEndpoint.CreateWebhook(webhookId.Value, appContext.Scheme, appContext.Host);
return WebhookEndpoint.CreateWebhook(webhookId.Value, appContext.Scheme, appContext.Host, appContext.BasePath);
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/auth/Ivy.Auth.Clerk/ClerkAuthTokenHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,10 @@ public ClerkAuthTokenHandler(IConfiguration configuration)
HttpClient.DefaultRequestHeaders.Add("User-Agent", userAgent);
}

public async Task InitializeAsync(IAuthTokenHandlerSession authSession, string requestScheme, string requestHost, CancellationToken cancellationToken = default)
public async Task InitializeAsync(IAuthTokenHandlerSession authSession, string requestScheme, string requestHost, string? basePath = null, CancellationToken cancellationToken = default)
{
_origin = $"{requestScheme}://{requestHost}";
_callbackBaseUrl = WebhookEndpoint.BuildAuthCallbackBaseUrl(requestScheme, requestHost);
_origin = basePath != null ? $"{requestScheme}://{requestHost}{basePath}" : $"{requestScheme}://{requestHost}";
_callbackBaseUrl = WebhookEndpoint.BuildAuthCallbackBaseUrl(requestScheme, requestHost, basePath);

var frontendClient = MakeFrontendApiClient(authSession);
if (IsProduction)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ public MicrosoftEntraAuthTokenHandler(IConfiguration configuration)
);
}

public Task InitializeAsync(IAuthTokenHandlerSession authSession, string requestScheme, string requestHost, CancellationToken cancellationToken = default)
public Task InitializeAsync(IAuthTokenHandlerSession authSession, string requestScheme, string requestHost, string? basePath = null, CancellationToken cancellationToken = default)
{
var baseUrl = WebhookEndpoint.BuildAuthCallbackBaseUrl(requestScheme, requestHost);
var baseUrl = WebhookEndpoint.BuildAuthCallbackBaseUrl(requestScheme, requestHost, basePath);
SetBaseUrl(baseUrl);
return Task.CompletedTask;
}
Expand Down
Loading