Compare commits

...

22 Commits

Author SHA1 Message Date
kawa 9480e9537a Bump dependencies to latest and document them
Update 13 NuGet packages, including major bumps: Microsoft.Graph
5.74.0 -> 6.2.0, Microsoft.Kiota.* 1.22.2 -> 2.0.0, and
Serilog.AspNetCore 9.0.0 -> 10.0.0. The set is internally consistent
(Graph 6.2.0 -> Graph.Core 4.0.1 -> Kiota 2.0.0) and builds clean with
no code changes. Add a Dependencies table to the README listing each
package, its pinned version, and purpose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:44:48 +02:00
kawa 181c82d310 Document certificate (app-only) auth as primary per-profile path
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:38:24 +02:00
kawa c4a1775d7d 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>
2026-06-11 14:30:19 +02:00
kawa 0adc2d4300 Hide write-only features from TechN0 menu
Read-only TechN0 users could see nav items for pages that immediately
return a WriteGuard notice (transfer, versions, templates, bulk members/
sites, folder structure), landing them on empty screens. Add a `write`
nav scope (HasProfile && Role >= TechN1) so those items no longer appear
for N0. The Bulk and Config section headers drop out automatically since
all their children are now write-scoped. Per-page guards remain intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:48:36 +02:00
kawa 17f6010a93 Fix open-redirect token leak and related auth hardening
Security review fixes:
- Constrain OAuth connect returnUrl to a site-relative path so the
  redeemable token_key can't be redirected off-domain (was a refresh-
  token leak / connection hijack)
- Route all login redirects (entra/dev/local) through ToLocalReturnUrl,
  also closing a protocol-relative // open redirect in local-login
- Neutralize CSV formula prefixes in both audit-log exporters via
  CsvSanitizer
- Force Secure flag on the prod auth cookie (Always, not SameAsRequest)
- Gate admin pages with an app_role-claim "Admin" policy instead of a
  render-time check

Findings and rationale recorded in SECURITY-TODO.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:39:20 +02:00
kawa fe33960c0e Let standard techs use profiles without sign-in; flag unshared ones
Standard technicians (TechN0/TechN1) are no longer auto-prompted for a
delegated SharePoint sign-in when selecting a profile — only admins are.
Techs operate under the profile's app (certificate) identity, so a profile
selection never forces them to authenticate.

To keep that usable, the admin profile list now shows a "No shared access"
badge on any profile that isn't certificate-configured, since standard
techs can't operate against those until an admin registers a cert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:27:46 +02:00
kawa 84b77d99f6 Fixed : Certificates arent stored properly app-side when creating new profiles 2026-06-11 11:12:31 +02:00
kawa 08dd27d91d Trim "no secrets stored on disk" from profiles subtitle
Drop the trailing claim from the profiles.subtitle string (FR + EN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:59:49 +02:00
kawa 38ffe714a2 Restore clean role-change success message
Drop the temporary "saved: …" diagnostic wording now that the production
interactivity bug is fixed. Keeps the robust @onchange handler and the
previous-role return value used in the audit entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:56:47 +02:00
kawa def8647de1 Drop --no-restore: it dropped Blazor framework assets
Root cause of the production "nothing is interactive" bug. The Dockerfile
restored with only the .csproj present (the layer-cache step) and then ran
`dotnet publish --no-restore`. That combination silently omits the Blazor
framework static assets (wwwroot/_framework/blazor.web.js) from the publish
output, so MapStaticAssets 404s the boot script and no interactive circuit
starts on any page — buttons, dropdowns (role changes) all dead.

Letting publish restore against the full project re-materializes the assets.
Reproduced locally and verified the fix. The SDK pin (10.0.203) was a red
herring and is left as-is for reproducibility.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:51:25 +02:00
kawa f6a36f3bd9 Use SDK 10.0.203 (10.0.204 has no MCR image)
The previous pin (sdk:10.0.204) doesn't exist on MCR — only the installer
SDK uses that patch. MCR publishes band-2 images up to 10.0.203. Band 2
publishes blazor.web.js correctly (verified locally on 10.0.204), so pin
to the newest available 2xx image, 10.0.203.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:37:04 +02:00
kawa c41abc0ea5 Pin build SDK to 10.0.204 to restore blazor.web.js
SDK 10.0.300 published a static-assets endpoints manifest without
blazor.web.js, so MapStaticAssets returned 404 for the Blazor boot script
in production. With no boot script the interactive circuit never starts
and every page renders static — buttons and dropdowns (e.g. user role
changes) do nothing. SDK 10.0.204 is verified to publish blazor.web.js
(physical file + manifest entry) against the 10.0.8 runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:34:06 +02:00
kawa fe0fcdb7da Make role-change report saved value on-screen
@bind:after did not persist reliably. Move back to an explicit @onchange
handler and surface every outcome in the page alert, including the role
re-read from the store after the write. This makes a failed save visible
(unrecognized value, exception, or saved != selected) instead of silent,
so we can pinpoint where the role update breaks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:17:27 +02:00
kawa cdc93d041a Fix role change silently failing via @bind
The role <select> used a manual value=/@onchange pattern that parsed
e.Value and returned silently when the parse failed, so changing a role
did nothing and showed no message. Switch to @bind + @bind:after so the
framework handles the enum conversion, and log/verify the persisted role
in UpdateRoleAsync (now returns the previous role) for diagnosis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:10:00 +02:00
kawa 98683bbd5e Re-read user role from store on navigation
Role lived in the scoped UserContextAccessor for the circuit's lifetime
and was never refreshed, so an admin promoting a user (e.g. N0 to N1) did
not reach the affected user's live session. AppInitializer now re-reads
the user on each LocationChanged, applying role changes on next navigation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:50:26 +02:00
kawa e190e40b07 Force request host/scheme to App__Domain behind a proxy
The cookie login redirect and other absolute URLs are built from Request.Host;
behind a proxy that doesn't forward the Host header that's the internal IP:port,
so hitting the domain 302'd to the server IP. Rewrite scheme+host to App__Domain
on every request (after UseForwardedHeaders) so all generated URLs stay on the
public domain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:54:30 +02:00
kawa 5f51e9d16d Pin OIDC redirect to App__Domain when set
Override the OIDC redirect_uri (and post-logout redirect) to <domain>/signin-oidc
instead of deriving it from the request host. Set in both the authorize request
and the code->token redemption so Entra sees a matching redirect_uri. Falls back
to request-host derivation when App__Domain is unset. Domain binding hoisted so
OIDC and ClientConnect share one AppDomainOptions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:47:04 +02:00
kawa 582cc54189 Add App__Domain config to derive connect redirect URI
Let deployments set a single App__Domain (e.g. sptb.example.com) instead of
spelling out the full ClientConnect__RedirectUri. The SharePoint-connect
callback is derived as <domain>/connect/callback; an explicit RedirectUri
still wins for back-compat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:42:05 +02:00
kawa 0ded1af6bc Merge pull request 'Add prebuilt docker-compose, .env.example, and prebuilt install docs' (#4) from fix/prod-auth-http-deploy into main
Reviewed-on: #4
2026-06-10 15:38:44 +02:00
kawa ad7d20021d Add prebuilt docker-compose, .env.example, and prebuilt install docs 2026-06-10 15:33:07 +02:00
kawa 415ec7152f Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web 2026-06-10 14:24:32 +02:00
kawa 5333a3888e Build and push script 2026-06-10 14:24:20 +02:00
24 changed files with 650 additions and 87 deletions
+21
View File
@@ -0,0 +1,21 @@
# Copy to `.env` beside docker-compose.prebuilt.yml and fill in real values.
# IMPORTANT: do NOT wrap values in quotes — the compose `environment:` list form
# embeds the literal quotes, producing a malformed Authority that fails OIDC
# metadata discovery (IDX20803).
# Image tag to run (default: latest)
SPTB_TAG=latest
# Public domain the app is reached at (e.g. sptb.example.com or https://sptb.example.com).
# Scheme defaults to https when omitted. The SharePoint-connect redirect URI is derived
# from this as <domain>/connect/callback — register that on each client profile's app.
# App__Domain=sptb.example.com
# OIDC app sign-in (required in Production). Authority is derived from TenantId.
Oidc__TenantId=00000000-0000-0000-0000-000000000000
Oidc__ClientId=00000000-0000-0000-0000-000000000000
Oidc__ClientSecret=your-client-secret
# Optional: seed the first admin while the user store is empty (local form login).
# Bootstrap__AdminEmail=admin@example.com
# Bootstrap__AdminPassword=change-me
+4
View File
@@ -65,3 +65,7 @@ data/exports/
data/templates/
data/audit.jsonl
data/appcerts/
# Local secrets
.env
!.env.example
+21 -10
View File
@@ -146,15 +146,15 @@
new("/permissions", "🔐", "tab.permissions", "", "profile"),
new("/storage", "💾", "tab.storage", "", "profile"),
new("/duplicates", "📋", "tab.duplicates", "", "profile"),
new("/versions", "🗂️", "versions.tab", "", "profile"),
new("/transfer", "📦", "nav.fileTransfer", "", "profile"),
new("/bulk-members", "👥", "tab.bulkMembers", "nav.section.bulk", "profile"),
new("/bulk-sites", "🌐", "tab.bulkSites", "nav.section.bulk", "profile"),
new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "profile"),
new("/versions", "🗂️", "versions.tab", "", "write"),
new("/transfer", "📦", "nav.fileTransfer", "", "write"),
new("/bulk-members", "👥", "tab.bulkMembers", "nav.section.bulk", "write"),
new("/bulk-sites", "🌐", "tab.bulkSites", "nav.section.bulk", "write"),
new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "write"),
new("/user-audit", "👤", "tab.userAccessAudit", "nav.section.audit", "profile"),
new("/user-directory", "📖", "nav.userDirectory", "nav.section.audit", "profile"),
new("/reports", "📑", "nav.reports", "nav.section.audit", "profile"),
new("/templates", "📐", "tab.templates", "nav.section.config", "profile"),
new("/templates", "📐", "tab.templates", "nav.section.config", "write"),
new("/scheduled-reports", "⏰", "nav.scheduledReports", "nav.section.admin", "admin"),
new("/profiles", "⚙️", "nav.clientProfiles", "nav.section.admin", "admin"),
new("/admin/users", "👥", "nav.userManagement", "nav.section.admin", "admin"),
@@ -170,6 +170,7 @@
.Where(i => i.Scope switch
{
"profile" => Session.HasProfile,
"write" => Session.HasProfile && UserContext.Role >= UserRole.TechN1,
"admin" => UserContext.Role == UserRole.Admin,
"auth" => UserContext.IsAuthenticated,
_ => true
@@ -226,8 +227,8 @@
}
// If profile selected but no credentials → show modal (cert profiles never prompt)
if (Session.HasProfile && !_hasCredentials && !CurrentProfileUsesCert && _credModal is not null)
await _credModal.ShowAsync();
if (ShouldPromptForCredentials)
await _credModal!.ShowAsync();
}
// True when the selected profile authenticates app-only via a stored certificate —
@@ -235,6 +236,15 @@
private bool CurrentProfileUsesCert =>
Session.CurrentProfile is { } p && AppOnly.IsConfigured(p);
// Whether to auto-show the delegated sign-in modal. Only admins are ever asked to
// authenticate: standard technicians (TechN0/TechN1) operate under the profile's app
// (certificate) identity and must never be prompted when selecting a profile. A profile
// that isn't cert-configured is an admin setup concern, not a sign-in for the technician.
private bool ShouldPromptForCredentials =>
Session.HasProfile && !_hasCredentials && !CurrentProfileUsesCert
&& UserContext.Role == UserRole.Admin
&& _credModal is not null;
private async Task HandleOAuthCallbackAsync()
{
var uri = new Uri(Nav.Uri);
@@ -320,8 +330,9 @@
// operating on the old connection.
await RefreshCredentialState();
// New profile selected and no valid credentials for it → prompt to connect.
if (Session.HasProfile && !_hasCredentials && _credModal is not null)
await _credModal.ShowAsync();
// Standard technicians are never prompted (see ShouldPromptForCredentials).
if (ShouldPromptForCredentials)
await _credModal!.ShowAsync();
});
}
+1 -1
View File
@@ -1,5 +1,5 @@
@page "/admin/audit"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "Admin")]
@inject IAuditService AuditService
@inject IUserContextAccessor UserContext
@inject NavigationManager Nav
+3 -4
View File
@@ -1,5 +1,5 @@
@page "/admin/users"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "Admin")]
@inject IUserService UserService
@inject IUserContextAccessor UserContext
@inject IAuditService Audit
@@ -87,7 +87,7 @@ else
<td style="padding:8px">
<select class="form-input" style="width:130px"
value="@user.Role"
@onchange="e => OnRoleChange(user, e)"
@onchange="@(e => OnRoleChange(user, e))"
disabled="@(user.Email == UserContext.Email)">
@foreach (var role in Enum.GetValues<UserRole>())
{
@@ -198,8 +198,7 @@ else
if (!Enum.TryParse<UserRole>(e.Value?.ToString(), out var newRole)) return;
try
{
var oldRole = user.Role;
await UserService.UpdateRoleAsync(user.Id, newRole);
var oldRole = await UserService.UpdateRoleAsync(user.Id, newRole);
user.Role = newRole;
await Audit.LogAsync("RoleChanged", "", Array.Empty<string>(),
$"Changed role for {user.Email} ({user.DisplayName}) from {oldRole} to {newRole}.");
+11 -1
View File
@@ -79,6 +79,13 @@
{
<span class="chip chip-green">@T["profiles.active"]</span>
}
@if (!AppOnlyFactory.IsConfigured(p))
{
<span class="chip chip-yellow"
title="No certificate configured — standard technicians can't use this profile. Open it and run 'Register app' (or upload a certificate) to enable shared access.">
⚠ No shared access
</span>
}
<button class="btn btn-secondary btn-sm" @onclick="() => SelectProfile(p)">
@(Session.CurrentProfile?.Id == p.Id ? T["profiles.selected"] : T["profiles.select"])
</button>
@@ -393,7 +400,10 @@
if (_editing == null)
{
_form.Id = Guid.NewGuid().ToString();
// Keep the Id assigned by the TenantProfile constructor. "Register app" may have
// already provisioned the cert and stored it on disk under this Id; reassigning a
// fresh Guid here would orphan that cert file and make the UI re-prompt for upload.
if (string.IsNullOrEmpty(_form.Id)) _form.Id = Guid.NewGuid().ToString();
_profiles.Add(_form);
}
else
+39 -5
View File
@@ -1,14 +1,20 @@
@inject AuthenticationStateProvider AuthProvider
@inject IUserService UserService
@inject IUserContextAccessor UserContext
@inject NavigationManager Nav
@inject ILogger<AppInitializer> Logger
@implements IDisposable
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Routing
@using SharepointToolbox.Web.Services.Auth
@using SharepointToolbox.Web.Services.Session
@* Invisible component. Run once per circuit to seed IUserContextAccessor. *@
@* Invisible component. Seeds IUserContextAccessor on circuit init and re-reads it from the
store on every navigation, so an admin's role change applies on the user's next page change. *@
@code {
private string? _email;
protected override async Task OnInitializedAsync()
{
var state = await AuthProvider.GetAuthenticationStateAsync();
@@ -20,23 +26,51 @@
return;
}
var email = principal.FindFirst("preferred_username")?.Value
_email = principal.FindFirst("preferred_username")?.Value
?? principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value;
if (string.IsNullOrEmpty(email))
if (string.IsNullOrEmpty(_email))
{
var claims = string.Join(", ", principal.Claims.Select(c => $"{c.Type}={c.Value}"));
Logger.LogWarning("AppInitializer: authenticated but no preferred_username/email claim. Claims present: [{Claims}]", claims);
return;
}
var user = await UserService.GetByEmailAsync(email);
await SeedAsync(logSeed: true);
// Re-read the user (and current role) from the store on each navigation. The role lives
// in the scoped UserContextAccessor for the circuit's lifetime, so without this a role
// change made by an admin would not reach the affected user's live session.
Nav.LocationChanged += OnLocationChanged;
}
private async Task SeedAsync(bool logSeed)
{
if (string.IsNullOrEmpty(_email)) return;
var user = await UserService.GetByEmailAsync(_email);
if (user is null)
{
Logger.LogWarning("AppInitializer: no user row for email '{Email}' — provisioning did not persist a matching record.", email);
Logger.LogWarning("AppInitializer: no user row for email '{Email}' — provisioning did not persist a matching record.", _email);
return;
}
if (logSeed)
Logger.LogInformation("AppInitializer: seeded UserContext for '{Email}' (role {Role}).", user.Email, user.Role);
UserContext.Initialize(user);
}
private async void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
try
{
await SeedAsync(logSeed: false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "AppInitializer: failed to refresh UserContext on navigation to '{Location}'.", e.Location);
}
}
public void Dispose() => Nav.LocationChanged -= OnLocationChanged;
}
+36
View File
@@ -0,0 +1,36 @@
namespace SharepointToolbox.Web.Core.Config;
/// <summary>
/// The app's public domain (e.g. <c>sptb.example.com</c> or <c>https://sptb.example.com</c>),
/// configured via <c>App__Domain</c>. Used to derive the SharePoint-connect redirect URI when
/// <see cref="ClientConnectOptions.RedirectUri"/> isn't set explicitly.
/// </summary>
public class AppDomainOptions
{
public string Domain { get; set; } = string.Empty;
/// <summary>
/// Builds an absolute URL for <paramref name="path"/> rooted at the configured domain, or
/// <c>null</c> when no domain is set. Defaults to <c>https</c> when the domain has no scheme,
/// and tolerates accidental surrounding quotes / trailing slashes (docker-compose's list-form
/// env values can embed literal quotes).
/// </summary>
public string? BuildUrl(string path)
{
var domain = Domain?.Trim().Trim('"', '\'').TrimEnd('/');
if (string.IsNullOrEmpty(domain))
return null;
if (!domain.Contains("://", StringComparison.Ordinal))
domain = "https://" + domain;
return domain + "/" + path.TrimStart('/');
}
/// <summary>
/// The configured domain as an absolute base <see cref="Uri"/> (scheme + host [+ port]), or
/// <c>null</c> when no domain is set or it can't be parsed.
/// </summary>
public Uri? GetBaseUri() =>
BuildUrl("/") is { } url && Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri : null;
}
+14
View File
@@ -4,4 +4,18 @@ public static class StringExtensions
{
public static string? TrimOrNull(this string? s)
=> string.IsNullOrWhiteSpace(s) ? null : s.Trim();
/// <summary>
/// Returns <paramref name="returnUrl"/> only when it is a safe site-relative path,
/// otherwise "/". Rejects absolute URLs and protocol-relative paths ("//evil.com",
/// "/\evil.com") so a post-auth / post-connect redirect can never leave the app.
/// Used by every login and OAuth-connect redirect to prevent open redirects.
/// </summary>
public static string ToLocalReturnUrl(this string? returnUrl)
{
if (string.IsNullOrEmpty(returnUrl)) return "/";
if (returnUrl[0] != '/') return "/"; // not site-relative
if (returnUrl.Length > 1 && (returnUrl[1] == '/' || returnUrl[1] == '\\')) return "/"; // protocol-relative
return returnUrl;
}
}
+13
View File
@@ -15,4 +15,17 @@ public class AppUser
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? LastLogin { get; set; }
// ── Local-account brute-force lockout ───────────────────────────────────────
// Consecutive failed password attempts and, once the threshold is hit, the UTC
// instant the account unlocks again. Only meaningful for AuthProvider.Local.
// A per-account counter (not just an IP rate limiter) is the control that holds
// up here: forwarded headers are trusted from any source, so an attacker who can
// rotate X-Forwarded-For would evade IP-based throttling but not this.
/// <summary>Consecutive failed local-login attempts since the last success.</summary>
public int FailedLoginCount { get; set; }
/// <summary>UTC instant the account unlocks; null when not locked.</summary>
public DateTimeOffset? LockoutEndUtc { get; set; }
}
+19 -4
View File
@@ -1,6 +1,5 @@
# Base images pinned to exact patch for reproducible builds. Floating `:10.0` tags
# drift; a stale/pre-GA SDK base silently drops the Blazor framework static assets
# (blazor.web.js) from the publish manifest → 404 in production. Bump deliberately.
# drift between machines; bump deliberately. (SDK 10.0.203 + runtime 10.0.8.)
FROM mcr.microsoft.com/dotnet/aspnet:10.0.8 AS base
WORKDIR /app
EXPOSE 8080
@@ -9,17 +8,33 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
FROM mcr.microsoft.com/dotnet/sdk:10.0.300 AS build
FROM mcr.microsoft.com/dotnet/sdk:10.0.203 AS build
WORKDIR /src
COPY ["SharepointToolbox.Web.csproj", "."]
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore
# Do NOT add --no-restore here. The restore above runs with only the .csproj present
# (no source, no wwwroot); pairing that cached state with `publish --no-restore`
# silently drops the Blazor framework static assets (wwwroot/_framework/blazor.web.js)
# from the output → the boot script 404s and no interactive circuit starts on any page.
# Letting publish restore against the full project re-materializes them. (Reproduced;
# the early restore above is kept only to cache the NuGet layer.)
RUN dotnet publish -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
# Run as the non-root `app` user shipped in the aspnet image (UID 1654) instead of root.
# /data holds the crown jewels (Data Protection keys, app-only certs, the user store), so
# create it owned by `app` with 0700 before declaring the volume — Docker seeds a fresh
# named volume from the image path's ownership/mode, so the running user can write it and
# other host users can't read the keys/certs at rest.
RUN mkdir -p /data \
&& chown -R app:app /app /data \
&& chmod 700 /data
USER app
# Volume for persistent data (profiles, settings, templates, logs, exports)
VOLUME ["/data"]
+27 -7
View File
@@ -4,6 +4,7 @@ using System.Text.Json;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Options;
using SharepointToolbox.Web.Core.Config;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Persistence;
using SharepointToolbox.Web.Services.Auth;
@@ -51,7 +52,10 @@ public static class OAuthEndpoints
TenantId = profile.TenantId,
ClientId = profile.ClientId,
SpHost = ExtractHost(profile.TenantUrl),
ReturnUrl = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl,
// Constrain to a site-relative path: the callback appends the redeemable
// token_key to this URL, so an external returnUrl would leak the client's
// SharePoint refresh token off-domain.
ReturnUrl = returnUrl.ToLocalReturnUrl(),
IsRegistration = false,
});
@@ -68,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))
@@ -103,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
{
+1 -1
View File
@@ -1564,7 +1564,7 @@ Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et i
<value>Sélectionné</value>
</data>
<data name="profiles.subtitle" xml:space="preserve">
<value>Gérez les connexions aux tenants SharePoint. Les identifiants sont saisis par session — aucun secret n'est stocké sur le disque.</value>
<value>Gérez les connexions aux tenants SharePoint. Les identifiants sont saisis par session.</value>
</data>
<data name="profiles.tenantid.label" xml:space="preserve">
<value>ID de tenant :</value>
+1 -1
View File
@@ -1564,7 +1564,7 @@ This tab does the reverse: you select one or more users and it finds every objec
<value>Selected</value>
</data>
<data name="profiles.subtitle" xml:space="preserve">
<value>Manage SharePoint tenant connections. Credentials are entered per session — no secrets stored on disk.</value>
<value>Manage SharePoint tenant connections. Credentials are entered per session.</value>
</data>
<data name="profiles.tenantid.label" xml:space="preserve">
<value>Tenant ID:</value>
+137 -13
View File
@@ -6,10 +6,13 @@ using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Serilog;
using SharepointToolbox.Web.Core.Config;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Auth;
using SharepointToolbox.Web.Infrastructure.OAuth;
@@ -69,6 +72,12 @@ builder.Services.AddDataProtection()
// Localization string source — Scoped: one per circuit, with its own explicit culture.
builder.Services.AddScoped<SharepointToolbox.Web.Localization.TranslationSource>();
// ── Public domain ─────────────────────────────────────────────────────────────
// App__Domain (e.g. sptb.example.com) drives both OIDC sign-in (below) and the
// SharePoint-connect redirect URI. Bound once here so both consumers share it.
var appDomain = new AppDomainOptions();
builder.Configuration.GetSection("App").Bind(appDomain);
// ── Authentication ────────────────────────────────────────────────────────────
if (builder.Environment.IsDevelopment())
{
@@ -103,7 +112,11 @@ else
// Auth state lives entirely in the browser cookie (Data Protection encrypted)
options.SessionStore = null;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
// Always mark the auth cookie Secure in prod. The app sits behind a TLS-terminating
// proxy and forwarded headers are trusted from any source (proxy IP is unknown inside
// the container network), so SameAsRequest would let a spoofed X-Forwarded-Proto: http
// — or any direct plaintext hit — emit a non-Secure cookie. Always wins regardless.
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
})
@@ -133,6 +146,34 @@ else
options.MapInboundClaims = false;
options.TokenValidationParameters.NameClaimType = "preferred_username";
// When App__Domain is set, pin the OIDC redirect_uri (and post-logout redirect) to that
// public host instead of deriving it from the request scheme/host. Keeps sign-in working
// when the app can't see its real external host (no/incorrect forwarded Host header, or
// several hostnames reach the same instance). The value must match the /signin-oidc URI
// registered on the Oidc app. The authorize request and the code→token redemption MUST
// send the identical redirect_uri, so override it in both events.
var oidcRedirectUri = appDomain.BuildUrl(options.CallbackPath.Value ?? "/signin-oidc");
var postLogoutUri = appDomain.BuildUrl(options.SignedOutCallbackPath.Value ?? "/signout-callback-oidc");
if (oidcRedirectUri is not null)
{
options.Events.OnRedirectToIdentityProvider = ctx =>
{
ctx.ProtocolMessage.RedirectUri = oidcRedirectUri;
return Task.CompletedTask;
};
options.Events.OnAuthorizationCodeReceived = ctx =>
{
if (ctx.TokenEndpointRequest is not null)
ctx.TokenEndpointRequest.RedirectUri = oidcRedirectUri;
return Task.CompletedTask;
};
options.Events.OnRedirectToIdentityProviderForSignOut = ctx =>
{
ctx.ProtocolMessage.PostLogoutRedirectUri = postLogoutUri;
return Task.CompletedTask;
};
}
options.Events.OnTokenValidated = async ctx =>
{
var userService = ctx.HttpContext.RequestServices.GetRequiredService<IUserService>();
@@ -163,7 +204,32 @@ else
});
}
builder.Services.AddAuthorization();
// "Admin" policy checks the app_role claim value directly, rather than [Authorize(Roles=…)]
// — the local/dev sign-in identities don't set a ClaimTypes.Role claim, so a Roles check would
// silently deny local admins. Every identity (OIDC, local, dev) carries app_role.
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Admin", p => p.RequireClaim("app_role", nameof(UserRole.Admin)));
});
// ── Rate limiting ───────────────────────────────────────────────────────────────
// Volumetric defence on the sign-in endpoints, partitioned by client IP (RemoteIpAddress
// reflects X-Forwarded-For — UseForwardedHeaders runs first). This is the coarse layer;
// the per-account lockout in UserService is what holds up when XFF is spoofed/rotated,
// since forwarded headers are trusted from any source behind the proxy.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("login", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}));
});
// ── Memory cache (used by OAuth flow cache) ───────────────────────────────────
builder.Services.AddMemoryCache();
@@ -172,6 +238,19 @@ builder.Services.AddHttpClient("oauth");
// ── ClientConnect options ─────────────────────────────────────────────────────
builder.Services.Configure<ClientConnectOptions>(builder.Configuration.GetSection("ClientConnect"));
// Derive the SharePoint-connect redirect URI from the app's public domain (App__Domain)
// when ClientConnect__RedirectUri isn't set explicitly. Lets a deployment configure a
// single domain (e.g. sptb.example.com) instead of spelling out the full callback URL.
// An explicit RedirectUri still wins, so existing configs are unaffected.
builder.Services.PostConfigure<ClientConnectOptions>(opts =>
{
if (string.IsNullOrWhiteSpace(opts.RedirectUri) &&
appDomain.BuildUrl("/connect/callback") is { } callback)
{
opts.RedirectUri = callback;
}
});
// ── App config ────────────────────────────────────────────────────────────────
var certsFolder = Path.Combine(dataFolder, "appcerts");
builder.Services.Configure<AppConfiguration>(opt =>
@@ -262,6 +341,23 @@ var app = builder.Build();
// Must run before anything that inspects the request scheme/IP (auth, OIDC, cookies).
app.UseForwardedHeaders();
// When App__Domain is set, rewrite every request's scheme + host to the public domain. The
// framework builds absolute URLs (the cookie login redirect, the OIDC redirect_uri, …) from
// Request.Scheme/Host; behind a proxy that doesn't forward the Host header these are the
// internal host (server IP:port), so loading https://<domain>/ would 302 to http://<ip>:8080.
// Forcing the host here keeps every generated URL on the public domain. Must run before auth.
var publicBaseUri = appDomain.GetBaseUri();
if (publicBaseUri is not null)
{
var publicHost = HostString.FromUriComponent(publicBaseUri);
app.Use((context, next) =>
{
context.Request.Scheme = publicBaseUri.Scheme;
context.Request.Host = publicHost;
return next(context);
});
}
// ── First-run bootstrap ───────────────────────────────────────────────────────
// Seed a local admin when no users exist yet, so a plain-HTTP / LAN deployment that
// can't use Microsoft OIDC (which requires HTTPS + a matching Entra redirect URI) can
@@ -287,6 +383,33 @@ app.UseForwardedHeaders();
}
}
// ── Security response headers ───────────────────────────────────────────────────
// Defence-in-depth on every response. CSP is tuned to this app: all scripts are external
// (_framework/blazor.web.js, js/app.js) so script-src can stay 'self' with no unsafe-*;
// the login page and the Blazor components use inline <style>/style="" so style-src needs
// 'unsafe-inline'. img-src allows data: for the base64 client logos and CSV download links.
// connect-src 'self' covers the Blazor Server WebSocket. frame-ancestors blocks clickjacking
// of the live circuit (X-Frame-Options for legacy agents).
app.Use(async (context, next) =>
{
var headers = context.Response.Headers;
headers["Content-Security-Policy"] =
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; " +
"font-src 'self' data:; " +
"connect-src 'self'; " +
"base-uri 'self'; " +
"object-src 'none'; " +
"form-action 'self'; " +
"frame-ancestors 'none'";
headers["X-Frame-Options"] = "DENY";
headers["X-Content-Type-Options"] = "nosniff";
headers["Referrer-Policy"] = "no-referrer";
await next();
});
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
@@ -299,6 +422,7 @@ app.UseStatusCodePagesWithReExecute("/not-found");
app.MapStaticAssets();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.UseAntiforgery();
// ── Login / Logout endpoints ──────────────────────────────────────────────────
@@ -337,7 +461,7 @@ if (isDev)
CookieAuthenticationDefaults.AuthenticationScheme));
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
ctx.Response.Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
ctx.Response.Redirect(returnUrl.ToLocalReturnUrl());
});
}
else
@@ -347,10 +471,10 @@ else
{
var props = new AuthenticationProperties
{
RedirectUri = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl
RedirectUri = returnUrl.ToLocalReturnUrl()
};
await ctx.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, props);
});
}).RequireRateLimiting("login");
}
// Local password sign-in — available in every environment.
@@ -363,7 +487,7 @@ app.MapPost("/account/local-login", async (HttpContext ctx, IAntiforgery antifor
var email = form["email"].ToString();
var password = form["password"].ToString();
var returnUrl = form["returnUrl"].ToString();
var safeReturn = string.IsNullOrEmpty(returnUrl) || !returnUrl.StartsWith('/') ? "/" : returnUrl;
var safeReturn = returnUrl.ToLocalReturnUrl();
var user = await userService.ValidateLocalCredentialsAsync(email, password);
if (user is null)
@@ -381,7 +505,7 @@ app.MapPost("/account/local-login", async (HttpContext ctx, IAntiforgery antifor
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
return Results.Redirect(safeReturn);
});
}).RequireRateLimiting("login");
app.MapGet("/account/logout", async (HttpContext ctx) =>
{
@@ -436,13 +560,13 @@ app.MapGet("/audit/export", async (AuditRepository auditRepo, HttpContext ctx) =
sb.AppendLine("Timestamp,UserEmail,UserDisplay,UserRole,Action,Client,Sites,Details");
foreach (var e in entries.OrderByDescending(x => x.Timestamp))
{
string Esc(string v) => v.Contains(',') || v.Contains('"') || v.Contains('\n')
? $"\"{v.Replace("\"", "\"\"")}\"" : v;
// CsvSanitizer neutralizes spreadsheet formula prefixes (= + - @) on top of RFC 4180
// quoting — audited fields (display name, client/site names, details) are user-controlled.
sb.AppendLine(string.Join(",",
Esc(e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")),
Esc(e.UserEmail), Esc(e.UserDisplay), Esc(e.UserRole.ToString()),
Esc(e.Action), Esc(e.ClientName),
Esc(string.Join("; ", e.Sites)), Esc(e.Details)));
CsvSanitizer.Escape(e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")),
CsvSanitizer.Escape(e.UserEmail), CsvSanitizer.Escape(e.UserDisplay), CsvSanitizer.Escape(e.UserRole.ToString()),
CsvSanitizer.Escape(e.Action), CsvSanitizer.Escape(e.ClientName),
CsvSanitizer.Escape(string.Join("; ", e.Sites)), CsvSanitizer.Escape(e.Details)));
}
return Results.File(System.Text.Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", "audit-log.csv");
});
+74 -5
View File
@@ -19,7 +19,7 @@ A web admin toolbox for Microsoft 365 / SharePoint Online, built with Blazor Ser
## Configuration
Authentication uses Microsoft OIDC (interactive sign-in) and, for scheduled reports, app-only certificate auth.
Signing into the toolbox itself uses Microsoft OIDC (or a local account). Per-profile SharePoint/Graph access uses **certificate (app-only) auth** as the primary path — the same app identity drives both technician work and scheduled reports, so technicians never sign in per profile. Profiles without a certificate fall back to an interactive per-profile sign-in.
Set these as environment variables (or in `appsettings.json` under the `Oidc` section). .NET maps `Section__Key` to `Section:Key`.
@@ -28,6 +28,7 @@ Set these as environment variables (or in `appsettings.json` under the `Oidc` se
| `Oidc__TenantId` | Entra tenant GUID |
| `Oidc__ClientId` | App registration client ID |
| `Oidc__ClientSecret` | App registration client secret |
| `App__Domain` | Public domain the app is reached at, e.g. `sptb.example.com` or `https://sptb.example.com` (scheme defaults to `https`). Pins the OIDC sign-in redirect (`/signin-oidc`) and derives the SharePoint-connect redirect URI. |
| `DataFolder` | Persistent data path (default `/data`) |
| `ASPNETCORE_ENVIRONMENT` | Must be `Production` to enable OIDC |
@@ -37,17 +38,62 @@ Set these as environment variables (or in `appsettings.json` under the `Oidc` se
These are separate and registered on **different** Entra apps. Don't conflate them.
1. **App sign-in (OIDC).** Logging into the toolbox itself via "Sign in with Microsoft". Uses the `Oidc__*` app above. Callback path is the framework default `/signin-oidc` (not configurable here).
1. **App sign-in (OIDC).** Logging into the toolbox itself via "Sign in with Microsoft". Uses the `Oidc__*` app above. Callback path is the framework default `/signin-oidc` (not configurable here). When `App__Domain` is set, the redirect is pinned to `<domain>/signin-oidc`; otherwise it's derived from the request host (`X-Forwarded-Host`/`Host`).
→ On **this** app registration, add redirect URI `https://your-host/signin-oidc` under the **Web** platform. This app also needs the Graph permissions the audit/reporting features require: `GroupMember.Read.All`, `Group.Read.All`, `User.Read.All`.
2. **SharePoint connect (per-profile).** Getting a delegated SharePoint/Graph token for a client tenant. A PKCE public-client flow that uses **each connection profile's own `ClientId`/`TenantId`** — not the `Oidc__*` app. `ClientConnect__RedirectUri` is the callback for this flow.
→ On **each client-tenant profile's** app registration, add the `ClientConnect__RedirectUri` value (e.g. `https://your-host/connect/callback`) under the **Mobile and desktop / public client** platform.
2. **SharePoint connect (per-profile).** Getting a SharePoint/Graph token for a client tenant. The primary path is **certificate (app-only) auth** (see below), which needs no redirect URI. Profiles without a certificate fall back to an interactive PKCE public-client flow that uses **each connection profile's own `ClientId`/`TenantId`** — not the `Oidc__*` app. That fallback's callback is derived from `App__Domain` as `<domain>/connect/callback`; set `ClientConnect__RedirectUri` to override the full URL directly.
→ Only when using the interactive fallback: on **each client-tenant profile's** app registration, add that callback value (e.g. `https://your-host/connect/callback`) under the **Mobile and desktop / public client** platform. See [Per-profile app registration permissions](#per-profile-app-registration-permissions) below for the API permissions it needs.
### Per-profile app registration permissions
The **Register App** action in a profile provisions this registration for you (single tenant, public client) and grants org-wide admin consent automatically. The list below documents what it creates, for manual registration or auditing.
Per-profile access uses **certificate (app-only) auth** as the primary path: the app identity drives both interactive technician work and unattended features (scheduled reports, tenant-wide audits), so technicians never sign in per profile. The cert is provisioned alongside the registration and attached as a sign-in credential. The registration grants **application permissions** for that app-only auth; it also keeps **delegated scopes** for the interactive connect flow that profiles *without* a certificate fall back to.
**Application permissions** (app-only certificate auth — primary):
| API | Permission | Used for |
|-----|------------|----------|
| Microsoft Graph | `User.Read.All` | Look up users (unattended) |
| Microsoft Graph | `Group.ReadWrite.All` | Read group members; add members/owners (unattended) |
| Microsoft Graph | `Directory.Read.All` | Expand M365/AAD group membership in the user-access audit |
| Microsoft Graph | `Sites.FullControl.All` | Site operations (unattended) |
| Microsoft Graph | `Mail.Send` | Send emailed scheduled reports |
| SharePoint | `Sites.FullControl.All` | App-only CSOM |
**Delegated permissions** (interactive connect flow — only for profiles without a certificate):
| API | Permission | Used for |
|-----|------------|----------|
| Microsoft Graph | `User.Read` | Signed-in user's basic profile |
| Microsoft Graph | `User.Read.All` | Look up users by email/UPN |
| Microsoft Graph | `Group.ReadWrite.All` | Read group members; add members/owners |
| Microsoft Graph | `Sites.Read.All` | Resolve a site's `groupId` from its `siteId` |
| SharePoint | `AllSites.FullControl` | CSOM — site permissions, content, admin operations |
All permissions above require **admin consent**.
> **HTTPS note.** The sign-in app is a confidential (Web) client, so Entra requires its `/signin-oidc` redirect URI to be **HTTPS** — plain HTTP is allowed only for `http://localhost`, not a LAN host/IP. To run OIDC on a plain-HTTP LAN deployment, put the app behind an HTTPS-terminating reverse proxy: register `https://your-host/signin-oidc`, and the app honours `X-Forwarded-Proto` (see `UseForwardedHeaders`) to build the correct `https` redirect. Without a proxy, OIDC sign-in won't work over a non-localhost HTTP host — use the local email/password login instead.
> **Reverse-proxy host.** Set `App__Domain` so the app builds every redirect (cookie login, OIDC) against the public domain regardless of what host the proxy forwards. Without it, a proxy that doesn't forward the `Host` header makes the app 302 to the internal `IP:port` it actually received.
Persistent state (profiles, settings, templates, logs, exports, certs) lives in `DataFolder`.
## Installation — Docker
## Installation — Docker (prebuilt image)
Pulls the published image from the Gitea registry — no local build needed.
```bash
cp .env.example .env # then edit .env with your OIDC values
docker compose -f docker-compose.prebuilt.yml pull
docker compose -f docker-compose.prebuilt.yml up -d
```
The compose file reads config from `.env` (see [`.env.example`](.env.example)). Pin a
version with `SPTB_TAG`, e.g. `SPTB_TAG=v1.2.0` in `.env`. Don't quote values — the
list form embeds literal quotes and breaks OIDC discovery.
## Installation — Docker (build locally)
```bash
docker compose up -d --build
@@ -115,3 +161,26 @@ Runs in `Development` mode — OIDC off, auto-login as Admin. No Entra config ne
## Tech stack
.NET 10 · Blazor Server · Microsoft Graph SDK · PnP.Framework · Serilog · CsvHelper
## Dependencies
NuGet packages and their pinned versions (see [`SharepointToolbox.Web.csproj`](SharepointToolbox.Web.csproj)). Last reviewed 2026-06-26.
| Package | Version | Purpose |
|---------|---------|---------|
| `Azure.Identity` | 1.21.0 | Entra ID token credentials — app-only certificate and client-secret auth |
| `CsvHelper` | 33.1.0 | CSV parsing for bulk import/export (sites, members, folder structures) |
| `Microsoft.AspNetCore.Authentication.OpenIdConnect` | 10.0.9 | OIDC "Sign in with Microsoft" app sign-in |
| `Microsoft.Graph` | 6.2.0 | Microsoft Graph SDK — users, groups, sites, mail |
| `Microsoft.Kiota.Abstractions` | 2.0.0 | Graph SDK request runtime (abstractions) |
| `Microsoft.Kiota.Authentication.Azure` | 2.0.0 | Graph SDK Azure auth provider |
| `Microsoft.Kiota.Http.HttpClientLibrary` | 2.0.0 | Graph SDK HTTP transport |
| `Microsoft.Kiota.Serialization.Form` | 2.0.0 | Graph SDK form serialization |
| `Microsoft.Kiota.Serialization.Json` | 2.0.0 | Graph SDK JSON serialization |
| `Microsoft.Kiota.Serialization.Multipart` | 2.0.0 | Graph SDK multipart serialization |
| `Microsoft.Kiota.Serialization.Text` | 2.0.0 | Graph SDK text serialization |
| `PnP.Framework` | 1.19.0 | SharePoint CSOM — site permissions, content, admin operations |
| `Serilog.AspNetCore` | 10.0.0 | Structured logging integration |
| `Serilog.Sinks.File` | 7.0.0 | Rolling-file log sink |
To check for newer releases: `dotnet list package --outdated`.
+93
View File
@@ -0,0 +1,93 @@
# Security findings
Review date: 2026-06-11. Items 14 and 6 fixed the same day; #5 reviewed and accepted by design.
Second pass (OWASP Top 10), 2026-06-11: items 711 below found and fixed the same day.
---
## 1. [HIGH] ✅ FIXED — Open redirect in `/connect/initiate` leaked SharePoint session tokens
**Was:** `returnUrl` from the query string was stored verbatim in the OAuth flow state and used as the final redirect target after the code exchange — with `token_key` (a 2-minute, redeemable handle to the connected client's refresh token) appended. `…?returnUrl=https://evil.com` leaked that handle off-domain, enabling connection hijack.
**Fix:** `returnUrl` is now constrained to a site-relative path via the new `string.ToLocalReturnUrl()` helper before it is stored ([Infrastructure/OAuth/OAuthEndpoints.cs](Infrastructure/OAuth/OAuthEndpoints.cs)). The helper rejects absolute *and* protocol-relative (`//host`, `/\host`) URLs.
---
## 2. [MEDIUM] ✅ FIXED — CSV formula injection in audit-log exports
**Was:** Both audit CSV exporters quoted per RFC 4180 but didn't neutralize a leading `=` `+` `-` `@`, so user-controlled fields (display/client/site names, details) could execute as formulas in Excel.
**Fix:** Both paths now use `CsvSanitizer.Escape` (which neutralizes formula prefixes): the `/audit/export` endpoint ([Program.cs](Program.cs)) and `AuditService.ExportCsvAsync` ([Services/Audit/AuditService.cs](Services/Audit/AuditService.cs)). The local `CsvEscape`/`Esc` duplicates were removed.
---
## 3. [LOW-MEDIUM] ✅ FIXED — Spoofable `X-Forwarded-Proto` could yield a non-Secure auth cookie
**Was:** Forwarded headers are trusted from any source (proxy IP unknown inside the container network), and the prod auth cookie used `SecurePolicy = SameAsRequest` — so a spoofed `X-Forwarded-Proto: http` or a direct plaintext hit could emit a non-Secure cookie.
**Fix:** Prod auth cookie now uses `CookieSecurePolicy.Always` ([Program.cs](Program.cs)), so the Secure flag is set regardless of the (untrusted) forwarded scheme. The forwarded-headers trust config is unchanged (still required behind the proxy); the residual XFF concern is limited — client IP is not used for auth and is not recorded in the audit log.
---
## 4. [LOW] ✅ FIXED — Open redirect in `/account/login/entra` and `/account/login/dev`
**Was:** `returnUrl` was used directly as the post-auth redirect on the entra and dev sign-in paths; only local-login validated it (and even that check allowed protocol-relative `//evil.com`).
**Fix:** All three sign-in redirects now route `returnUrl` through `ToLocalReturnUrl()` ([Program.cs](Program.cs)), which also closes the protocol-relative gap in the old local-login check.
---
## 5. [LOW] ⚪ ACCEPTED (by design) — Authenticated users can download any export / report
**Where:** `/export/download/{fileName}` and `/reports/download/{id}` ([Program.cs](Program.cs)).
**Decision:** Not changed. The app has no per-user profile authorization anywhere — any tech may select and use any shared profile (see the "standard techs use profiles without sign-in" design), and `GeneratedReport` carries only a `ProfileId`, no owner. Reports are listed and shared *per client/profile*, not per user, so per-user download scoping would contradict the product model rather than fix a boundary. Both endpoints already require authentication and strip path traversal (`Path.GetFileName`). Revisit only if a per-tech profile ACL is introduced.
---
## 6. [LOW] ✅ FIXED — Admin pages gated only by a render-time role check
**Was:** `/admin/users` and `/admin/audit` used `@attribute [Authorize]` (any authenticated user) plus an in-markup `if (Role != Admin) return;`.
**Fix:** Added an `Admin` authorization policy (`RequireClaim("app_role", "Admin")`) in [Program.cs](Program.cs) and both pages now use `[Authorize(Policy = "Admin")]` ([UserManagement.razor](Components/Pages/Admin/UserManagement.razor), [AuditLogs.razor](Components/Pages/Admin/AuditLogs.razor)). A claim-value policy (not `[Authorize(Roles=…)]`) was used deliberately: the local/dev sign-in identities don't set a `ClaimTypes.Role` claim, so a Roles check would have silently denied local admins. The in-component checks were kept as defense-in-depth.
---
## 7. [MEDIUM] ✅ FIXED — No brute-force protection on local sign-in (OWASP A07)
**Was:** `/account/local-login` and `UserService.ValidateLocalCredentialsAsync` had no rate limiting and no account lockout, leaving local accounts (incl. the bootstrap admin) open to unlimited password guessing — relevant given the plain-HTTP/LAN bootstrap deployment mode.
**Fix:** Two layers. (1) A per-account lockout on `AppUser` (`FailedLoginCount`/`LockoutEndUtc`) — 5 consecutive failures → 15-minute lock, counter cleared on success ([UserService.cs](Services/Auth/UserService.cs), [AppUser.cs](Core/Models/AppUser.cs)). A locked account is refused before the password is even checked, and failure stays generic (no enumeration). (2) An IP-partitioned fixed-window rate limiter (`"login"` policy, 10/min) on `/account/local-login` and `/account/login/entra` ([Program.cs](Program.cs)). The account lockout is the control that holds up when `X-Forwarded-For` is spoofed/rotated (forwarded headers are trusted from any source — see #3); the IP limiter is the coarse volumetric layer.
---
## 8. [MEDIUM] ✅ FIXED — Missing security response headers (OWASP A05)
**Was:** No `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, or `Referrer-Policy`. A clickjacked page could drive the victim's live Blazor Server circuit, and there was no CSP backstop against injected script.
**Fix:** A middleware emits these on every response ([Program.cs](Program.cs)). CSP is tuned to the app: all scripts are external so `script-src 'self'` (no `unsafe-*`); `style-src` keeps `'unsafe-inline'` for the login page's inline `<style>` and the components' `style=""`; `img-src 'self' data:` for base64 client logos / download links; `connect-src 'self'` for the SignalR WebSocket; `frame-ancestors 'none'` (+ `X-Frame-Options: DENY`) blocks clickjacking.
---
## 9. [LOW-MEDIUM] ✅ FIXED — Container ran as root; `/data` world-readable (OWASP A05 / A02)
**Was:** The [Dockerfile](Dockerfile) had no `USER` directive, so the process ran as root and the `/data` volume (Data Protection keys, app-only certs, user store) had default permissions. The DP keys decrypt the auth cookie, the browser-stored SharePoint refresh tokens, and the on-disk certs — a large blast radius for anyone able to read the volume.
**Fix:** Final image now creates `/data` owned by the shipped non-root `app` user with mode `0700`, then `USER app` ([Dockerfile](Dockerfile)). Docker seeds a fresh named volume from that ownership/mode, so the running user can write it while other host users can't read the keys/certs at rest. (DP keys themselves remain unencrypted at rest — inherent to file-based keys in a container; the volume permissions are the mitigation.)
---
## 10. [LOW] ✅ FIXED — Raw token-endpoint response reflected into the redirect URL (OWASP A09)
**Was:** `/connect/callback` put the raw Azure token-endpoint JSON (and the provider's verbose `error_description`) into `?connect_error=…`, landing in browser history and — behind the proxy — proxy access logs. Those bodies can carry correlation/trace ids and claim hints.
**Fix:** The token-endpoint failure body and the verbose `error_description` are now logged server-side via `ILogger`; the redirect carries only a generic notice (or the short, safe OAuth `error` code) ([OAuthEndpoints.cs](Infrastructure/OAuth/OAuthEndpoints.cs)).
---
## 11. [LOW] ✅ FIXED — `/connect/callback` threw a 500 when no refresh_token was returned
**Was:** `root.GetProperty("refresh_token").GetString()!` threw `KeyNotFoundException` (→ 500 + stack trace in non-prod) when the tenant/app withheld `offline_access`.
**Fix:** Uses `TryGetProperty`; a missing/empty refresh token is logged and redirects with a friendly `connect_error` instead of throwing ([OAuthEndpoints.cs](Infrastructure/OAuth/OAuthEndpoints.cs)).
+11 -15
View File
@@ -2,6 +2,7 @@ using System.Text;
using Microsoft.AspNetCore.Authentication;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Persistence;
using SharepointToolbox.Web.Services.Export;
using SharepointToolbox.Web.Services.Session;
namespace SharepointToolbox.Web.Services.Audit;
@@ -41,23 +42,18 @@ public class AuditService : IAuditService
sb.AppendLine("Timestamp,UserEmail,UserDisplay,UserRole,Action,Client,Sites,Details");
foreach (var e in entries.OrderByDescending(x => x.Timestamp))
{
// CsvSanitizer adds spreadsheet formula-injection guards (= + - @) on top of
// RFC 4180 quoting; the user/display/client/site fields are user-controlled.
sb.AppendLine(string.Join(",",
CsvEscape(e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")),
CsvEscape(e.UserEmail),
CsvEscape(e.UserDisplay),
CsvEscape(e.UserRole.ToString()),
CsvEscape(e.Action),
CsvEscape(e.ClientName),
CsvEscape(string.Join("; ", e.Sites)),
CsvEscape(e.Details)));
CsvSanitizer.Escape(e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")),
CsvSanitizer.Escape(e.UserEmail),
CsvSanitizer.Escape(e.UserDisplay),
CsvSanitizer.Escape(e.UserRole.ToString()),
CsvSanitizer.Escape(e.Action),
CsvSanitizer.Escape(e.ClientName),
CsvSanitizer.Escape(string.Join("; ", e.Sites)),
CsvSanitizer.Escape(e.Details)));
}
return sb.ToString();
}
private static string CsvEscape(string value)
{
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
}
+3 -1
View File
@@ -11,7 +11,9 @@ public interface IUserService
Task<AppUser?> GetByEmailAsync(string email);
Task<IReadOnlyList<AppUser>> GetAllAsync();
Task UpdateRoleAsync(string userId, UserRole role);
/// <summary>Persist a new role for the user. Returns the previous role (read from the store).</summary>
/// <exception cref="KeyNotFoundException">No user matches <paramref name="userId"/>.</exception>
Task<UserRole> UpdateRoleAsync(string userId, UserRole role);
Task DeleteAsync(string userId);
/// <summary>Create a local password-based account. First user ever becomes Admin.</summary>
+54 -4
View File
@@ -1,5 +1,6 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Persistence;
@@ -9,11 +10,13 @@ public class UserService : IUserService
{
private readonly UserRepository _repo;
private readonly IPasswordHasher<AppUser> _hasher;
private readonly ILogger<UserService> _logger;
public UserService(UserRepository repo, IPasswordHasher<AppUser> hasher)
public UserService(UserRepository repo, IPasswordHasher<AppUser> hasher, ILogger<UserService> logger)
{
_repo = repo;
_hasher = hasher;
_logger = logger;
}
public async Task<AppUser> ProvisionAsync(ClaimsPrincipal principal)
@@ -56,13 +59,23 @@ public class UserService : IUserService
public Task<IReadOnlyList<AppUser>> GetAllAsync() => _repo.LoadAsync();
public async Task UpdateRoleAsync(string userId, UserRole role)
public async Task<UserRole> UpdateRoleAsync(string userId, UserRole role)
{
var users = (await _repo.LoadAsync()).ToList();
var user = users.FirstOrDefault(u => u.Id == userId)
?? throw new KeyNotFoundException($"User {userId} not found.");
?? throw new KeyNotFoundException($"User '{userId}' not found among {users.Count} stored users.");
var oldRole = user.Role;
user.Role = role;
await _repo.UpsertAsync(user);
// Verify the write landed by re-reading the row from disk.
var persisted = (await _repo.LoadAsync()).FirstOrDefault(u => u.Id == user.Id)?.Role;
_logger.LogInformation(
"UpdateRoleAsync: {Email} (id {Id}) {OldRole} → {NewRole}; persisted value now {Persisted}.",
user.Email, user.Id, oldRole, role, persisted);
return oldRole;
}
public Task DeleteAsync(string userId) => _repo.DeleteAsync(userId);
@@ -96,21 +109,58 @@ public class UserService : IUserService
return user;
}
// Brute-force lockout: after this many consecutive failures the account is locked
// for the window below. Tuned to stop guessing while staying usable for a fat-fingered
// admin. The counter resets on any successful sign-in.
private const int LockoutThreshold = 5;
private static readonly TimeSpan LockoutWindow = TimeSpan.FromMinutes(15);
public async Task<AppUser?> ValidateLocalCredentialsAsync(string email, string password)
{
var user = await _repo.FindByEmailAsync(email);
if (user is null || user.Provider != AuthProvider.Local || string.IsNullOrEmpty(user.PasswordHash))
return null;
var now = DateTimeOffset.UtcNow;
// Account currently locked → refuse without even checking the password, so a locked
// account can't be probed. Returning null (generic failure) avoids account enumeration.
if (user.LockoutEndUtc is { } until && until > now)
{
_logger.LogWarning("Local login blocked: account {Email} is locked until {Until:o}.", user.Email, until);
return null;
}
// Lock window elapsed → clear it before re-evaluating.
if (user.LockoutEndUtc is not null)
{
user.LockoutEndUtc = null;
user.FailedLoginCount = 0;
}
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (result == PasswordVerificationResult.Failed)
{
user.FailedLoginCount++;
if (user.FailedLoginCount >= LockoutThreshold)
{
user.LockoutEndUtc = now + LockoutWindow;
_logger.LogWarning(
"Local login: account {Email} locked for {Minutes} min after {Count} failed attempts.",
user.Email, LockoutWindow.TotalMinutes, user.FailedLoginCount);
}
await _repo.UpsertAsync(user);
return null;
}
// Transparently upgrade the hash if the algorithm parameters changed
if (result == PasswordVerificationResult.SuccessRehashNeeded)
user.PasswordHash = _hasher.HashPassword(user, password);
user.LastLogin = DateTimeOffset.UtcNow;
// Success → clear the failure trail.
user.FailedLoginCount = 0;
user.LockoutEndUtc = null;
user.LastLogin = now;
await _repo.UpsertAsync(user);
return user;
}
+12 -12
View File
@@ -26,19 +26,19 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.14.1" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
<PackageReference Include="Microsoft.Graph" Version="5.74.0" />
<PackageReference Include="Microsoft.Kiota.Abstractions" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Authentication.Azure" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Http.HttpClientLibrary" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Form" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Json" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Multipart" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Text" Version="1.22.2" />
<PackageReference Include="PnP.Framework" Version="1.18.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.9" />
<PackageReference Include="Microsoft.Graph" Version="6.2.0" />
<PackageReference Include="Microsoft.Kiota.Abstractions" Version="2.0.0" />
<PackageReference Include="Microsoft.Kiota.Authentication.Azure" Version="2.0.0" />
<PackageReference Include="Microsoft.Kiota.Http.HttpClientLibrary" Version="2.0.0" />
<PackageReference Include="Microsoft.Kiota.Serialization.Form" Version="2.0.0" />
<PackageReference Include="Microsoft.Kiota.Serialization.Json" Version="2.0.0" />
<PackageReference Include="Microsoft.Kiota.Serialization.Multipart" Version="2.0.0" />
<PackageReference Include="Microsoft.Kiota.Serialization.Text" Version="2.0.0" />
<PackageReference Include="PnP.Framework" Version="1.19.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
</ItemGroup>
+3
View File
@@ -1,5 +1,8 @@
{
"DataFolder": "/data",
"App": {
"Domain": ""
},
"Oidc": {
"TenantId": "YOUR_ENTRA_TENANT_ID",
"ClientId": "YOUR_SPTB_APP_CLIENT_ID",
+45
View File
@@ -0,0 +1,45 @@
# Runs the prebuilt image from the Gitea registry (no local build).
# docker compose -f docker-compose.prebuilt.yml pull
# docker compose -f docker-compose.prebuilt.yml up -d
#
# Pin a version by overriding the tag: SPTB_TAG=v1.2.0 docker compose ...
# Set the OIDC secrets via a .env file next to this compose file (see below).
services:
sptb-web:
image: git.azuze.fr/kawa/sptb-web:${SPTB_TAG:-latest}
container_name: sptb-web
ports:
- "8080:8080"
volumes:
- sptb-data:/data
environment:
- ASPNETCORE_ENVIRONMENT=Production
- DataFolder=/data
# Public domain the app is reached at (e.g. sptb.example.com). The SharePoint-connect
# redirect URI is derived from it as <domain>/connect/callback.
- App__Domain=${App__Domain:-}
# OIDC config — overrides the placeholder values baked into appsettings.json.
# Authority is derived from TenantId in code; do NOT set an Authority key.
# Put real values in a .env file beside this compose file (NO quotes around
# values — the list form embeds literal quotes and breaks discovery):
# Oidc__TenantId=<entra-tenant-guid>
# Oidc__ClientId=<app-client-id>
# Oidc__ClientSecret=<app-client-secret>
- Oidc__TenantId=${Oidc__TenantId:-}
- Oidc__ClientId=${Oidc__ClientId:-}
- Oidc__ClientSecret=${Oidc__ClientSecret:-}
# Optional: seed first admin while the user store is empty (local form login).
- Bootstrap__AdminEmail=${Bootstrap__AdminEmail:-}
- Bootstrap__AdminPassword=${Bootstrap__AdminPassword:-}
restart: unless-stopped
healthcheck:
# /account/login is anonymous and returns 200; -f fails on >=400.
test: ["CMD", "curl", "-fsS", "http://localhost:8080/account/login"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
sptb-data:
driver: local
+4
View File
@@ -12,6 +12,10 @@ services:
environment:
- ASPNETCORE_ENVIRONMENT=Production
- DataFolder=/data
# Public domain the app is reached at (e.g. sptb.example.com). The SharePoint-connect
# redirect URI (<domain>/connect/callback) is derived from it. Set your OIDC values
# here too, or pass an env file.
- App__Domain=${App__Domain:-}
restart: unless-stopped
healthcheck:
# /account/login is anonymous and returns 200 (the app root now 302-redirects