Harden auth, headers, and container per OWASP review

- Add per-account lockout + IP rate limiter on local sign-in (A07)
- Emit CSP and security headers on every response (A05)
- Run container as non-root `app`, /data 0700 (A05/A02)
- Stop reflecting raw token-endpoint body into redirect URL (A09)
- Handle missing refresh_token in connect callback without a 500

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 14:30:19 +02:00
parent 0adc2d4300
commit c4a1775d7d
6 changed files with 176 additions and 9 deletions
+22 -6
View File
@@ -72,12 +72,18 @@ public static class OAuthEndpoints
string? error_description,
IOAuthFlowCache flowCache,
IOptions<ClientConnectOptions> opts,
IHttpClientFactory httpClientFactory) =>
IHttpClientFactory httpClientFactory,
ILoggerFactory loggerFactory) =>
{
var log = loggerFactory.CreateLogger("SharepointToolbox.Web.OAuth.Connect");
if (!string.IsNullOrEmpty(error))
{
var errMsg = Uri.EscapeDataString(error_description ?? error);
return Results.Redirect($"/?connect_error={errMsg}");
// The provider's verbose error_description can carry correlation/trace ids and
// lands in the URL bar + proxy access logs. Log it server-side; surface only the
// short, safe OAuth error code (e.g. "access_denied") to the browser.
log.LogWarning("Connect callback returned error {Error}: {Description}", error, error_description);
return Results.Redirect($"/?connect_error={Uri.EscapeDataString(error)}");
}
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
@@ -107,14 +113,24 @@ public static class OAuthEndpoints
if (!resp.IsSuccessStatusCode)
{
var msg = Uri.EscapeDataString($"Token exchange failed: {json}");
return Results.Redirect($"/?connect_error={msg}");
// The raw token-endpoint body can contain trace ids / claim hints — keep it out of
// the URL and the proxy logs. Record it server-side, redirect with a generic notice.
log.LogWarning("Token exchange failed ({Status}): {Body}", resp.StatusCode, json);
return Results.Redirect($"/?connect_error={Uri.EscapeDataString("Token exchange failed. Please try connecting again.")}");
}
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var upn = ExtractUpnFromIdToken(root);
var refreshToken = root.GetProperty("refresh_token").GetString()!;
// offline_access should yield a refresh_token; if the tenant/app withheld it the
// session can't be persisted. Fail cleanly instead of throwing a 500 + stack trace.
if (!root.TryGetProperty("refresh_token", out var refreshTokenEl) ||
refreshTokenEl.GetString() is not { Length: > 0 } refreshToken)
{
log.LogWarning("Token response had no refresh_token for tenant {Tenant}.", flowState.TenantId);
return Results.Redirect($"/?connect_error={Uri.EscapeDataString("Sign-in did not return a refresh token. Please try again.")}");
}
var tokens = new SessionTokens
{