From 7c6d3960425198105c07d225481665904e7fb6da Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Sat, 18 Jul 2026 12:20:08 +0200 Subject: [PATCH 1/9] fix(auth): fixes unlimited access when no authentication is set up --- src/ReverseLlama.Server/AdminEndpoints.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ReverseLlama.Server/AdminEndpoints.cs b/src/ReverseLlama.Server/AdminEndpoints.cs index d66bd42..c77fb57 100644 --- a/src/ReverseLlama.Server/AdminEndpoints.cs +++ b/src/ReverseLlama.Server/AdminEndpoints.cs @@ -41,6 +41,14 @@ internal static class AdminEndpoints { api.RequireAuthorization(); } + else + { + api.AddEndpointFilter((context, next) => + new ValueTask( + Results.Json( + new { error = "No authentication configured - authentication required to use the admin API" }, + statusCode: StatusCodes.Status403Forbidden))); + } api.MapGet("/summary", (HttpContext context, TunnelHub hub, ManagementStore store) => Results.Json(BuildSummary(context.User, hub, store, settings))); From 9a1048936efb7ae914ab61b5744f9297f74f7346 Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Sat, 18 Jul 2026 14:37:30 +0200 Subject: [PATCH 2/9] feat(auth): adds auth rate limiting --- src/ReverseLlama.Server/AuthRateLimiter.cs | 116 +++++++++++++++++++++ src/ReverseLlama.Server/Program.cs | 62 +++++++++++ 2 files changed, 178 insertions(+) create mode 100644 src/ReverseLlama.Server/AuthRateLimiter.cs diff --git a/src/ReverseLlama.Server/AuthRateLimiter.cs b/src/ReverseLlama.Server/AuthRateLimiter.cs new file mode 100644 index 0000000..54a6401 --- /dev/null +++ b/src/ReverseLlama.Server/AuthRateLimiter.cs @@ -0,0 +1,116 @@ +using System.Collections.Concurrent; + +namespace ReverseLlama.Server; + +internal sealed class AuthRateLimiter +{ + private readonly ConcurrentDictionary _attempts = new(StringComparer.OrdinalIgnoreCase); + private readonly ILogger _logger; + + public AuthRateLimiter(ILogger logger) + { + _logger = logger; + } + + public void RecordFailure(string ipAddress, string endpoint) + { + var info = _attempts.GetOrAdd(ipAddress, _ => new AuthAttemptInfo()); + + lock (info) + { + info.Count++; + info.LastAttemptUtc = DateTime.UtcNow; + + if (info.Count >= 20) + { + info.BlockedUntilUtc = DateTime.UtcNow.AddHours(48); + _logger.LogWarning( + "IP {IpAddress} blocked for 48 hours after {Count} failed auth attempts (last: {Endpoint})", + ipAddress, info.Count, endpoint); + } + else + { + _logger.LogWarning( + "Failed auth attempt #{Count} from {IpAddress} on {Endpoint}", + info.Count, ipAddress, endpoint); + } + } + } + + public (bool Allowed, TimeSpan? RetryAfter, bool IsBlocked) CheckRateLimit(string ipAddress) + { + if (!_attempts.TryGetValue(ipAddress, out var info)) + { + return (true, null, false); + } + + lock (info) + { + if (info.BlockedUntilUtc is { } blockedUntil) + { + if (blockedUntil > DateTime.UtcNow) + { + return (false, blockedUntil - DateTime.UtcNow, true); + } + + info.Count = 0; + info.BlockedUntilUtc = null; + info.LastAttemptUtc = DateTime.MinValue; + return (true, null, false); + } + + var waitTime = CalculateWaitTime(info.Count); + if (waitTime is { } wait) + { + var elapsed = DateTime.UtcNow - info.LastAttemptUtc; + if (elapsed < wait) + { + return (false, wait - elapsed, false); + } + } + + return (true, null, false); + } + } + + public static string GetClientIp(HttpRequest request) + { + if (request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor)) + { + var first = forwardedFor.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(first)) + { + var commaIndex = first.IndexOf(','); + return commaIndex > 0 ? first[..commaIndex].Trim() : first.Trim(); + } + } + + if (request.Headers.TryGetValue("X-Real-IP", out var realIp)) + { + var first = realIp.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(first)) + { + return first.Trim(); + } + } + + return request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + } + + private static TimeSpan? CalculateWaitTime(int attemptCount) => + attemptCount switch + { + < 3 => null, + < 5 => TimeSpan.FromSeconds(5), + < 10 => TimeSpan.FromSeconds(5 + (attemptCount - 5) * 5), + < 20 => TimeSpan.FromMinutes(attemptCount - 9), + _ => null + }; + + private sealed class AuthAttemptInfo + { + public int Count; + public DateTime LastAttemptUtc; + public DateTime? BlockedUntilUtc; + } +} diff --git a/src/ReverseLlama.Server/Program.cs b/src/ReverseLlama.Server/Program.cs index 6b0289d..1f3d061 100644 --- a/src/ReverseLlama.Server/Program.cs +++ b/src/ReverseLlama.Server/Program.cs @@ -15,6 +15,7 @@ builder.Services.AddSingleton(settings); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddElmah().Configure( options => options.ConnectionString = builder.Configuration.GetConnectionString("ElmahConnection")); @@ -101,6 +102,67 @@ app.Use(async (context, next) => await next(); }); +var rateLimiter = app.Services.GetRequiredService(); + +app.Use(async (context, next) => +{ + if (HttpMethods.IsOptions(context.Request.Method)) + { + await next(); + return; + } + + var ip = AuthRateLimiter.GetClientIp(context.Request); + var (allowed, retryAfter, _) = rateLimiter.CheckRateLimit(ip); + + if (!allowed) + { + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + context.Response.Headers.RetryAfter = ((int)retryAfter!.Value.TotalSeconds).ToString(); + context.Response.ContentType = "application/json"; + var seconds = (int)retryAfter!.Value.TotalSeconds; + string retryMessage; + if (seconds >= 3600) + { + var hours = seconds / 3600; + retryMessage = $"Please try again in {hours} hour{(hours == 1 ? "" : "s")}."; + } + else if (seconds >= 60) + { + var minutes = seconds / 60; + retryMessage = $"Please try again in {minutes} minute{(minutes == 1 ? "" : "s")}."; + } + else + { + retryMessage = $"Please try again in {seconds} second{(seconds == 1 ? "" : "s")}."; + } + await context.Response.WriteAsJsonAsync(new { error = $"Too many requests. {retryMessage}" }, context.RequestAborted); + return; + } + + context.Response.OnStarting(() => + { + if (context.Response.StatusCode is StatusCodes.Status401Unauthorized) + { + rateLimiter.RecordFailure(ip, context.Request.Path); + } + else if (context.Response.StatusCode is StatusCodes.Status302Found + && context.Request.Path.StartsWithSegments("/api/admin")) + { + var location = context.Response.Headers.Location.FirstOrDefault(); + if (location is not null + && location.Contains("/admin/login", StringComparison.OrdinalIgnoreCase)) + { + rateLimiter.RecordFailure(ip, context.Request.Path); + } + } + + return Task.CompletedTask; + }); + + await next(); +}); + app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) From ff78aaa6b40e212e3cde3f89fb48aec223f5a9ed Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Sat, 18 Jul 2026 17:26:33 +0200 Subject: [PATCH 3/9] fix(server): fixes auth errors not logging to elmah --- src/ReverseLlama.Server/AuthRateLimiter.cs | 41 +++++++++++++++++++++- src/ReverseLlama.Server/Program.cs | 10 ++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/ReverseLlama.Server/AuthRateLimiter.cs b/src/ReverseLlama.Server/AuthRateLimiter.cs index 54a6401..3c1e6ca 100644 --- a/src/ReverseLlama.Server/AuthRateLimiter.cs +++ b/src/ReverseLlama.Server/AuthRateLimiter.cs @@ -1,15 +1,20 @@ using System.Collections.Concurrent; +using ElmahCore; namespace ReverseLlama.Server; internal sealed class AuthRateLimiter { + private const int DecayIntervalMinutes = 144; // ~1 step per 2.4 hours + private readonly ConcurrentDictionary _attempts = new(StringComparer.OrdinalIgnoreCase); private readonly ILogger _logger; + private readonly ErrorLog _errorLog; - public AuthRateLimiter(ILogger logger) + public AuthRateLimiter(ILogger logger, ErrorLog errorLog) { _logger = logger; + _errorLog = errorLog; } public void RecordFailure(string ipAddress, string endpoint) @@ -34,6 +39,27 @@ internal sealed class AuthRateLimiter "Failed auth attempt #{Count} from {IpAddress} on {Endpoint}", info.Count, ipAddress, endpoint); } + + _errorLog.Log(new Error(new AuthFailureException(ipAddress, endpoint, info.Count))); + } + } + + public void RecordSuccess(string ipAddress) + { + if (!_attempts.TryGetValue(ipAddress, out var info)) + return; + + lock (info) + { + if (info.Count > 0) + { + var before = info.Count; + info.Count /= 2; + info.LastAttemptUtc = DateTime.UtcNow; + _logger.LogInformation( + "Auth success from {IpAddress}: count reduced from {Before} to {After}", + ipAddress, before, info.Count); + } } } @@ -59,6 +85,16 @@ internal sealed class AuthRateLimiter return (true, null, false); } + if (info.Count > 0) + { + var elapsed = DateTime.UtcNow - info.LastAttemptUtc; + var decayTicks = (int)(elapsed.TotalMinutes / DecayIntervalMinutes); + if (decayTicks > 0) + { + info.Count = Math.Max(0, info.Count - decayTicks); + } + } + var waitTime = CalculateWaitTime(info.Count); if (waitTime is { } wait) { @@ -113,4 +149,7 @@ internal sealed class AuthRateLimiter public DateTime LastAttemptUtc; public DateTime? BlockedUntilUtc; } + + private sealed class AuthFailureException(string ipAddress, string endpoint, int attemptCount) + : Exception($"Failed auth attempt #{attemptCount} from {ipAddress} on {endpoint}"); } diff --git a/src/ReverseLlama.Server/Program.cs b/src/ReverseLlama.Server/Program.cs index 1f3d061..0c552ca 100644 --- a/src/ReverseLlama.Server/Program.cs +++ b/src/ReverseLlama.Server/Program.cs @@ -155,6 +155,16 @@ app.Use(async (context, next) => { rateLimiter.RecordFailure(ip, context.Request.Path); } + else if (location is not null + && location.StartsWith("/admin", StringComparison.OrdinalIgnoreCase)) + { + rateLimiter.RecordSuccess(ip); + } + } + else if (context.Response.StatusCode is >= 200 and < 300 + && context.Request.Path.StartsWithSegments("/api/admin")) + { + rateLimiter.RecordSuccess(ip); } return Task.CompletedTask; From 02e4462520c3e146e66688f9dbeedce23adb4dc2 Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Sat, 18 Jul 2026 18:08:00 +0200 Subject: [PATCH 4/9] fix(server): fixes status path returns database path and error message to clients --- src/ReverseLlama.Server/Program.cs | 8 ++------ src/ReverseLlama.Server/ReverseProxyEndpoint.cs | 9 ++++++++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/ReverseLlama.Server/Program.cs b/src/ReverseLlama.Server/Program.cs index 0c552ca..08d806f 100644 --- a/src/ReverseLlama.Server/Program.cs +++ b/src/ReverseLlama.Server/Program.cs @@ -198,15 +198,11 @@ app.MapGet(settings.StatusPath, (HttpContext context, TunnelHub hub, ServerSetti embeddingCache = new { available = embeddingCache.IsAvailable, - count = embeddingCache.Count, - databasePath = embeddingCache.DatabasePath, - lastError = embeddingCache.LastError + count = embeddingCache.Count }, management = new { - available = managementStore.IsAvailable, - databasePath = managementStore.DatabasePath, - lastError = managementStore.LastError + available = managementStore.IsAvailable }, clients = hub.ClientsSnapshot }); diff --git a/src/ReverseLlama.Server/ReverseProxyEndpoint.cs b/src/ReverseLlama.Server/ReverseProxyEndpoint.cs index 8f2c7c1..1a44f53 100644 --- a/src/ReverseLlama.Server/ReverseProxyEndpoint.cs +++ b/src/ReverseLlama.Server/ReverseProxyEndpoint.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using ElmahCore; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Primitives; using ReverseLlama.Protocol; @@ -569,10 +570,16 @@ internal static class ReverseProxyEndpoint { logger.LogWarning(exception, "Proxy request {RequestId} failed.", requestId); + var errorLog = context.RequestServices.GetService(); + if (errorLog is not null) + { + await errorLog.LogAsync(new Error(exception, context)); + } + if (!context.Response.HasStarted) { context.Response.StatusCode = StatusCodes.Status502BadGateway; - await context.Response.WriteAsync(exception.Message, CancellationToken.None); + await context.Response.WriteAsync("Bad gateway", CancellationToken.None); } else { From 536771d2bc98fae98c06ca30b629cb030c584421 Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Sat, 18 Jul 2026 18:14:37 +0200 Subject: [PATCH 5/9] fix(auth): fixes possible timing attack in the token validation --- src/ReverseLlama.Server/TokenAuthentication.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ReverseLlama.Server/TokenAuthentication.cs b/src/ReverseLlama.Server/TokenAuthentication.cs index ac4199d..2e8c502 100644 --- a/src/ReverseLlama.Server/TokenAuthentication.cs +++ b/src/ReverseLlama.Server/TokenAuthentication.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using ReverseLlama.Protocol; namespace ReverseLlama.Server; @@ -115,7 +117,9 @@ internal static class TokenAuthentication } if (!string.IsNullOrWhiteSpace(settings.Token) - && string.Equals(token, settings.Token, StringComparison.Ordinal)) + && CryptographicOperations.FixedTimeEquals( + SHA256.HashData(Encoding.UTF8.GetBytes(token)), + SHA256.HashData(Encoding.UTF8.GetBytes(settings.Token)))) { return AuthResult.Success(null); } From 7675c9ad8e1236eb0320d8e643b0501e748332be Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Sat, 18 Jul 2026 18:25:14 +0200 Subject: [PATCH 6/9] docs: add documentation regarding the safety of query-string token usage --- README.md | 7 ++++++- src/ReverseLlama.Server/TokenAuthentication.cs | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5f1ccc7..f953954 100644 --- a/README.md +++ b/README.md @@ -136,4 +136,9 @@ The script ensures .NET 10 and Ollama are installed, builds the client self-cont ## Notes - Request and response bodies are streamed through the tunnel, which is important for Ollama streaming responses. -- Use HTTPS or a private network/VPN when exposing this outside a trusted network. The token is simple shared-secret protection, not a full access-control system. + +## Security notes + +- Use HTTPS or a private network/VPN when exposing this outside a trusted network. +- **Tokens in URLs** (`/token//...` and `?token=...`) are logged by web servers (Apache, Nginx, Kestrel), reverse proxies, and browsers (history). Malicious MITM proxies can also read them. Prefer header-based auth (`X-Reverse-Llama-Token` or `Authorization: Bearer`) when your client supports it. +- The token is simple shared-secret protection, not a full access-control system. diff --git a/src/ReverseLlama.Server/TokenAuthentication.cs b/src/ReverseLlama.Server/TokenAuthentication.cs index 2e8c502..f05e527 100644 --- a/src/ReverseLlama.Server/TokenAuthentication.cs +++ b/src/ReverseLlama.Server/TokenAuthentication.cs @@ -47,6 +47,9 @@ internal static class TokenAuthentication } } + // Path-token auth: useful for clients that cannot send headers. + // SECURITY: the token appears in the URL and will be logged by + // web servers, proxies, and browsers. Prefer header auth when possible. if (allowPathToken && TryGetPathToken(request.Path, out var pathToken, out _)) { @@ -57,6 +60,9 @@ internal static class TokenAuthentication } } + // Query-string auth: needed for clients that cannot send headers + // (e.g. browser address bar, status page). + // SECURITY: same URL-logging risks as path-token auth above. if (allowQueryToken && request.Query.TryGetValue("token", out var queryValues)) { From 31a86ed23418814e8055abffb0a3b666f1e834a7 Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Sat, 18 Jul 2026 18:58:11 +0200 Subject: [PATCH 7/9] fix(client installer): fixes shell injection --- deploy/install-client.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/deploy/install-client.sh b/deploy/install-client.sh index ef1ceae..e3018ba 100755 --- a/deploy/install-client.sh +++ b/deploy/install-client.sh @@ -262,6 +262,13 @@ chmod +x "$INSTALL_DIR/ReverseLlama.Client" info "Client installed to $INSTALL_DIR." +# ── Write environment file (avoids shell injection in unit file) ───────────── +ENV_DIR="/etc/reversellama-client" +mkdir -p "$ENV_DIR" +printf 'REVERSE_LLAMA_TOKEN=%s\n' "$TOKEN" > "$ENV_DIR/env" +chmod 600 "$ENV_DIR/env" +info "Environment file written to $ENV_DIR/env (mode 0600)." + # ── Create systemd service ─────────────────────────────────────────────────── SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" @@ -280,11 +287,12 @@ $([ "$SKIP_OLLAMA" = "false" ] && echo "Wants=ollama.service") [Service] Type=simple -ExecStart=$INSTALL_DIR/ReverseLlama.Client --server "$SERVER_URL" --upstream "$UPSTREAM" --token "$TOKEN" --client-id "$CLIENT_ID" +ExecStart=$INSTALL_DIR/ReverseLlama.Client --server "$SERVER_URL" --upstream "$UPSTREAM" --client-id "$CLIENT_ID" Restart=always RestartSec=5 Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1 Environment=DOTNET_NOLOGO=1 +EnvironmentFile=$ENV_DIR/env WorkingDirectory=$INSTALL_DIR [Install] From 6240030dd57e5743ac6f0593085333c1e4f2515f Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Sat, 18 Jul 2026 19:39:53 +0200 Subject: [PATCH 8/9] fix(server): adds XFrameOptions and ContentSecurityPolicy headers --- src/ReverseLlama.Server/Program.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ReverseLlama.Server/Program.cs b/src/ReverseLlama.Server/Program.cs index 08d806f..98dbd6f 100644 --- a/src/ReverseLlama.Server/Program.cs +++ b/src/ReverseLlama.Server/Program.cs @@ -92,6 +92,8 @@ app.Use(async (context, next) => context.Response.Headers.AccessControlAllowOrigin = "*"; context.Response.Headers.AccessControlAllowMethods = "GET, POST, PUT, DELETE, PATCH, OPTIONS"; context.Response.Headers.AccessControlAllowHeaders = "Content-Type, Authorization"; + context.Response.Headers.XFrameOptions = "DENY"; + context.Response.Headers.ContentSecurityPolicy = "frame-ancestors 'none'"; if (HttpMethods.IsOptions(context.Request.Method)) { From 7217f299090844d3bd59829ca45f3d195cedac2b Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Sat, 18 Jul 2026 19:42:39 +0200 Subject: [PATCH 9/9] fix(server): fixes lax cookie SecurePolicy issue --- src/ReverseLlama.Server/Program.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ReverseLlama.Server/Program.cs b/src/ReverseLlama.Server/Program.cs index 98dbd6f..7839870 100644 --- a/src/ReverseLlama.Server/Program.cs +++ b/src/ReverseLlama.Server/Program.cs @@ -31,7 +31,7 @@ if (settings.Keycloak.IsConfigured) { options.Cookie.Name = "ReverseLlama.Admin"; options.Cookie.SameSite = SameSiteMode.Lax; - options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.LoginPath = "/admin/login"; options.LogoutPath = "/admin/logout"; }) @@ -46,9 +46,9 @@ if (settings.Keycloak.IsConfigured) options.SaveTokens = true; options.GetClaimsFromUserInfoEndpoint = true; options.CorrelationCookie.SameSite = SameSiteMode.Lax; - options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always; options.NonceCookie.SameSite = SameSiteMode.Lax; - options.NonceCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.NonceCookie.SecurePolicy = CookieSecurePolicy.Always; options.Scope.Clear(); options.Scope.Add("openid"); options.Scope.Add("profile");