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
89 changes: 89 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ A PostgreSQL connection string is required, by default under
| `DatabaseConnectionName` | `appfoundation-database` | Connection-string name to read |
| `WolverineServiceName` | `AppFoundation` | Service name for the durable inbox/outbox |
| `SecretsDirectory` | `/run/secrets` | Key-per-file secrets directory (see below) — *from 1.1.0* |
| `SchemaCreation` | env-based | Marten schema mode; `null` ⇒ `All` in Development, `CreateOrUpdate` otherwise. Set `None` for least-privilege — see [Database schema](#database-schema) |
| `KnownProxyNetworks` / `KnownProxies` | *(empty)* | Trusted reverse-proxy CIDRs / IPs for `X-Forwarded-*` — see [Running behind a reverse proxy](#running-behind-a-reverse-proxy) |
| `ConfigureForwardedHeaders` | — | Callback for full `ForwardedHeadersOptions` control |
| `DataProtectionApplicationDiscriminator` | `WolverineServiceName` | Isolates protected payloads from other apps sharing infrastructure |
| `ConfigureDataProtection` | — | Callback on the DataProtection builder (Key Vault, cert rotation, …) |
| `AllowUnprotectedKeyRing` | `false` | Accept an unencrypted key ring outside Development — see [Data protection keys](#data-protection-keys) |

**`AppFoundationLayoutOptions`** (management shell branding):
`BrandName`, `LogoPath`, `Copyright`, and `AdminMenu` (a Razor component type rendered as
Expand Down Expand Up @@ -136,6 +142,89 @@ secrets:

---

## Production configuration

Outside `Development`, a few things must be configured — the app is secure by default and
**fails fast** rather than running in an unsafe state. Checklist for a non-Development host
(`ASPNETCORE_ENVIRONMENT=Production` or `Staging`):

1. **Connection string** — required (see [Configuration](#configuration)).
2. **Data protection keys** — required, or the app won't start (see below).
3. **Reverse proxy** — set the trusted proxy networks so client IP/scheme are honored (see below).
4. **Database schema** — defaults to non-destructive; tighten for least privilege (see below).

### Data protection keys

The DataProtection key ring is persisted in the same database as the data it protects, so
outside Development the foundation **refuses to start unless the keys are encrypted at rest**
— otherwise a database dump would also expose the keys guarding the SMTP password, login
tokens, and auth cookies. Provide one of:

- **A certificate** via `DataProtection:CertificatePath` (+ `DataProtection:CertificatePassword`),
ideally supplied as Docker secrets:

```yaml
environment:
- DataProtection__CertificatePath=/run/secrets/dataprotection_cert
secrets:
- dataprotection_cert
- DataProtection__CertificatePassword
```

- **Key Vault / KMS** via `options.ConfigureDataProtection = b => b.ProtectKeysWithAzureKeyVault(...)`.
- Or, only when the database storage is already encrypted at rest by other means, opt out with
`options.AllowUnprotectedKeyRing = true`.

### Running behind a reverse proxy

The app trusts the TCP peer of each request — which behind a proxy is the **proxy's** address,
not the client's. Declare the proxy so `X-Forwarded-For` / `X-Forwarded-Proto` are honored only
from it (and never spoofable by arbitrary clients). Configure via code or, since the production
CIDR is usually only known at deploy time, via configuration:

```yaml
# docker-compose.yml — app service
environment:
- AppFoundation__KnownProxyNetworks=172.28.0.0/16 # your proxy/ingress subnet
expose: ["8080"] # not published to the host — only the proxy can reach the app
networks: [edge]
networks:
edge:
ipam:
config: [{ subnet: 172.28.0.0/16 }]
```

`AppFoundation__KnownProxyNetworks` (and `AppFoundation__KnownProxies` for single IPs) accept a
comma/semicolon/space-delimited list or a config array; IPv6 CIDRs are supported. With none set,
forwarded headers are trusted from any origin in Development but only from loopback otherwise
(the app logs a warning). The proxy must forward the headers — for nginx, and to keep Blazor
Server circuits alive over WebSockets:

```nginx
location / {
proxy_pass http://app:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; # https → app sees IsHttps=true
proxy_http_version 1.1; # WebSocket upgrade for SignalR
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
```

For multiple proxies (e.g. Cloudflare → nginx), also raise `ForwardLimit` via
`ConfigureForwardedHeaders` and trust each hop.

### Database schema

`SchemaCreation` defaults to `AutoCreate.All` in Development (permits destructive rebuilds) and
`AutoCreate.CreateOrUpdate` (additive only — never drops) elsewhere, so a code/database mismatch
can't destroy data at runtime. For a least-privilege deployment, set `SchemaCreation = AutoCreate.None`
and provision the schema out-of-band (a migration job / `db-apply`) with a privileged role, then
run the app with a role that has no DDL rights.

---

## Repository layout

```
Expand Down
11 changes: 11 additions & 0 deletions src/AndreGoepel.AppFoundation.Hosting/AppFoundationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ public sealed class AppFoundationOptions
/// </summary>
public Action<IDataProtectionBuilder>? ConfigureDataProtection { get; set; }

/// <summary>
/// Opt in to storing the DataProtection key ring <b>without</b> at-rest encryption
/// outside Development. The key ring lives in the same database as the data it
/// protects, so by default the foundation refuses to start in a non-Development
/// environment unless key encryption is configured (a certificate via
/// <c>DataProtection:CertificatePath</c>, or Key Vault/KMS via
/// <see cref="ConfigureDataProtection"/>). Set this to <c>true</c> only when the
/// database storage is encrypted at rest by other means (#54).
/// </summary>
public bool AllowUnprotectedKeyRing { get; set; }

/// <summary>
/// Optional extension point on the Wolverine options, invoked inside the
/// foundation's single <c>UseWolverine</c> call after its own configuration.
Expand Down
43 changes: 43 additions & 0 deletions src/AndreGoepel.AppFoundation.Hosting/Initialization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.DataProtection.KeyManagement;
using Microsoft.AspNetCore.DataProtection.XmlEncryption;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Radzen;
using Wolverine;
using Wolverine.Marten;
Expand Down Expand Up @@ -176,6 +178,16 @@ public static WebApplication UseAppFoundation(this WebApplication app)
app.MapDefaultEndpoints();

var options = app.Services.GetRequiredService<AppFoundationOptions>();

// Fail closed if the key ring would be persisted unencrypted in a non-local
// environment: the keys live in the same Postgres as the data they protect,
// so a database dump must not also yield the keys (#54).
EnsureKeyRingProtected(
app.Environment.IsDevelopment(),
options.AllowUnprotectedKeyRing,
app.Services.GetRequiredService<IOptions<KeyManagementOptions>>().Value.XmlEncryptor
);

var forwardedOptions = BuildForwardedHeadersOptions(
options,
app.Environment.IsDevelopment()
Expand Down Expand Up @@ -225,6 +237,37 @@ public static WebApplication UseAppFoundation(this WebApplication app)
/// (local convenience) and only the framework default (loopback) elsewhere, so
/// arbitrary clients cannot spoof the client IP or scheme in production (#51).
/// </summary>
/// <summary>
/// Throws when the DataProtection key ring would be stored without at-rest
/// encryption outside Development, unless the host has explicitly accepted that
/// via <see cref="AppFoundationOptions.AllowUnprotectedKeyRing"/>.
/// <paramref name="xmlEncryptor"/> is the resolved
/// <see cref="KeyManagementOptions.XmlEncryptor"/>: non-<c>null</c> whenever key
/// encryption is configured (certificate, Key Vault, KMS, …), so this reflects the
/// actual end state regardless of how protection was wired.
/// </summary>
internal static void EnsureKeyRingProtected(
bool isDevelopment,
bool allowUnprotectedKeyRing,
IXmlEncryptor? xmlEncryptor
)
{
if (isDevelopment || allowUnprotectedKeyRing || xmlEncryptor is not null)
{
return;
}

throw new InvalidOperationException(
"The DataProtection key ring would be stored unencrypted in the database, "
+ "where a dump would also expose the keys that protect the SMTP password, "
+ "login tokens, and auth cookies. Configure key encryption — set "
+ "DataProtection:CertificatePath, or use AppFoundationOptions."
+ "ConfigureDataProtection for Azure Key Vault / KMS — or set "
+ "AppFoundationOptions.AllowUnprotectedKeyRing = true to accept this "
+ "(e.g. when the database storage is encrypted at rest by other means)."
);
}

/// <summary>
/// Merges reverse-proxy trust configured under <c>AppFoundation:KnownProxyNetworks</c>
/// and <c>AppFoundation:KnownProxies</c> into <paramref name="options"/>. Each key
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using AndreGoepel.AppFoundation.Hosting;
using Microsoft.AspNetCore.DataProtection.XmlEncryption;
using NSubstitute;

namespace AndreGoepel.AppFoundation.Tests.Hosting;

public class EnsureKeyRingProtectedTests
{
[Fact]
public void NonDevelopment_WithoutEncryptor_NotAllowed_Throws()
{
// Arrange / Act / Assert
var exception = Assert.Throws<InvalidOperationException>(() =>
Initialization.EnsureKeyRingProtected(
isDevelopment: false,
allowUnprotectedKeyRing: false,
xmlEncryptor: null
)
);
Assert.Contains("key ring", exception.Message);
}

[Fact]
public void NonDevelopment_WithEncryptor_DoesNotThrow()
{
// Arrange
var encryptor = Substitute.For<IXmlEncryptor>();

// Act / Assert — encryption configured (cert / Key Vault / KMS), so allowed.
Initialization.EnsureKeyRingProtected(
isDevelopment: false,
allowUnprotectedKeyRing: false,
xmlEncryptor: encryptor
);
}

[Fact]
public void NonDevelopment_WithoutEncryptor_ExplicitlyAllowed_DoesNotThrow()
{
// Act / Assert — host accepted an unencrypted key ring.
Initialization.EnsureKeyRingProtected(
isDevelopment: false,
allowUnprotectedKeyRing: true,
xmlEncryptor: null
);
}

[Fact]
public void Development_WithoutEncryptor_DoesNotThrow()
{
// Act / Assert — local development never requires key encryption.
Initialization.EnsureKeyRingProtected(
isDevelopment: true,
allowUnprotectedKeyRing: false,
xmlEncryptor: null
);
}
}
Loading