Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,34 @@ This project follows Semantic Versioning using the format `MAJOR.MINOR.PATCH`.
differ from earlier releases; string and numeric digests are unchanged.
* Clarified that the `Hash` disposition is an unkeyed integrity digest with no
confidentiality for low-entropy values; use `HmacSha256` instead.
* **Behavior change for adopters:** MVC now validates antiforgery tokens on
every unsafe request (`POST`, `PUT`, `PATCH`, `DELETE`) through a global
`AutoValidateAntiforgeryTokenAttribute`. Actions that must accept requests
without a token, such as token-authenticated APIs that do not use cookies,
need `[IgnoreAntiforgeryToken]`.
* **Behavior change:** claims transformation now returns a new principal
instead of editing the incoming one, and sets each identity's `NameClaimType`
and `RoleClaimType` to `application:name` and `application:role`, so
`User.Identity.Name`, `User.IsInRole`, and `[Authorize(Roles = "...")]` keep
working when `RemoveOriginalClaims` is enabled.
* Paths in `SecurityHeaders:ExcludedPathPrefixes` (`/health`, `/metrics`) now
still receive `X-Content-Type-Options: nosniff`.
* HSTS is implemented by `UseApplicationHsts()` in `SecurityHeadersExtensions`
and invoked from the same slot in `UseProblemDetails()`; the pipeline order is
unchanged.
* Authorization policies are built from the bound and validated
`ApplicationAuthorizationOptions` instead of a separate configuration snapshot
read at registration.
* `ApplicationSecurityHeadersOptions.SectionName` and
`ApplicationRequestLoggingOptions.SectionName` are now `const`.

### Fixed

* Audit actor attribution (`HttpContextCurrentActorAccessor` and
`HttpContextApplicationAuditContextAccessor`) now reads the normalized
`application:subject` claim first. With `RemoveOriginalClaims` enabled,
authenticated users were previously recorded as `Remote IP: ...`.

* Audit reconciliation runs now persist findings in a single transaction inside
the execution strategy (or join a caller-owned transaction), guard every
finding update with its `ConcurrencyStamp`, and insert findings only when the
Expand Down
2 changes: 1 addition & 1 deletion docs/articles/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Error handling is configured through the application pipeline using:
app.UseProblemDetails();
```

This is the single error-handling registration. It adds the developer exception page in Development, or the production exception handler and HSTS outside it, and then branches status-code handling between Problem Details responses and the re-executed browser error page.
This is the single error-handling registration. It adds the developer exception page in Development, or the production exception handler and HSTS (`UseApplicationHsts()`) outside it, and then branches status-code handling between Problem Details responses and the re-executed browser error page.

The error handling behavior is environment-aware:

Expand Down
2 changes: 1 addition & 1 deletion docs/articles/health-checks.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ The default security header configuration excludes `/health`:
]
```

Because the exclusion is prefix-based, `/health`, `/health/ready`, and `/health/live` are all excluded from security header application. This keeps health probe responses small and infrastructure-friendly.
Because the exclusion is prefix-based, `/health`, `/health/ready`, and `/health/live` are all excluded from the security header middleware, except `X-Content-Type-Options: nosniff`. `Strict-Transport-Security` is registered separately and still applies to HTTPS health responses outside Development. This keeps health probe responses small and infrastructure-friendly.

## Contract References

Expand Down
2 changes: 1 addition & 1 deletion docs/articles/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The pipeline order is:
11. Authorization
12. Controller and Razor Page endpoint mapping

Error handling is one step, not two. `UseProblemDetails()` adds the developer exception page in Development, or the production exception handler and HSTS outside it, and then branches status-code handling between Problem Details responses and the re-executed browser error page. Registering a second environment-aware error-handling extension alongside it would add a duplicate exception handler, a duplicate HSTS middleware, and an unconditional status-code re-execute wrapping the classified one.
Error handling is one step, not two. `UseProblemDetails()` adds the developer exception page in Development, or the production exception handler and HSTS (`UseApplicationHsts()`) outside it, and then branches status-code handling between Problem Details responses and the re-executed browser error page. Registering a second environment-aware error-handling extension alongside it would add a duplicate exception handler, a duplicate HSTS middleware, and an unconditional status-code re-execute wrapping the classified one.

This order keeps proxy correction early, request logging close to the beginning of the request, error handling ahead of most application behavior, and endpoint-specific features such as CORS and rate limiting after routing.

Expand Down
27 changes: 16 additions & 11 deletions docs/articles/security-headers.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ app.UseApplicationSecurityHeaders();

## v1.0 Security Header Contract

This contract applies when `ProjectTemplate:SecurityHeaders:Enabled` is `true` and the request path does not match `ExcludedPathPrefixes`.
This contract applies when `ProjectTemplate:SecurityHeaders:Enabled` is `true` and the request path does not match `ExcludedPathPrefixes`. Responses on excluded paths still receive `X-Content-Type-Options: nosniff` and no other header from this middleware. `Strict-Transport-Security` is registered separately (see [HSTS and Transport Security](#hsts-and-transport-security)) and also applies to excluded paths on HTTPS requests outside Development.

| Header | Default | Contract | Configuration |
|:---|:---|:---|:---|
Expand All @@ -39,7 +39,7 @@ This contract applies when `ProjectTemplate:SecurityHeaders:Enabled` is `true` a
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)` | Configurable | Controlled by `EnablePermissionsPolicy` and `PermissionsPolicy` |
| `Content-Security-Policy` | `default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; img-src 'self' data:; script-src 'self'; style-src 'self';` | Configurable | Controlled by `EnableContentSecurityPolicy` and `ContentSecurityPolicy` |
| `X-XSS-Protection` | Not emitted | Intentionally omitted | Not supported |
| `Strict-Transport-Security` | Not emitted | Intentionally omitted | See [HSTS and Transport Security](#hsts-and-transport-security) |
| `Strict-Transport-Security` | Not emitted by this middleware | Emitted by `UseApplicationHsts()` outside Development | See [HSTS and Transport Security](#hsts-and-transport-security) |

The middleware intentionally does not add `X-XSS-Protection` because that header is obsolete and can create inconsistent behavior in modern browsers.

Expand All @@ -55,15 +55,19 @@ whoever owns the certificate, the origin, and the rollback path, which in most
deployments is the reverse proxy, ingress controller, CDN, or host platform
rather than the application process.

NCAT therefore emits headers that are safe to apply per-response and defers HSTS
to an explicit deployment decision.
NCAT therefore emits headers that are safe to apply per-response from this
middleware, registers HSTS separately with framework defaults, and leaves HSTS
values to an explicit deployment decision.

### Where HSTS belongs

ASP.NET Core provides `UseHsts()` and `AddHsts(...)` for application-emitted HSTS.
The generated pipeline does not call `UseHsts()`. A consuming application may add
it, or may leave HSTS to the edge. Emitting it from both layers is not an error,
but only one layer should own the values.
The generated pipeline calls `UseHsts()` outside Development through
`UseApplicationHsts()` (defined in `SecurityHeadersExtensions`, registered by
`UseProblemDetails()` between the exception handler and status-code pages), with the ASP.NET Core
defaults. A consuming application that leaves HSTS to the edge may remove that
call. Emitting it from both layers is not an error, but only one layer should
own the values.

| Layer | When it is the right owner |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
Expand Down Expand Up @@ -96,8 +100,9 @@ Whichever layer owns HSTS, the following are explicit choices, not defaults:
default. Do not add development hosts to an HSTS policy; a cached directive on
a developer machine outlives the branch that caused it.

NCAT does not validate, emit, or test HSTS behavior. An application that adopts
HSTS owns its values, its rollout, and its rollback.
Apart from registering `UseHsts()` with framework defaults, NCAT does not
configure, validate, or test HSTS values. An application that adopts HSTS owns
its values, its rollout, and its rollback.

## Intentional Opt-Outs

Expand All @@ -109,7 +114,7 @@ The following settings reduce or remove default browser hardening and should be
| `EnableContentSecurityPolicy = false` | Removes CSP | Temporary troubleshooting or applications that must define CSP elsewhere |
| `EnablePermissionsPolicy = false` | Removes Permissions-Policy | Only when browser feature policy is managed elsewhere |
| `EnableCrossOriginHeaders = false` | Removes COOP and CORP | Applications that intentionally integrate cross-origin windows or resources |
| `ExcludedPathPrefixes` | Skips all security headers for matching paths | Infrastructure endpoints such as `/health` and `/metrics` |
| `ExcludedPathPrefixes` | Skips every security header except `X-Content-Type-Options` for matching paths | Infrastructure endpoints such as `/health` and `/metrics` |

## Configuration

Expand Down Expand Up @@ -141,7 +146,7 @@ Security headers can be configured from `appsettings.json`:
|`EnableCrossOriginHeaders`|Controls whether `Cross-Origin-Opener-Policy` and `Cross-Origin-Resource-Policy` are applied.|
|`ContentSecurityPolicy`|Defines the application Content Security Policy value.|
|`PermissionsPolicy`|Defines the Permissions Policy value.|
|`ExcludedPathPrefixes`|Skips security header application for matching request path prefixes.|
|`ExcludedPathPrefixes`|Skips security header application, except `X-Content-Type-Options: nosniff`, for matching request path prefixes.|

## Environment-Specific Behavior

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Diagnostics;
using System.Security.Claims;
using ProjectTemplate.Infrastructure.Data.Auditing;
using ProjectTemplate.Web.Authentication.Claims;

namespace ProjectTemplate.Web.Accessors;

Expand All @@ -20,7 +21,9 @@ public ApplicationAuditContext Current
HttpContext? httpContext = httpContextAccessor.HttpContext;
ClaimsPrincipal? user = httpContext?.User;

string? subject = GetAuthenticatedClaim(user, _subjectClaimType)
// Prefer the normalized application claim: claims transformation may remove the provider claims.
string? subject = GetAuthenticatedClaim(user, ApplicationClaimTypes.Subject)
?? GetAuthenticatedClaim(user, _subjectClaimType)
?? GetAuthenticatedClaim(user, ClaimTypes.NameIdentifier);

if (!string.IsNullOrWhiteSpace(subject))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Security.Claims;
using ProjectTemplate.Infrastructure.Data;
using ProjectTemplate.Web.Authentication.Claims;

namespace ProjectTemplate.Web.Accessors;

Expand All @@ -16,7 +17,7 @@ public sealed class HttpContextCurrentActorAccessor(

/// <summary>
/// Accesses the current actor information from the HTTP context. It first attempts to retrieve the authenticated
/// subject claim from the user's claims, then falls back to the authenticated name identifier claim, then the remote
/// normalized application subject claim, then the provider subject claim, then falls back to the authenticated name identifier claim, then the remote
/// IP address. If none are available, it returns "Unknown".
/// </summary>
public string CurrentActor
Expand Down Expand Up @@ -47,7 +48,10 @@ public string CurrentActor
return null;
}

string? subject = GetClaimValue(user, _subjectClaimType);
// Prefer the normalized application claim: when claims transformation removes the original claims,
// the provider "sub" and name identifier claims are no longer present.
string? subject = GetClaimValue(user, ApplicationClaimTypes.Subject)
?? GetClaimValue(user, _subjectClaimType);

if (!string.IsNullOrWhiteSpace(subject))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,13 @@ public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
return Task.FromResult(principal);
}

foreach (ClaimsIdentity identity in principal.Identities.OfType<ClaimsIdentity>())
// Transformation may run more than once per request, and the incoming principal can be shared with other
// components, so normalize copies instead of editing the caller's principal in place.
var transformed = new ClaimsPrincipal();

foreach (ClaimsIdentity source in principal.Identities)
{
ClaimsIdentity identity = source.Clone();
ApplicationClaimMappingOptions mappings = ResolveMappings(options, identity.AuthenticationType);

NormalizeClaim(identity, ApplicationClaimTypes.Subject, mappings.Subject, options.RemoveOriginalClaims);
Expand All @@ -38,9 +43,41 @@ public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
NormalizeClaim(identity, ApplicationClaimTypes.Role, mappings.Role, options.RemoveOriginalClaims);
NormalizeClaim(identity, ApplicationClaimTypes.Group, mappings.Group, options.RemoveOriginalClaims);
NormalizeClaim(identity, ApplicationClaimTypes.Permission, mappings.Permission, options.RemoveOriginalClaims);

transformed.AddIdentity(WithNormalizedNameAndRoleClaimTypes(identity));
}

return Task.FromResult(principal);
return Task.FromResult(transformed);
}

// Points Identity.Name, User.IsInRole, and [Authorize(Roles = "...")] at application:name and
// application:role, so they keep working once RemoveOriginalClaims strips the provider claims. The original
// claim type is kept only when the identity still carries it and has no normalized equivalent (for example,
// when the provider's type is not in the configured mappings).
private static ClaimsIdentity WithNormalizedNameAndRoleClaimTypes(ClaimsIdentity identity)
{
string nameClaimType = ResolveClaimType(identity, ApplicationClaimTypes.Name, identity.NameClaimType);
string roleClaimType = ResolveClaimType(identity, ApplicationClaimTypes.Role, identity.RoleClaimType);

return string.Equals(nameClaimType, identity.NameClaimType, StringComparison.Ordinal)
&& string.Equals(roleClaimType, identity.RoleClaimType, StringComparison.Ordinal)
? identity
: new ClaimsIdentity(identity.Claims, identity.AuthenticationType, nameClaimType, roleClaimType)
{
Actor = identity.Actor,
BootstrapContext = identity.BootstrapContext,
Label = identity.Label
};
}

private static string ResolveClaimType(ClaimsIdentity identity, string normalizedClaimType, string currentClaimType)
{
bool hasNormalized = identity.HasClaim(claim =>
string.Equals(claim.Type, normalizedClaimType, StringComparison.OrdinalIgnoreCase));
bool hasCurrent = identity.HasClaim(claim =>
string.Equals(claim.Type, currentClaimType, StringComparison.OrdinalIgnoreCase));

return hasNormalized || !hasCurrent ? normalizedClaimType : currentClaimType;
}

private static ApplicationClaimMappingOptions ResolveMappings(
Expand Down
Loading
Loading