Security
For an Architecture Review Board (ARB) or Security Review - security posture, threat model, data handling, and the honest residual risks in one place. Reflects the 2026-06-01 audits.
Security posture at a glance
Section titled “Security posture at a glance”| Control | Implementation |
|---|---|
| Inbound authentication | Entra JWT bearer via Microsoft.Identity.Web (issuer / audience / signature validated). No token → 401. Fails closed: production refuses to start without AzureAd config; the permissive dev handler is reachable only in Development and logs a loud warning. |
| Authorization | 30 narrow scopes (16 .Read + 14 .ReadWrite), least-privilege, ~1:1 to personas. A write action always carries a .ReadWrite policy; 147 of 331 write actions (about 44%) also sit behind a class-level .Read policy and need both roles, the other 184 (about 56%), including Mailboxes, Users, and Recipients, gate the write on .ReadWrite alone. Either way, a read-only token can never write. |
| Cardinal control - cmdlet allowlist | Every cmdlet is checked against a hard allowlist at the executor seam, before PowerShell runs. No arbitrary-cmdlet endpoint exists. Defense in depth: the runspace only imports allowlisted cmdlets (-CommandName), so an un-allowlisted cmdlet isn’t even loaded. Allowlist membership also routes EXO vs IPPS, guarded by a disjointness test. |
| No injection surface | All cmdlet arguments - write bodies and the $filter→OPATH translation - pass via structured AddParameter binding, never string interpolation. AddScript / Invoke-Expression / ScriptBlock.Create appear nowhere in request handling. $filter is AST-validated + quote-escaped. |
| Secret handling | Outbound tokens acquired in-memory, never written to disk; production is secret-free (MI / certificate). Secret-shaped values scrubbed in logs and in caller-facing error bodies. Zero secrets in the repo or its full git history (verified by scan). |
| Error envelope | Canonical { error: { code, message, target, innerError }, correlationId }. No stack traces / CLR types to callers. |
| HTTP hardening | In-process security headers (nosniff / SAMEORIGIN / no-referrer; HSTS outside dev). |
| Rate limiting | Per-principal token bucket (100 / 5 min), 429 + Retry-After, canonical envelope. |
| Runspace isolation | Lease-exclusive sessions, age/count/health recycle + idle sweeper, hard timeouts, off-path wedged-slot recovery. An interactive-prompt DoS (a missing mandatory param could crash the process) is fixed - host prompts decline gracefully. |
| Input validation | DataAnnotations on write bodies → canonical validation envelope; large Set-* bodies use a per-cmdlet writable-params allowlist; a request-time drift gate rejects (422) params a tenant’s cmdlet version doesn’t accept. |
| dryRun safety | ?dryRun is guaranteed non-mutating - cmdlets whose -WhatIf is a no-op return 422 before invocation, never the real operation under a false dry-run banner. |
Threat model
Section titled “Threat model”| Threat | Mitigation |
|---|---|
| Command / script injection | Hard allowlist + structured AddParameter; no AddScript; $filter AST-validated → OPATH. |
| Privilege escalation (read token drives a write) | Independent per-route policies; the read-only /diag/cmdlet tool is hard-restricted to Get-*. |
| Secret / credential disclosure | In-memory-only tokens; log + error-body scrubbing; no committed secrets; secret-free production. |
| Denial of service | Per-principal rate limit; pool with connect/cmdlet timeouts + wedged-slot recovery + idle sweeper; prompt callbacks decline instead of throwing. |
| Tampering / replay | TLS at the ingress; JWT signature + audience validated; writes set -Confirm:$false and support ?dryRun. |
| Information disclosure | Single-tenant; error envelope withholds internals (raw EXO message text is surfaced as an aid - see residual risks). |
Data handling & tenancy
Section titled “Data handling & tenancy”- Tenancy: single tenant in v1 (one app registration, one EXO organization).
- What transits: Exchange administrative metadata - mailbox settings, group membership, transport / retention / Defender policy config, message-trace metadata. Not message bodies or attachments (that surface belongs to Microsoft Graph).
- What is logged: structured app logs + a
correlationIdper error. Request/response bodies are not logged at the wire level; secret-shaped values scrubbed. - What is stored: warm PowerShell sessions only. No response cache, no persistent datastore
- stateless between requests. Tokens live only in memory for a request/session’s life.
Honest residual risks (disclosed, not hidden)
Section titled “Honest residual risks (disclosed, not hidden)”None is a known live exploit; each is a hardening item a board may want tracked (full detail in the audits):
- Two read endpoints accept a raw
?filter=string (/mailPublicFolders,/transportRules) that bypasses the$filterproperty allowlist (still a-Filtervalue, not script) - to be constrained or explicitly risk-accepted. - CORS is not configured in-process - expected to be handled at an Azure ingress / APIM; the baseline security headers ship in-process now.
- FullLanguage runspaces -
ConstrainedLanguagewould be defense-in-depth (no script runs today, given the allowlist + parameterized binding). - Raw EXO error messages are returned to callers - a deliberate usability tradeoff; the text is Microsoft’s and is now secret-scrubbed on the caller-facing body.
- Observability is console-only - OpenTelemetry / Application Insights not yet wired; needed before a production deployment.
- No server-side retry budget yet - EXO throttles surface
Retry-Afterto the caller, but the executor does not yet retry-with-backoff internally. - Not yet deployed - runs locally against one tenant; Azure infrastructure (MI auth, Key Vault, container image) is designed but intentionally on hold.

