diff --git a/src/content/docs/building-blocks/mailing.mdx b/src/content/docs/building-blocks/mailing.mdx index 479adb71..365dc963 100644 --- a/src/content/docs/building-blocks/mailing.mdx +++ b/src/content/docs/building-blocks/mailing.mdx @@ -1,6 +1,6 @@ --- title: Mailing building block -lastUpdated: 2026-06-11 +lastUpdated: 2026-09-25 description: SMTP or SendGrid email abstraction behind a single IMailService, with multi-recipient delivery and HTML body support. sidebar: label: Mailing @@ -59,6 +59,27 @@ public class MailRequest( Because `Body` always lands in the HTML part, plain text placed there is parsed as markup. Two consequences bite in practice: a bare URL is **not** turned into a link - most clients only auto-link inside `text/plain` - so an action link arrives as dead text the user cannot click; and any interpolated value (a person's name, a tenant's name) is read as markup rather than shown. Build real HTML with an ``, HTML-encode every interpolated value, and put the plain wording in `TextBody`. ::: +### `HtmlEmail` helper + +`FSH.Framework.Mailing.HtmlEmail` is the shared shell and the single encoder for outbound mail, so no module ships its own weaker escaping: + +- **`Encode(value)`** - full HTML encoding (`WebUtility.HtmlEncode`, including quotes and apostrophes), safe in both element content and attributes. +- **`Shell(heading, innerHtml)`** - wraps markup in the kit's document: doctype, charset, viewport and the centred card. `heading` is encoded; `innerHtml` is **trusted** and inserted verbatim, so build it from literals plus `Encode`d values - never pass user input straight through. +- **`LinkAction(heading, intro, actionUrl, actionLabel)`** - a single-action message: the link as a real button anchor, with the address repeated as text for clients that strip buttons. +- **`Notice(heading, message)`** - a short informational message with no link. + +```csharp +string html = HtmlEmail.LinkAction( + "Reset your password", + "Use the button below to choose a new password.", + resetUri, + "Reset password"); + +var mail = new MailRequest(to, "Reset your password", html, textBody: $"Reset your password: {resetUri}"); +``` + +Always pair it with a `TextBody` alternative. + ### Implementations - **`SmtpMailService`** - uses MailKit + MimeKit. Reads `MailOptions:Smtp:*` for host, port, credentials; connects with STARTTLS per send. @@ -82,7 +103,7 @@ var mailRequest = new MailRequest( jobService.Enqueue("email", () => mailService.SendAsync(mailRequest, cancellationToken)); ``` -Identity uses this shape for its email flows: email confirmation, password reset, and the welcome mail. Its `EmailBodies` helper renders the HTML - the action link as a real anchor, every interpolated value HTML-encoded - while the `textBody` argument carries the same wording in plain text. +Identity uses this shape for its email flows: email confirmation, password reset, and the welcome mail. Password reset and the welcome mail render through `HtmlEmail` (`LinkAction` / `Notice`); the confirmation e-mail still builds its own document for now. The billing e-mails in the Notifications module render inside `HtmlEmail.Shell` and encode every value with `HtmlEmail.Encode`. In each case the `textBody` argument carries the same wording in plain text. ## Configuration @@ -148,6 +169,7 @@ Build a separate `IMailTemplateRenderer` service. Resolve a Razor or Scriban tem - `src/BuildingBlocks/Mailing/Services/SmtpMailService.cs` - `src/BuildingBlocks/Mailing/Services/SendGridMailService.cs` - `src/BuildingBlocks/Mailing/MailOptions.cs` +- `src/BuildingBlocks/Mailing/HtmlEmail.cs` ## Related diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx index 72f069e1..356edfea 100644 --- a/src/content/docs/changelog/index.mdx +++ b/src/content/docs/changelog/index.mdx @@ -24,6 +24,10 @@ Notable changes to the kit, newest first. - **Idempotency: an idempotent handler now survives a client disconnect (fix).** The handler ran under the client's abort token, so a client hanging up right after the side effect committed cancelled whatever the handler awaited next - an EF read, an outbox write, a Mediator behaviour - and the filter was left with nothing to store: the retry re-executed the side effect, which is the exact duplicate the feature exists to absorb. The handler now runs with that token detached, so on an idempotent endpoint a disconnect no longer aborts it (keep those handlers short, and don't put `.WithIdempotency()` on a streaming or large-file endpoint - the response is buffered to be captured). Four smaller holes went with it: the cache **probe** was the one link that still hard-failed, so a Redis blip 500'd every idempotent endpoint for exactly the clients that send a key - it now fails open as a miss like the reservation and the store; a handler that writes to `HttpContext.Response` itself is passed through instead of having an empty capture stored and its status set after the response started; the key folds in the resolved **route values**, so `PUT /tickets/1` and `PUT /tickets/2` are no longer one operation; and the key folds in the **caller**, so two users of one tenant reusing a low-entropy key no longer receive each other's response bodies. The `409` now carries `Retry-After: 1`, and `IdempotencyOptions` is validated at startup - a zero `DefaultTtl` used to throw inside the best-effort store, log a warning and carry on, so nothing was ever cached and replay never engaged. - **Idempotency: self-registration is no longer idempotent, and no anonymous endpoint can be (fix).** `/self-register` is anonymous, and there is no user to scope the cache key by, so every unauthenticated caller resolves to the same `anon` caller: two people registering on one tenant with the same low-entropy key (`"1"`, `"retry"`) built the identical key, and the second replayed the first registrant's `201` while their own account was silently never created. Reachable only once replay started engaging, which is what the rest of this entry did. `.WithIdempotency()` is off that endpoint - a genuine retry there is already safe, the unique-email constraint rejects the duplicate - and `WithIdempotency()` now marks the endpoint with `IdempotentEndpointMetadata` so an integration test can walk the endpoint map and fail the build if an `AllowAnonymous()` endpoint ever carries it again. - **Idempotency: only 2xx is stored, and a duplicate still in flight gets `409`.** A failure is not a record of a committed side effect, and storing it locked the caller out of that key for the full 24 h TTL after a transient downstream error. Concurrent duplicates are serialized by an atomic in-flight reservation (Redis `SET NX`, else in-process) under a new **`IdempotencyOptions.ReservationTtl`** (default 1 minute), deliberately decoupled from the response TTL - keying the lock to 24 h would strand it for a day if the process died mid-request. The duplicate that loses the race re-probes once, and otherwise receives `409 Conflict`. Reserve and release both fail open, and the `409` and the over-long-key `400` are now RFC 9457 `ProblemDetails` like every other error on these endpoints. +- **CORS: the restricted policy now allows `PATCH` and every header the two React apps send (fix).** With `CorsOptions:AllowAll=false` - the shipped `appsettings.json` and `appsettings.Production.json` - the browser rejected preflights from both apps, because `AllowedHeaders` was only `content-type`, `authorization` and `AllowedMethods` lacked `PATCH`. Dev hid it (`appsettings.Development.json` sets `AllowAll: true`). The shipped lists now add `tenant` (every call), `x-fsh-app` (login), `idempotency-key` (chat send), and `x-requested-with` / `x-signalr-user-agent` (SignalR negotiate), plus `PATCH`. **Upgrade note:** if you override `CorsOptions:AllowedHeaders` or `CorsOptions:AllowedMethods` in your own config, add these too, or login, realtime and `PATCH` calls fail under restricted CORS. See [CORS & headers](/docs/security/cors-and-headers/) and [#1391](https://github.com/fullstackhero/dotnet-starter-kit/pull/1391). +- **Auditing: entity-change diffs no longer store secrets (security fix).** The entity diff flagged sensitive properties as `IsSensitive` but still stored their raw old and new values, so `PasswordHash`, security stamps and token values landed in `AuditRecords` in clear text. Any property whose name contains `password`, `secret`, `token`, `apikey`, `connectionstring` or `securitystamp` is now masked to `"****"` (a `null` stays `null`). Rows written before the upgrade are not rewritten - purge or scrub them if that matters for you. A new **`IAuditExempt`** marker in `Modules.Auditing.Contracts` skips an entity in entity-change auditing entirely; `[NoAudit]` still only affects HTTP activity auditing. See [Auditing](/docs/modules/auditing/) and [#1392](https://github.com/fullstackhero/dotnet-starter-kit/pull/1392). +- **Forwarded headers: `TrustedProxyOptions:ForwardLimit` below `1` is rejected at startup (fix).** It was passed straight to `ForwardedHeadersOptions`: `0` silently left forwarded headers unprocessed, and a negative value made every request fail with `500`. Both now fail the host build with a message naming the setting. See [Reverse proxy & forwarded headers](/docs/security/cors-and-headers/#reverse-proxy--forwarded-headers) and [#1386](https://github.com/fullstackhero/dotnet-starter-kit/pull/1386). +- **Mailing: one HTML shell and one encoder for every module.** New `HtmlEmail` helper in `FSH.Framework.Mailing` - `Encode`, `Shell`, `LinkAction`, `Notice` - replaces Identity's internal `EmailBodies` and the billing e-mails' own wrapper. Billing e-mails (invoice issued, nearing expiry, grace, expired) now render in the shared card and escape values with full HTML encoding (quotes and apostrophes included, and the invoice amount's currency) instead of a hand-rolled `&` / `<` / `>` replace. The confirmation e-mail still builds its own document for now. See [Mailing](/docs/building-blocks/mailing/) and [#1385](https://github.com/fullstackhero/dotnet-starter-kit/pull/1385). ## 2026-08-07 diff --git a/src/content/docs/modules/auditing.mdx b/src/content/docs/modules/auditing.mdx index b1e8fe9a..51e0dcb1 100644 --- a/src/content/docs/modules/auditing.mdx +++ b/src/content/docs/modules/auditing.mdx @@ -1,6 +1,6 @@ --- title: Auditing module -lastUpdated: 2026-06-11 +lastUpdated: 2026-09-25 description: Entity-change capture via SaveChanges interceptor, HTTP middleware for request/response audit, security events, exception capture, JSON masking, and a retention purge job. sidebar: label: Auditing @@ -25,6 +25,8 @@ Seven read-only endpoints expose audits filtered by tenant, user, event type, se - **Security events** - `Audit.ForSecurity(SecurityAction.LoginFailed).WithUser(...).WriteAsync()` for login attempts, permission denials, policy failures. - **Exception capture** - `Audit.ForException(ex).WithSeverity(...)` with `ExceptionSeverityClassifier` that maps common .NET exception types to sensible severities (e.g. `OperationCanceledException` → Information, `UnauthorizedAccessException` → Warning, everything else → Error). - **JSON masking** - `IAuditMaskingService` redacts sensitive fields (passwords, tokens, PII) before audits are written. Configurable patterns. +- **Entity-diff masking** - the entity-change diff masks the value of any property whose name contains `password`, `secret`, `token`, `apikey`, `connectionstring` or `securitystamp` (case-insensitive): old and new values are stored as `"****"` (a `null` stays `null`, so you still see a value being set or cleared) and the change is flagged `IsSensitive`. The row records *that* the value changed, never the value. +- **`IAuditExempt` marker** - implement `FSH.Modules.Auditing.Contracts.IAuditExempt` on an entity to skip it in entity-change auditing entirely, for entities whose values must live in one table only (confidential reports, health records). - **Channel publisher → SQL sink with file DLQ** - audits go through a `System.Threading.Channels` pipeline so the request path isn't blocked. Writes go to PostgreSQL; failures fall back to a local file DLQ. - **Trigram GIN indexes** - PostgreSQL `pg_trgm` extension powers free-text search across `Source`, `UserName`, and other audit fields. - **Retention job** - opt-in Hangfire `AuditRetentionJob` purges old records on a daily cron, with per-event-type retention windows. @@ -90,7 +92,7 @@ The entity-change variant is the one **you almost never call directly** - the Sa Without any code changes, the module captures: -1. Every EF Core entity Insert / Update / Delete with property-level before / after values (`AuditingSaveChangesInterceptor`). +1. Every EF Core entity Insert / Update / Delete with property-level before / after values (`AuditingSaveChangesInterceptor`) - sensitive-named properties masked to `"****"`, and entities implementing `IAuditExempt` skipped. 2. Every HTTP request and response with body, status, duration, headers (size-capped via `AuditHttpOptions.MaxRequestBytes` / `MaxResponseBytes`, content-type-filtered to JSON-ish by default). 3. Every unhandled exception that bubbles to the host's exception handler (severity classified automatically). @@ -199,6 +201,23 @@ endpoints.MapPost("/sensitive", handler) …or apply `[NoAudit]` to a handler method. +`[NoAudit]` / `.NoAudit()` only governs **HTTP activity** auditing. It does not stop the SaveChanges interceptor from diffing the entities that endpoint writes. + +### Opt an entity out of entity-change auditing + +Sensitive-named properties are already masked (see above). When an entity's values must not be copied into `AuditRecords` at all, mark it with `IAuditExempt` - the interceptor then captures no diff for it: + +```csharp +using FSH.Modules.Auditing.Contracts; + +public sealed class ConfidentialReport : BaseEntity, IAuditExempt +{ + // ... +} +``` + +The masking keyword list lives in `EntityDiffBuilder` (substring match on the property name) and is kept in step with `JsonMaskingService`; add a keyword in both places if your entities use another naming. + ### Plug a different sink `IAuditSink` is the interface; `SqlAuditSink` is the default; `IAuditDlqSink` is the dead-letter sink. Add an OpenTelemetry sink, a Splunk sink, a Kafka sink - anything you need - by registering a different implementation. The channel publisher fans out so multiple sinks can coexist. diff --git a/src/content/docs/security/cors-and-headers.mdx b/src/content/docs/security/cors-and-headers.mdx index 56449de2..a96b6849 100644 --- a/src/content/docs/security/cors-and-headers.mdx +++ b/src/content/docs/security/cors-and-headers.mdx @@ -1,6 +1,6 @@ --- title: CORS & security headers -lastUpdated: 2026-07-13 +lastUpdated: 2026-09-25 description: CORS-before-HTTPS-redirect ordering, the SignalR-credentialed-CORS gotcha, forwarded-headers trusted-proxy config, and the production security headers the kit emits by default. sidebar: label: CORS & headers @@ -30,14 +30,15 @@ CORS in fullstackhero has two non-default conventions: **CORS middleware runs be "https://app.example.com", "https://admin.example.com" ], - "AllowedHeaders": [ "content-type", "authorization" ], - "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ] + "AllowedHeaders": [ "content-type", "authorization", "tenant", "x-fsh-app", "idempotency-key", "x-requested-with", "x-signalr-user-agent" ], + "AllowedMethods": [ "GET", "POST", "PUT", "PATCH", "DELETE" ] } } ``` - `AllowAll: true` → `SetIsOriginAllowed(_ => true)` + any header + any method + `AllowCredentials()`. Dev only. - `AllowAll: false` → `WithOrigins/WithHeaders/WithMethods` from the three lists + `AllowCredentials()`. Startup validation **fails** if any of the three lists is empty while `AllowAll` is false. +- The shipped header and method lists are exactly what the two React apps send: `tenant` on every call, `x-fsh-app` on login, `idempotency-key` on chat send, `x-requested-with` / `x-signalr-user-agent` on the SignalR negotiate, and `PATCH` for partial updates. If you override `AllowedHeaders` or `AllowedMethods`, keep all of them - drop one and the browser rejects the preflight, so login, realtime or `PATCH` calls fail under restricted CORS (dev hides this because `appsettings.Development.json` sets `AllowAll: true`). Add your own custom headers on top. - Credentials are always allowed by the policy - there is no `AllowCredentials` config key. - If `AllowAll` is false **and** `AllowedOrigins` is empty, CORS isn't mounted at all - cross-origin browser calls will simply fail. (`appsettings.Production.json` ships with an empty list precisely so you have to fill it in.) diff --git a/src/content/docs/security/production-checklist.mdx b/src/content/docs/security/production-checklist.mdx index 8f6191b6..59ec19b1 100644 --- a/src/content/docs/security/production-checklist.mdx +++ b/src/content/docs/security/production-checklist.mdx @@ -67,8 +67,8 @@ Adjust for your industry. Healthcare (HIPAA) and finance (PCI-DSS) tend to requi "https://app.example.com", "https://admin.example.com" ], - "AllowedHeaders": [ "content-type", "authorization" ], - "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ] + "AllowedHeaders": [ "content-type", "authorization", "tenant", "x-fsh-app", "idempotency-key", "x-requested-with", "x-signalr-user-agent" ], + "AllowedMethods": [ "GET", "POST", "PUT", "PATCH", "DELETE" ] } } ```