Commit Graph

19 Commits

Author SHA1 Message Date
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 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 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 80f660053d Strip quotes/whitespace from Oidc config values
docker-compose's `environment` list form embeds literal quotes in the value
(`- Oidc__TenantId="<guid>"` → the value is "<guid>" with quotes), producing a
malformed Authority URL (…/"<tenant>"/v2.0). Metadata discovery then fails with
IDX20803 and the Microsoft sign-in challenge 500s. The same trap on ClientSecret
would silently break the token exchange.

Trim surrounding quotes and whitespace from TenantId, ClientId and ClientSecret
so a quoted env var no longer breaks OIDC.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:32:58 +02:00
kawa 8dfbf7c18a Fix OIDC stuck-on-loading: slim auth cookie principal
The OIDC OnTokenValidated handler stored the raw principal (all id_token +
userinfo claims) in the auth cookie. Encrypted + base64 it exceeds ~4 KB, so
ChunkingCookieManager splits it across …CookiesC1/C2. The chunked cookie
survives the prerender GET but is dropped on the Blazor interactive WebSocket
upgrade, so the circuit comes up anonymous and the page sticks on "Chargement…".
SaveTokens=false alone didn't shrink it enough — the claims themselves bloat it.

Replace the principal with a slim 4-claim identity (preferred_username, name,
app_role, auth_provider), identical to the local-login path, so the cookie
stays single + unchunked and the circuit authenticates.

Also fixes a latent bug: the OIDC principal never carried app_role or
auth_provider, so Entra admins got no admin nav and logout skipped the OIDC
sign-out branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:21:46 +02:00
kawa c23039efa1 Fix stuck-on-loading after sign-in; enable HTTP/LAN local login
The app stuck on "Chargement…" after sign-in because the interactive
Blazor circuit came up anonymous: no auth cookie reached this origin.
Root cause was the deployment (plain HTTP on an IP, http://host:8080),
which Microsoft OIDC cannot serve — Entra forbids http redirect URIs for
non-localhost hosts, so the sign-in cookie never lands on the origin.

Changes:
- ForwardedHeaders (X-Forwarded-Proto/For) so that behind a TLS proxy the
  app sees the real https scheme, builds a matching OIDC redirect_uri, and
  sets the auth cookie Secure. Proxy IP unknown in-container → known
  proxy/network restrictions cleared.
- First-run bootstrap: seed a local admin (Bootstrap__AdminEmail /
  Bootstrap__AdminPassword) when that email has no account, so HTTP/LAN
  deployments that can't use OIDC can sign in via the local form. Idempotent.
- OIDC SaveTokens=false: the cookie-stored access/id/refresh tokens were
  never read (SharePoint/Graph auth uses the separate connect-flow + cert
  paths). Dropping them keeps the auth cookie small/unchunked.
- AppInitializer now logs which branch leaves UserContext unseeded
  (unauthenticated principal / missing claim / no user row) instead of
  failing silently — this is what surfaced the anonymous-circuit cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:46:53 +02:00
kawa ebda614aaa Fix prod auth: persist DataProtection keys; redirect unauth to login
Two deployment-breaking issues caused 404s on protected pages after a
container recreate:

1. DataProtection keys were stored in the container's ephemeral home dir.
   Every redeploy regenerated them, invalidating all auth cookies (users
   silently logged out) and — worse — making the app-only certs encrypted
   under /data/appcerts undecryptable. Persist keys to /data/dpkeys with a
   stable application name so they survive recreates.

2. DefaultChallengeScheme was OpenIdConnect, so a logged-out request to any
   [Authorize] Blazor page forced an OIDC challenge. When OIDC is
   unconfigured/unreachable the challenge throws and the request 404s, with
   no path to the login page. Challenge the cookie scheme instead, which
   redirects to /account/login (the combined local + Microsoft page). OIDC
   is still triggered explicitly from /account/login/entra.

Also harden the container image:
- Pin base images to exact patch (sdk:10.0.300, aspnet:10.0.8). Floating
  :10.0 tags drift; a stale/pre-GA SDK base silently drops blazor.web.js
  from the publish manifest, 404ing framework assets in production.
- Install curl and switch the compose healthcheck to it (the aspnet image
  ships no wget/curl, so the old healthcheck always reported unhealthy).
  Probe /account/login (anonymous, 200) since / now 302-redirects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:34:58 +02:00
kawa d265a1a81b Fix 404 on _framework assets in production deploy
Blazor framework assets (blazor.web.js) under _framework are served via
the static-asset endpoints manifest, not physical wwwroot files. Plain
UseStaticFiles only serves physical files, so published deployments
returned 404 for blazor.web.js (worked in dev via the dev-time static
web assets provider). Switch to MapStaticAssets, which reads the
endpoints manifest shipped with publish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:35:47 +02:00
kawa 6d9c79ad5a Add scheduled reports + app-only cert auth; fix tenant-wide user-access audit
Feature work:
- Certificate (app-only) auth per profile: cert store, context/Graph client
  factories, automated app-registration provisioning (delegated + application
  permissions, admin consent), and a SessionManager seam that resolves the auth
  model per profile.
- Scheduled reports: repositories, hosted service/runner/coordinator, report
  pages, and email delivery (app-only Mail.Send).
- Tenant-wide user-access audit when no site is selected.

Audit fixes:
- Site enumeration: app-only discovery used Graph getAllSites (needs Graph
  Sites.Read.All the cert app lacks) and silently returned empty. Switched to
  the admin-host CSOM TenantSiteEnumerator, matching the scheduler; both auth
  models now share one enumeration path.
- Group expansion: the scan records a SharePoint group as a single principal, so
  user-centric audits found nothing for group-granted access. Resolve group
  membership (shared by audit + scheduler) and attribute it to the target user.
- M365 group claims: the resolver only recognized AAD security groups
  (c:0t.c|). Group-connected/Teams sites grant via the M365 group claim
  (c:0o.c|…|<guid>[_o]); now expanded too, resolving owners for the "_o" claim.
- Provision Directory.Read.All as an application permission so M365/AAD group
  expansion works under the cert identity.

Also: ignore data/appcerts/ (encrypted certificate key material).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:55:28 +02:00
kawa a5c57ba1e8 Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web 2026-06-03 09:50:25 +02:00
kawa e6ae06e29d Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web 2026-06-02 17:39:52 +02:00
kawa 78881b83a2 Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web 2026-06-02 17:13:09 +02:00
kawa d69c3290d8 Merge pull request 'Add report logos and configurable folder scan depth' (#2) from feat/report-logos-and-scan-depth into main
Reviewed-on: #2
2026-06-02 15:46:05 +02:00
kawa 57f5239cfc Wire auto-elevate ownership across all SharePoint operations
The "Auto-elevate ownership when permission scan is denied" setting was
dead code: the toggle was persisted but never read, the audit flow never
passed its onAccessDenied callback, and EnrichException wrapped every CSOM
error (including ServerUnauthorizedAccessException) into a generic
InvalidOperationException so the access-denied catch could never match.

Centralize elevation instead of per-call-site callbacks:

- Throw typed SharePointAccessDeniedException from EnrichException on
  access-denied, preserving the failing site URL and enriched diagnostic.
- Add scoped IElevationCoordinator that catches it, and when AutoTakeOwnership
  is enabled takes site-collection admin via the tenant admin endpoint and
  retries the operation once. Per-site dedupe prevents loops; admin-host
  denials are not treated as ownership issues. Retry is safe because each
  wrapped operation closure re-issues its own CSOM loads.
- Wrap all site-scoped operations (Storage, Permissions, Duplicates, Search,
  VersionCleanup, FolderStructure, BulkMembers, FileTransfer, Templates) and
  the UserAccessAudit per-site scan in the coordinator.
- Drop the unused onAccessDenied parameter from IUserAccessAuditService.

Elevation still requires SharePoint tenant admin rights on the signed-in
account; the coordinator surfaces a clear message when that is missing.

Also keeps the prior StorageService change that avoids admin-gated
folder.StorageMetrics (403 for delegated non-admin tokens).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:16:12 +02:00
kawa bcced08caf Register Entra app via secretless device-code bootstrap
AADSTS700016 came from the register flow sending the configured
Oidc:ClientId (still a placeholder) as the auth client. The desktop
reference app never needs config: it bootstraps with the first-party
"Microsoft Graph Command Line Tools" public client (14d82eec-...) via
MSAL interactive, which exists in every tenant.

Replicate that for the web app. A server can't do MSAL loopback and the
bootstrap client's redirect URIs don't include /connect/callback, so use
the OAuth 2.0 device authorization grant instead — the web-equivalent of
the desktop interactive flow:

- Add EntraDeviceCodeFlow: POST /devicecode then poll /token with the
  bootstrap client. No backing app, no client id/secret, no redirect URI.
- Profiles "Register in Entra" now shows the verification URL + user code
  and polls until the admin signs in, then calls AppRegistrationService
  to create the per-client app and adopts its appId.
- Remove the dead /connect/register-initiate endpoint and the
  IsRegistration branch from the callback (connect flow only now).

The client-tenant register/connect flows are now fully secretless. The
Oidc:* config is used only by the toolbox's own sign-in (unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:47:23 +02:00
kawa 5a23783e07 Fix GUI polish issues across auth modal, theme, and 404
- Add missing modal CSS (.modal-overlay/.modal-dialog/.modal-header):
  the "Connect to Microsoft" auth modal was rendering unstyled inline
  at the bottom of the page. Now a centered dialog with backdrop.
- Surface OAuth connect errors in the modal instead of silently
  reopening it with no explanation.
- MainLayout: implement IDisposable so event handlers are actually
  unsubscribed (Dispose existed but was never invoked).
- Wire up the Settings theme selector (was a dead control): drop the
  unsupported Dark option, call sptb.setTheme on save and on load,
  resolve System via prefers-color-scheme.
- Add branded 404 page via UseStatusCodePagesWithReExecute + Routes
  <NotFound> (blank white page before).
- Add .progress-fill.indeterminate animation and .progress-panel.
- Home: replace inline JS hover handlers with a .feature-card CSS class.
- Define missing --surface-2 variable referenced by MainLayout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:16:01 +02:00
kawa d19092c84e Initial commit 2026-06-02 10:56:03 +02:00