diff --git a/.githooks/README.md b/.githooks/README.md new file mode 100644 index 0000000..7314003 --- /dev/null +++ b/.githooks/README.md @@ -0,0 +1,26 @@ +# Git hooks + +Repo-managed git hooks (they live in version control, unlike `.git/hooks`). + +## Enable (once per clone) + +```bash +git config core.hooksPath .githooks +``` + +## `pre-commit` — secret scan + +A fast, dependency-free backstop for the root `CLAUDE.md` rule **"Never commit secrets"** +(refinement-phase-5). It rejects a commit that stages: + +- the historically-leaked SQL Server host `87.107.152.16`, +- the retired hardcoded admin password `qw123321`, +- a **real** connection-string password in any `appsettings*.json` (only the `SET_VIA_USER_SECRETS_OR_ENV` + placeholder is allowed — real values belong in user-secrets / environment variables), +- private-key material or an AWS access-key id, anywhere. + +It scans only staged additions, so it is quick. It is **not** a replacement for a full scanner +(gitleaks / trufflehog) in CI — it is the local first line of defence. + +Bypass a false positive with `git commit --no-verify` (use sparingly, and only when you are certain the +flagged line is not a secret). diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..9e16a72 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Balinyaar secret-scanning pre-commit hook (refinement-phase-5). +# Blocks a commit that stages an obvious credential. This is a fast, dependency-free backstop for the +# root CLAUDE.md rule "Never commit secrets" — not a replacement for gitleaks/trufflehog in CI. +# +# Enable once per clone: git config core.hooksPath .githooks +# Bypass a false positive: git commit --no-verify (use sparingly, and only when you are certain) +# +set -euo pipefail + +# Committed placeholders are allowed — real values are not. Keep in sync with StartupSecretsGuard. +PLACEHOLDER='SET_VIA_USER_SECRETS_OR_ENV' + +# Only scan added/changed lines in text files that are staged. +staged=$(git diff --cached --name-only --diff-filter=ACM) +[ -z "$staged" ] && exit 0 + +violations=0 +report() { printf ' ✖ %s\n' "$1"; violations=$((violations + 1)); } + +while IFS= read -r file; do + # Skip this hook, lockfiles, and binaries. + case "$file" in + .githooks/*) continue ;; + *.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.dll|*.exe|*.snk) continue ;; + esac + [ -f "$file" ] || continue + + added=$(git diff --cached -U0 -- "$file" | grep '^+' | grep -v '^+++' || true) + [ -z "$added" ] && continue + + # The historically-leaked SQL Server host — must never reappear. + echo "$added" | grep -Eq '87\.107\.152\.16' && report "$file: leaked SQL Server host 87.107.152.16" + + # The retired hardcoded admin password. + echo "$added" | grep -Eq 'qw123321' && report "$file: hardcoded admin password 'qw123321'" + + # A real (non-placeholder) connection-string password in a committed appsettings file. + case "$file" in + *appsettings*.json) + echo "$added" \ + | grep -Ei 'Password=[^;"'"'"' ]+' \ + | grep -viq "Password=${PLACEHOLDER}" \ + && report "$file: connection-string password must be '${PLACEHOLDER}' (real value belongs in user-secrets/env)" + ;; + esac + + # Private keys and common cloud tokens, anywhere. + echo "$added" | grep -Eq -- '-----BEGIN (RSA|EC|OPENSSH|PRIVATE) .*PRIVATE KEY-----' && report "$file: private key material" + echo "$added" | grep -Eq 'AKIA[0-9A-Z]{16}' && report "$file: AWS access key id" +done <<< "$staged" + +if [ "$violations" -gt 0 ]; then + echo "" + echo "Commit blocked: $violations potential secret(s) staged. Move the real value to user-secrets" + echo "(Development) or an environment variable (deploy) and commit only the '${PLACEHOLDER}' placeholder." + echo "See dev/post-phase/refinement/RUNBOOK.md. To override a false positive: git commit --no-verify" + exit 1 +fi + +exit 0 diff --git a/dev/post-phase/refinement/RUNBOOK.md b/dev/post-phase/refinement/RUNBOOK.md index abfebbf..5a4669a 100644 --- a/dev/post-phase/refinement/RUNBOOK.md +++ b/dev/post-phase/refinement/RUNBOOK.md @@ -75,9 +75,11 @@ Development environment, so this never affects a deployed build. dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj ``` -On boot the API applies all EF migrations and seeds roles + an admin user + a sandbox payment gateway -against the (empty) local DB, then listens on **`https://localhost:5002`** — Swagger at -`https://localhost:5002/swagger`. +On boot the API applies all EF migrations and seeds roles against the (empty) local DB, then listens on +**`https://localhost:5002`** — Swagger at `https://localhost:5002/swagger`. In **Development** it also seeds a +sandbox payment gateway and the demo world (below). It does **not** seed the old `admin`/`qw123321` account +anymore (refinement-phase-5); a break-glass admin is created only if you set `Seed:AdminUsername` / +`Seed:AdminPassword` (see below), and the day-to-day admin path is the phone-OTP demo admins. In **Development** it additionally runs the **demo-world seeder** (Refinement Phase 1): verified/unverified demo nurses with priced variants + Tehran coverage (and therefore real `nurse_search_index` rows), plus demo @@ -112,7 +114,15 @@ endpoint works). Use them to see the real path populated: | `09120000011` | customer | رضا حسینی (male) | 1 patient, 1 Tehran address | | `09120000020` | admin (`super_admin`) | نگار مدیری (female) | full backoffice — **lands on `/admin`**, sees every console incl. RBAC | | `09120000021` | admin (`finance`) | کامران مالی (male) | scoped backoffice — lands on `/admin`, sidebar shows only the money consoles (`useAdminCapabilities` gating) | -| `admin` / `qw123321` | admin | reference super-admin (username+password) | not a phone-OTP login — the frontend uses the phone admins above | + +> The old username+password `admin`/`qw123321` account is **no longer auto-seeded** (refinement-phase-5 — +> no committed credential). To bootstrap a break-glass username+password admin, set both secrets before boot, +> then log in via the API (not the web UI, which is phone-OTP only): +> ```bash +> cd server/src/API/Baya.Web.Api +> dotnet user-secrets set "Seed:AdminUsername" "admin" +> dotnet user-secrets set "Seed:AdminPassword" "" +> ``` The **phone-OTP admins** (`09120000020` / `09120000021`, refinement-phase-2) are how you reach the `/admin` console through the same web login flow as everyone else — admin sub-roles are server-granted, never @@ -148,8 +158,17 @@ Prove search works without the frontend: open Swagger → ## Good to know -- **The API speaks HTTP/2** (Kestrel `Protocols: Http2`, for gRPC). Browsers negotiate h2-over-TLS - automatically, so `fetch` just works; for `curl` add `--http2`. +- **The API speaks HTTP/1.1 and HTTP/2** (Kestrel `Protocols: Http1AndHttp2`, refinement-phase-5 — the + previous HTTP/2-only default broke non-TLS HTTP/1.1 hops). Over TLS the client negotiates h2 via ALPN, so + gRPC and `fetch` both work; plain-HTTP hops fall back to HTTP/1.1. +- **Secrets fail fast.** On a fresh clone with no user-secrets the API refuses to start with + `Refusing to start: required secret configuration is missing…` — set the connection-string user-secret + (step 3) and boot again. Deployed environments must additionally supply real `IdentitySettings` JWE keys + and `Seams:FieldEncryption` keys (Development uses dev-only defaults from `appsettings.Development.json`). +- **Enable the secret-scan pre-commit hook** once per clone so a stray credential can't be committed: + `git config core.hooksPath .githooks` (see [`.githooks/README.md`](../../../.githooks/README.md)). +- **Behind a reverse proxy**, list its address in `ForwardedHeaders:KnownProxies` (or a CIDR in + `:KnownNetworks`) so the rate limiter partitions on the real client IP, not the proxy's. - **Only `auth` is real by default.** 21 of 22 client service domains default to an in-browser mock (`USE_*_MOCK = true`); the home, search, bookings, etc. are fake in-memory data until Refinement Phase 4. - **The DB self-migrates + self-seeds**, so pointing at an empty local instance is enough — including the @@ -183,6 +202,7 @@ world, wipe the volume (`docker compose down -v`) and boot again. | Symptom | Fix | | --- | --- | | Browser: `net::ERR_CERT_AUTHORITY_INVALID` on `:5002` | Run `dotnet dev-certs https --trust` (setup step 1). | +| API startup: `Refusing to start: required secret configuration is missing…` | The connection-string user-secret isn't set (or still the placeholder). Do setup step 3. | | API startup: `Login failed for user 'sa'` / connect timeout | DB not up or wrong password — check `docker compose ps` and that the user-secrets password matches `docker-compose.yml`. | | Console: `...has been blocked by CORS policy` | `UseCors` missing/mis-ordered, or the browser origin isn't in `Cors:AllowedOrigins`. It must sit after `UseRouting` and before the rate limiter. | | `dotnet user-secrets` errors with "could not find UserSecretsId" | Run it from `server/src/API/Baya.Web.Api` (the project with ``). | diff --git a/server/CLAUDE.md b/server/CLAUDE.md index eab193f..0cff5ba 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -56,9 +56,14 @@ You are a **senior .NET software engineer** working on this codebase. That means | Update DB | `dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` | **Default URL:** `https://localhost:5002` — Swagger at `/swagger`. -On boot, `Program.cs` calls `ApplyMigrationsAsync()`, `SeedDefaultUsersAsync()`, `SeedPaymentGatewaysAsync()` -— and, **only in Development**, `SeedDemoWorldAsync()` (the demo marketplace seeder, see Persistence below). -A reachable SQL Server is required to start. +On boot (non-Testing), `Program.cs` calls `ApplyMigrationsAsync()` + `SeedDefaultUsersAsync()` (roles always; +a bootstrap admin **only if `Seed:AdminUsername`/`Seed:AdminPassword` are configured** — never a committed +credential), and **only in Development** `SeedPaymentGatewaysAsync()` (the sandbox gateway) + `SeedDemoWorldAsync()` +(the demo marketplace, see Persistence below). A reachable SQL Server is required to start. Startup **fails fast** +(`StartupSecretsGuard`) if a load-bearing secret — the DB connection strings, and in deployed environments the +JWE + field-encryption keys — is missing or left at its committed `SET_VIA_USER_SECRETS_OR_ENV` placeholder +(refinement-phase-5). Development supplies working dev-only crypto keys via `appsettings.Development.json`; only +the connection string must come from user-secrets (see [RUNBOOK](../dev/post-phase/refinement/RUNBOOK.md)). --- @@ -510,25 +515,29 @@ only canonical if it stays accurate. Service registration is composed from per-layer extension methods (each project's `ServiceConfiguration/`): ``` +builder.ValidateRequiredSecrets() // refinement-phase-5: fail fast on missing/placeholder DB + crypto secrets ConfigureHealthChecks() · SetupOpenTelemetry() AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate) -RegisterIdentityServices(...) // Identity, JWT/JWE, authorization policies, ICurrentUser + IHttpContextAccessor +RegisterIdentityServices(…, requireHttpsMetadata) // Identity, JWT/JWE (RequireHttpsMetadata on outside Dev/Testing), ICurrentUser AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories AddCrossCuttingSeams(config) // IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher (mocks) AddWebFrameworkServices() // API versioning + snake_case routing AddCorsPolicies(config) // browser CORS policy from Cors:AllowedOrigins (refinement-phase-0; default http://localhost:3000 in Dev) -AddRateLimitingPolicies() // built-in rate limiter: per-IP global + named (otp/auth/sensitive) +AddForwardedHeadersConfiguration(config) // refinement-phase-5: trust ForwardedHeaders:KnownProxies/KnownNetworks so the rate limiter sees the real client IP behind a proxy +AddRateLimitingPolicies() // built-in rate limiter: per-resolved-IP global + named (otp/auth/sensitive/webhook) AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster() ConfigureGrpcPluginServices() // Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates ISmsSender to capture each // OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development. ``` -Pipeline order: exception handler → Swagger → routing → **CORS → rate limiter → authentication → -authorization** → controllers → metrics → health checks → gRPC. `UseCors(...)` (refinement-phase-0) sits -**after `UseRouting()` and before `UseRateLimiter()`** so a pre-flight `OPTIONS` is answered before the -limiter/auth run; `UseRateLimiter()` is placed **before** `UseAuthentication()` so over-limit auth/OTP -attempts are rejected (`429`) before hitting the auth stack. +Pipeline order: **forwarded headers** → exception handler → Swagger → routing → **CORS → rate limiter → +authentication → authorization** → controllers → metrics → health checks → gRPC. `UseForwardedHeaders()` +(refinement-phase-5) is **first** so the resolved client IP (`X-Forwarded-For` from a trusted proxy) is in +place before the rate limiter partitions on it. `UseCors(...)` (refinement-phase-0) sits **after +`UseRouting()` and before `UseRateLimiter()`** so a pre-flight `OPTIONS` is answered before the limiter/auth +run; `UseRateLimiter()` is placed **before** `UseAuthentication()` so over-limit auth/OTP attempts are +rejected (`429`) before hitting the auth stack. When adding new infrastructure, expose it as an extension method and call it from `Program.cs` — never inline registrations there directly. @@ -606,18 +615,26 @@ action to `sender.Send(...)`. Full conventions are in [CONVENTIONS.md](CONVENTIO process-wide singleton because EF caches the model). Equality lookups go through the deterministic `PhoneHash` column (UNIQUE, synced on SaveChanges — which also resets `ShahkarVerifiedAt` when the phone actually changes). Never query `PhoneNumber == x`. -- **Roles:** full vocabulary in `Domain/Entities/User/RoleNames` (seeded by `SeedDataBase`). - `customer`/`nurse` are self-selectable via `POST me/select_role` (audited - `granted_by`/`granted_at`, idempotent, both can be held); admin sub-roles are internal-only and - return 403 there. `user_roles.revoked_at` has a global query filter, so revoked grants disappear - from every role read automatically. Auth knobs (`auth_otp_resend_seconds`, `auth_otp_max_attempts`, +- **Roles:** full vocabulary in `Domain/Entities/User/RoleNames`; `SeedDataBase` always seeds the roles, + and seeds a **bootstrap admin only when `Seed:AdminUsername`/`Seed:AdminPassword` are configured** + (refinement-phase-5 — no more committed `admin`/`qw123321`; break-glass only, day-to-day admins come from + the phone-OTP demo seeds or are provisioned out-of-band). `customer`/`nurse` are self-selectable via + `POST me/select_role` (audited `granted_by`/`granted_at`, idempotent, both can be held); admin sub-roles are + internal-only and return 403 there. `user_roles.revoked_at` has a global query filter, so revoked grants + disappear from every role read automatically. Auth knobs (`auth_otp_resend_seconds`, `auth_otp_max_attempts`, `auth_session_ttl_days`) are `platform_configs` rows read via `IPlatformConfig`. - Dynamic permission system: `DynamicPermissionHandler` reads `[controller]` + `[action]` route values and checks role claims. Always use `[controller]`/`[action]` tokens so the keys stay consistent (see CONVENTIONS.md §1 Routing). -- Settings bound from `appsettings.json` → `IdentitySettings`. +- Settings bound from `appsettings.json` → `IdentitySettings`. **JWE keys are never committed**: the + committed values are `SET_VIA_USER_SECRETS_OR_ENV` placeholders (real ones via user-secrets/env; Development + uses dev-only keys in `appsettings.Development.json`). `RequireHttpsMetadata` is **on outside Dev/Testing** + (passed into `RegisterIdentityServices`), the access-token lifetime is `ExpirationMinutes: 60`, and + `Issuer`/`Audience` are real (`Balinyaar`/`BalinyaarClient`) — refinement-phase-5. - Auth and OTP endpoints must be rate-limited (CONVENTIONS.md §11) — `request_otp`/`verify_otp` use - the `otp` policy, `refresh` the `auth` policy; plus a per-phone resend window via `ICacheService`. + the `otp` policy, `refresh` the `auth` policy; plus a per-phone resend window via `ICacheService`. The two + PSP/BNPL webhooks share the single deliberate **`webhook`** policy (bursty-tolerant, partitioned per-provider); + behind a reverse proxy the limiter partitions on the forwarded client IP (see Startup wiring). --- diff --git a/server/src/API/Baya.Web.Api/Configuration/StartupSecretsGuard.cs b/server/src/API/Baya.Web.Api/Configuration/StartupSecretsGuard.cs new file mode 100644 index 0000000..a1b727e --- /dev/null +++ b/server/src/API/Baya.Web.Api/Configuration/StartupSecretsGuard.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace Baya.Web.Api.Configuration; + +/// +/// Fail-fast validation that no load-bearing secret is missing or left at its committed placeholder. +/// A database connection is required in every real environment; the JWE + field-encryption keys are +/// required only in deployed environments (Development keeps working dev-only defaults in +/// appsettings.Development.json, and the "Testing" environment runs on in-memory SQLite with +/// test-injected keys). The effect: a fresh clone with no user-secrets stops at boot with a clear +/// message instead of silently connecting somewhere unintended, and a deployment can never fall back +/// to a committed placeholder key. +/// +public static class StartupSecretsGuard +{ + // Substrings that mark a value as a committed placeholder, never a real secret. Any configured value + // containing one of these is treated as "not provided". + private static readonly string[] PlaceholderMarkers = + [ + "SET_VIA_USER_SECRETS_OR_ENV", + "not-for-production", + "change-me", + "ShouldBe-LongerThan-16Char-SecretKey", + "16CharEncryptKey" + ]; + + public static void ValidateRequiredSecrets(this WebApplicationBuilder builder) + { + // Integration tests boot as "Testing" over in-memory SQLite and inject their own crypto keys. + if (builder.Environment.IsEnvironment("Testing")) + return; + + var config = builder.Configuration; + var errors = new List(); + + RequireReal(errors, "ConnectionStrings:SqlServer", config.GetConnectionString("SqlServer")); + RequireReal(errors, "ConnectionStrings:logDb", config.GetConnectionString("logDb")); + + // Development supplies working dev-only keys via appsettings.Development.json; only deployed + // environments must inject real per-environment secrets (env vars / Key Vault / KMS). + if (!builder.Environment.IsDevelopment()) + { + RequireReal(errors, "IdentitySettings:SecretKey", config["IdentitySettings:SecretKey"]); + RequireReal(errors, "IdentitySettings:Encryptkey", config["IdentitySettings:Encryptkey"]); + RequireReal(errors, "Seams:FieldEncryption:Key", config["Seams:FieldEncryption:Key"]); + RequireReal(errors, "Seams:FieldEncryption:HashKey", config["Seams:FieldEncryption:HashKey"]); + } + + if (errors.Count == 0) + return; + + throw new InvalidOperationException( + "Refusing to start: required secret configuration is missing or still a committed placeholder. " + + "Provide real values via user-secrets (Development) or environment variables (deployed) — see " + + "dev/post-phase/refinement/RUNBOOK.md.\n - " + string.Join("\n - ", errors)); + } + + private static void RequireReal(List errors, string key, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + errors.Add($"{key} is not set."); + else if (PlaceholderMarkers.Any(marker => value.Contains(marker, StringComparison.OrdinalIgnoreCase))) + errors.Add($"{key} is still a committed placeholder."); + } +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/WebhooksBnplController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/WebhooksBnplController.cs index d4ac0bc..4629f25 100644 --- a/server/src/API/Baya.Web.Api/Controllers/V1/WebhooksBnplController.cs +++ b/server/src/API/Baya.Web.Api/Controllers/V1/WebhooksBnplController.cs @@ -25,7 +25,7 @@ namespace Baya.Web.Api.Controllers.V1; [ApiController] [Route("api/v{version:apiVersion}/webhooks_bnpl")] [AllowAnonymous] -[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)] +[EnableRateLimiting(RateLimitingServiceExtension.WebhookPolicy)] [Display(Description = "BNPL provider callbacks (signature-authenticated, idempotent)")] public sealed class WebhooksBnplController(ISender sender) : BaseController { diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/WebhooksController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/WebhooksController.cs index 824d703..6809a99 100644 --- a/server/src/API/Baya.Web.Api/Controllers/V1/WebhooksController.cs +++ b/server/src/API/Baya.Web.Api/Controllers/V1/WebhooksController.cs @@ -7,9 +7,11 @@ using Baya.Application.Features.Payments.Commands.HandlePaymentWebhook; using Baya.Application.Models.Payments; using Baya.WebFramework.Attributes; using Baya.WebFramework.BaseController; +using Baya.WebFramework.ServiceConfiguration; using Mediator; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; namespace Baya.Web.Api.Controllers.V1; @@ -23,6 +25,7 @@ namespace Baya.Web.Api.Controllers.V1; [ApiController] [Route("api/v{version:apiVersion}/webhooks")] [AllowAnonymous] +[EnableRateLimiting(RateLimitingServiceExtension.WebhookPolicy)] [Display(Description = "PSP/BNPL payment callbacks (signature-authenticated, idempotent)")] public sealed class WebhooksController(ISender sender) : BaseController { diff --git a/server/src/API/Baya.Web.Api/Program.cs b/server/src/API/Baya.Web.Api/Program.cs index affa643..d8338a2 100644 --- a/server/src/API/Baya.Web.Api/Program.cs +++ b/server/src/API/Baya.Web.Api/Program.cs @@ -12,6 +12,7 @@ using Baya.Infrastructure.Identity.ServiceConfiguration; using Baya.Infrastructure.Monitoring.Configurations; using Baya.Infrastructure.Persistence.ServiceConfiguration; using Baya.SharedKernel.Extensions; +using Baya.Web.Api.Configuration; using Baya.Web.Plugins.Grpc; using Baya.WebFramework.Filters; using Baya.WebFramework.Middlewares; @@ -29,8 +30,16 @@ builder.Host.UseSerilog(LoggingConfiguration.ConfigureLogger); var configuration = builder.Configuration; +// Fail fast if a load-bearing secret (DB connection, JWE/field-encryption keys) is missing or still a +// committed placeholder — before any service reaches for it. Skipped in the "Testing" environment. +builder.ValidateRequiredSecrets(); + Activity.DefaultIdFormat = ActivityIdFormat.W3C; +// HTTPS metadata is required for the token exchange in deployed environments; relaxed for local +// Development and the Testing host, which run over plain HTTP. +var requireHttpsMetadata = !builder.Environment.IsDevelopment() && !builder.Environment.IsEnvironment("Testing"); + builder .ConfigureHealthChecks() .SetupOpenTelemetry(); @@ -68,11 +77,12 @@ builder.Services.AddSwagger("v1","v1.1"); builder.Services.AddApplicationServices() - .RegisterIdentityServices(identitySettings) + .RegisterIdentityServices(identitySettings, requireHttpsMetadata) .AddPersistenceServices(configuration) .AddCrossCuttingSeams(configuration) .AddWebFrameworkServices() .AddCorsPolicies(configuration) + .AddForwardedHeadersConfiguration(configuration) .AddRateLimitingPolicies(); // Development-only: capture each OTP in-memory so GET /api/v1/dev/last_otp/{phone} can complete a login @@ -106,12 +116,16 @@ if (!app.Environment.IsEnvironment("Testing")) { await app.ApplyMigrationsAsync(); await app.SeedDefaultUsersAsync(); - await app.SeedPaymentGatewaysAsync(); - // Development-only: populate a demo marketplace (nurses/variants/search rows, customers/patients) - // so the real-path screens aren't empty. Idempotent; never runs in Production/Staging. + // Development-only: a sandbox payment gateway (all-zeros merchant id) and a demo marketplace + // (nurses/variants/search rows, customers/patients) so the real-path screens aren't empty. Neither + // belongs in a deployed DB — a production gateway is an admin action, so this never runs in + // Production/Staging. Both are idempotent. if (app.Environment.IsDevelopment()) + { + await app.SeedPaymentGatewaysAsync(); await app.SeedDemoWorldAsync(); + } } if (app.Environment.IsDevelopment()) @@ -121,6 +135,10 @@ if (app.Environment.IsDevelopment()) else app.UseExceptionHandler(_=>{}); +// First in the pipeline so the resolved client IP (X-Forwarded-For, from a trusted proxy) is in place +// before anything downstream — notably the rate limiter — reads HttpContext.Connection.RemoteIpAddress. +app.UseForwardedHeaders(); + app.UseSwaggerAndUi(); app.UseRouting(); diff --git a/server/src/API/Baya.Web.Api/appsettings.Development.json b/server/src/API/Baya.Web.Api/appsettings.Development.json index 1c4331b..0977c9e 100644 --- a/server/src/API/Baya.Web.Api/appsettings.Development.json +++ b/server/src/API/Baya.Web.Api/appsettings.Development.json @@ -1,37 +1,15 @@ { - "ConnectionStrings": { - "SqlServer": "Server=localhost,1433;Database=Baya;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;", - "logDb": "Server=localhost,1433;Database=Baya_Logs;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;" - }, "IdentitySettings": { - "SecretKey": "ShouldBe-LongerThan-16Char-SecretKey", - "Encryptkey": "16CharEncryptKey", - "Issuer": "MyWebsite", - "Audience": "MyWebsite", - "NotBeforeMinutes": "0", - "ExpirationMinutes": "10000" + "SecretKey": "dev-only-jwe-signing-key-not-for-production-0123456789abcdef", + "Encryptkey": "dev-only-16bytes" }, "Seams": { "FieldEncryption": { - "Key": "local-dev-field-encryption-key-change-me", - "HashKey": "local-dev-field-hash-key-change-me" - }, - "ObjectStorage": { - "RootPath": "" - }, - "Geocoding": { - "ReturnNullCoordinates": false, - "LowConfidenceMarker": "NO_GEO", - "ResolvedConfidence": 0.9 + "Key": "local-dev-field-encryption-key-not-for-production", + "HashKey": "local-dev-field-hash-key-not-for-production" } }, "Cors": { "AllowedOrigins": [ "http://localhost:3000" ] - }, - "AllowedHosts": "*", - "Kestrel": { - "EndpointDefaults": { - "Protocols": "Http2" - } } } diff --git a/server/src/API/Baya.Web.Api/appsettings.json b/server/src/API/Baya.Web.Api/appsettings.json index 179337f..2941ee5 100644 --- a/server/src/API/Baya.Web.Api/appsettings.json +++ b/server/src/API/Baya.Web.Api/appsettings.json @@ -4,17 +4,17 @@ "logDb": "Server=localhost,1433;Database=Baya_Logs;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;" }, "IdentitySettings": { - "SecretKey": "ShouldBe-LongerThan-16Char-SecretKey", - "Encryptkey": "16CharEncryptKey", - "Issuer": "MyWebsite", - "Audience": "MyWebsite", + "SecretKey": "SET_VIA_USER_SECRETS_OR_ENV", + "Encryptkey": "SET_VIA_USER_SECRETS_OR_ENV", + "Issuer": "Balinyaar", + "Audience": "BalinyaarClient", "NotBeforeMinutes": "0", - "ExpirationMinutes": "10000" + "ExpirationMinutes": "60" }, "Seams": { "FieldEncryption": { - "Key": "local-dev-field-encryption-key-change-me", - "HashKey": "local-dev-field-hash-key-change-me" + "Key": "SET_VIA_USER_SECRETS_OR_ENV", + "HashKey": "SET_VIA_USER_SECRETS_OR_ENV" }, "ObjectStorage": { "RootPath": "" @@ -28,10 +28,14 @@ "Cors": { "AllowedOrigins": [] }, + "ForwardedHeaders": { + "KnownProxies": [], + "KnownNetworks": [] + }, "AllowedHosts": "*", "Kestrel": { "EndpointDefaults": { - "Protocols": "Http2" + "Protocols": "Http1AndHttp2" } } } diff --git a/server/src/API/Baya.WebFramework/ServiceConfiguration/ForwardedHeadersServiceExtension.cs b/server/src/API/Baya.WebFramework/ServiceConfiguration/ForwardedHeadersServiceExtension.cs new file mode 100644 index 0000000..2353df1 --- /dev/null +++ b/server/src/API/Baya.WebFramework/ServiceConfiguration/ForwardedHeadersServiceExtension.cs @@ -0,0 +1,47 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Baya.WebFramework.ServiceConfiguration; + +public static class ForwardedHeadersServiceExtension +{ + /// Configuration section holding the trusted reverse-proxy addresses/networks. + public const string ConfigSection = "ForwardedHeaders"; + + /// + /// Configures the forwarded-headers middleware so that, behind a reverse proxy, the resolved client + /// address (from X-Forwarded-For) — not the proxy's address — is what + /// HttpContext.Connection.RemoteIpAddress reports. The rate limiter partitions on that address, + /// so without this every client behind the proxy shares a single bucket (self-DoS). Only proxies listed + /// in ForwardedHeaders:KnownProxies / :KnownNetworks (plus loopback) are trusted; an + /// untrusted hop's forwarded header is ignored, so a client can't spoof its address. Pair with + /// app.UseForwardedHeaders() placed first in the pipeline (before anything reads the client IP). + /// + public static IServiceCollection AddForwardedHeadersConfiguration(this IServiceCollection services, IConfiguration configuration) + { + var knownProxies = configuration.GetSection($"{ConfigSection}:KnownProxies").Get() ?? []; + var knownNetworks = configuration.GetSection($"{ConfigSection}:KnownNetworks").Get() ?? []; + + services.Configure(options => + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + + // The framework defaults trust only loopback; extend that to the deployment's real proxy hop(s). + foreach (var proxy in knownProxies) + if (IPAddress.TryParse(proxy, out var address)) + options.KnownProxies.Add(address); + + foreach (var network in knownNetworks) + { + var parts = network.Split('/', 2); + if (parts.Length == 2 && IPAddress.TryParse(parts[0], out var prefix) && int.TryParse(parts[1], out var prefixLength)) + options.KnownIPNetworks.Add(new System.Net.IPNetwork(prefix, prefixLength)); + } + }); + + return services; + } +} diff --git a/server/src/API/Baya.WebFramework/ServiceConfiguration/RateLimitingServiceExtension.cs b/server/src/API/Baya.WebFramework/ServiceConfiguration/RateLimitingServiceExtension.cs index f64a52d..838864c 100644 --- a/server/src/API/Baya.WebFramework/ServiceConfiguration/RateLimitingServiceExtension.cs +++ b/server/src/API/Baya.WebFramework/ServiceConfiguration/RateLimitingServiceExtension.cs @@ -20,6 +20,13 @@ public static class RateLimitingServiceExtension /// Limit for money-sensitive actions (refund/payout) applied in later phases. public const string SensitivePolicy = "sensitive"; + /// + /// The single deliberate policy for inbound PSP/BNPL webhooks. PSP callbacks are bursty (retries, + /// batched settlements), so this is more permissive than and partitions + /// per-provider (not just per-IP) so one provider's burst can't starve another. + /// + public const string WebhookPolicy = "webhook"; + /// /// Registers the built-in rate limiter with a per-IP global limit plus named policies that /// auth/OTP/sensitive endpoints opt into via [EnableRateLimiting(name)]. Over-limit @@ -46,6 +53,9 @@ public static class RateLimitingServiceExtension AddFixedWindowPolicy(options, AuthPolicy, permitLimit: 10, windowSeconds: 60); AddFixedWindowPolicy(options, SensitivePolicy, permitLimit: 20, windowSeconds: 60); + // Bursty PSP/BNPL callbacks, partitioned per-provider (see WebhookPartitionKey). + AddFixedWindowPolicy(options, WebhookPolicy, permitLimit: 120, windowSeconds: 60, keyResolver: WebhookPartitionKey); + // A deliberately tiny policy used by the phase-0 ping endpoint to demonstrate 429s. AddFixedWindowPolicy(options, GlobalPolicy, permitLimit: 5, windowSeconds: 10); }); @@ -53,11 +63,13 @@ public static class RateLimitingServiceExtension return services; } - private static void AddFixedWindowPolicy(RateLimiterOptions options, string name, int permitLimit, int windowSeconds) + private static void AddFixedWindowPolicy(RateLimiterOptions options, string name, int permitLimit, int windowSeconds, + Func? keyResolver = null) { + var resolvePartition = keyResolver ?? PartitionKey; options.AddPolicy(name, context => RateLimitPartition.GetFixedWindowLimiter( - PartitionKey(context), + resolvePartition(context), _ => new FixedWindowRateLimiterOptions { PermitLimit = permitLimit, @@ -66,6 +78,16 @@ public static class RateLimitingServiceExtension })); } + // The client IP as resolved by the forwarded-headers middleware (see ForwardedHeadersServiceExtension) — + // behind a trusted proxy this is the real client, not the proxy, so each client gets its own bucket. private static string PartitionKey(HttpContext context) => context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + + // Per-provider partition so a single PSP's burst can't exhaust the shared webhook budget; still bounded + // by the resolved client IP so a spoofed provider segment can't fan out unboundedly. + private static string WebhookPartitionKey(HttpContext context) + { + var provider = context.Request.RouteValues.TryGetValue("provider", out var value) ? value?.ToString() : null; + return $"webhook:{(string.IsNullOrWhiteSpace(provider) ? "unknown" : provider)}:{PartitionKey(context)}"; + } } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs b/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs index 558168b..71d66d0 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs @@ -1,6 +1,7 @@ using Baya.Domain.Entities.User; using Baya.Infrastructure.Identity.Identity.Manager; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; namespace Baya.Infrastructure.Identity.Identity.SeedDatabaseService; @@ -13,11 +14,13 @@ public class SeedDataBase : ISeedDataBase { private readonly AppUserManager _userManager; private readonly AppRoleManager _roleManager; + private readonly IConfiguration _configuration; - public SeedDataBase(AppUserManager userManager, AppRoleManager roleManager) + public SeedDataBase(AppUserManager userManager, AppRoleManager roleManager, IConfiguration configuration) { _userManager = userManager; _roleManager = roleManager; + _configuration = configuration; } public async Task Seed() @@ -35,18 +38,35 @@ public class SeedDataBase : ISeedDataBase } } - if (!_userManager.Users.AsNoTracking().Any(u => u.UserName.Equals("admin"))) - { - var user = new User - { - UserName = "admin", - Email = "admin@site.com", - PhoneNumberConfirmed = true, - IsActive = true - }; + await SeedBootstrapAdminAsync(); + } - await _userManager.CreateAsync(user, "qw123321"); - await _userManager.AddToRoleAsync(user,"admin"); - } + // The bootstrap admin is config-driven, never a committed credential: it is created only when both + // Seed:AdminUsername and Seed:AdminPassword are supplied (via user-secrets in Development, environment + // variables in a deployment). With neither configured — the default for Testing and any fresh boot — + // no admin account is created, so no well-known password ever lands in a real database. Day-to-day + // admins reach the backoffice through the phone-OTP demo seeds (Development) or are provisioned + // out-of-band; this account is a break-glass bootstrap only. + private async Task SeedBootstrapAdminAsync() + { + var username = _configuration["Seed:AdminUsername"]; + var password = _configuration["Seed:AdminPassword"]; + + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + return; + + if (_userManager.Users.AsNoTracking().Any(u => u.UserName.Equals(username))) + return; + + var user = new User + { + UserName = username, + Email = _configuration["Seed:AdminEmail"] ?? "admin@balinyaar.local", + PhoneNumberConfirmed = true, + IsActive = true + }; + + await _userManager.CreateAsync(user, password); + await _userManager.AddToRoleAsync(user, "admin"); } } \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Identity/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Infrastructure/Baya.Infrastructure.Identity/ServiceConfiguration/ServiceCollectionExtension.cs index eaadce3..20492d2 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Identity/ServiceConfiguration/ServiceCollectionExtension.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Identity/ServiceConfiguration/ServiceCollectionExtension.cs @@ -29,7 +29,7 @@ namespace Baya.Infrastructure.Identity.ServiceConfiguration; public static class ServiceCollectionExtension { - public static IServiceCollection RegisterIdentityServices(this IServiceCollection services,IdentitySettings identitySettings) + public static IServiceCollection RegisterIdentityServices(this IServiceCollection services,IdentitySettings identitySettings, bool requireHttpsMetadata) { services.AddHttpContextAccessor(); services.AddScoped(); @@ -136,7 +136,9 @@ public static class ServiceCollectionExtension }; - options.RequireHttpsMetadata = false; + // HTTPS is required for the token/metadata exchange in deployed environments; relaxed only for + // local Development / the Testing host, which run over plain HTTP. + options.RequireHttpsMetadata = requireHttpsMetadata; options.SaveToken = true; options.TokenValidationParameters = validationParameters; options.Events = new JwtBearerEvents diff --git a/server/src/Tests/Baya.Test.Api/BayaApiFactory.cs b/server/src/Tests/Baya.Test.Api/BayaApiFactory.cs index 98564c9..8de1196 100644 --- a/server/src/Tests/Baya.Test.Api/BayaApiFactory.cs +++ b/server/src/Tests/Baya.Test.Api/BayaApiFactory.cs @@ -33,6 +33,14 @@ public sealed class BayaApiFactory : WebApplicationFactory { _keepAlive = new SqliteConnection(_connectionString); _keepAlive.Open(); + + // The committed appsettings.json ships placeholder JWE keys (real ones come from user-secrets / + // env in Development / deploy). The Testing host has neither, so supply working test keys via + // environment variables — they sit after appsettings.json in the default config chain, so they + // reliably override the placeholders. The Encrypt key must be exactly 16 bytes for the AES-128 + // JWE; field-encryption keeps the mock seam's placeholder (it derives a key from any string). + Environment.SetEnvironmentVariable("IdentitySettings__SecretKey", "testing-only-jwe-signing-key-0123456789abcdef"); + Environment.SetEnvironmentVariable("IdentitySettings__Encryptkey", "testing-16-bytes"); } protected override void ConfigureWebHost(IWebHostBuilder builder) diff --git a/server/src/Tests/Baya.Test.Api/StartupSecretsGuardTests.cs b/server/src/Tests/Baya.Test.Api/StartupSecretsGuardTests.cs new file mode 100644 index 0000000..633702b --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/StartupSecretsGuardTests.cs @@ -0,0 +1,67 @@ +using Baya.Web.Api.Configuration; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; + +namespace Baya.Test.Api; + +/// +/// Refinement Phase 5 — proves the fail-fast secret guard: a deployed environment left on committed +/// placeholders refuses to boot with a clear message, and boots once real values are supplied. (The +/// integration host runs as "Testing", where the guard is intentionally skipped, so this exercises it +/// directly.) +/// +public class StartupSecretsGuardTests +{ + private static WebApplicationBuilder ProductionBuilder(Dictionary settings) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Production" }); + builder.Configuration.AddInMemoryCollection(settings); + return builder; + } + + private static Dictionary RealSecrets() => new() + { + ["ConnectionStrings:SqlServer"] = "Server=db;Database=Baya;User Id=app;Password=real-value;", + ["ConnectionStrings:logDb"] = "Server=db;Database=Baya_Logs;User Id=app;Password=real-value;", + ["IdentitySettings:SecretKey"] = "a-real-production-jwe-signing-key-0123456789", + ["IdentitySettings:Encryptkey"] = "real-16-byte-key", + ["Seams:FieldEncryption:Key"] = "a-real-production-field-key", + ["Seams:FieldEncryption:HashKey"] = "a-real-production-hash-key" + }; + + [Fact] + public void PlaceholderConnectionString_InDeployedEnvironment_RefusesToStart() + { + var settings = RealSecrets(); + settings["ConnectionStrings:SqlServer"] = "Server=localhost;Password=SET_VIA_USER_SECRETS_OR_ENV;"; + + var ex = Assert.Throws(() => ProductionBuilder(settings).ValidateRequiredSecrets()); + Assert.Contains("ConnectionStrings:SqlServer", ex.Message); + } + + [Fact] + public void PlaceholderJweKey_InDeployedEnvironment_RefusesToStart() + { + var settings = RealSecrets(); + settings["IdentitySettings:SecretKey"] = "SET_VIA_USER_SECRETS_OR_ENV"; + + var ex = Assert.Throws(() => ProductionBuilder(settings).ValidateRequiredSecrets()); + Assert.Contains("IdentitySettings:SecretKey", ex.Message); + } + + [Fact] + public void DevOnlyKeyLeakedToDeployedEnvironment_RefusesToStart() + { + var settings = RealSecrets(); + settings["Seams:FieldEncryption:Key"] = "local-dev-field-encryption-key-not-for-production"; + + Assert.Throws(() => ProductionBuilder(settings).ValidateRequiredSecrets()); + } + + [Fact] + public void RealSecrets_InDeployedEnvironment_StartUpSucceeds() + { + var exception = Record.Exception(() => ProductionBuilder(RealSecrets()).ValidateRequiredSecrets()); + Assert.Null(exception); + } +}