Skip to content

Security: JakubIwicki/JjChat

Security

SECURITY.md

Security

This document records accepted risks and their mitigations. It is not a marketing page or a compliance checklist — it exists so that choices visible in the source are also visible to anyone deploying the project.

Reporting a vulnerability

This is a portfolio/demo project with no production deployment and no SLA. If you discover a security issue, please open a private GitHub security advisory on this repository. The accepted risks below are deliberate and documented rather than oversights — each entry explains why the risk was accepted and what mitigation is in place.


Accepted risks

1. User enumeration on registration (§2.4)

POST /v1/connect/register returns 409 "Email is already registered." when the email is already taken (RegisterHandler.cs:40). An attacker can enumerate registered emails.

Why accepted. The project has no mail infrastructure, so the standard mitigation — always return 201 and send a "you already have an account" email — is not available.

Mitigation in place. The endpoint is rate-limited to 5 requests per 10 minutes per client IP (RegisterRateLimitPolicy in api.JjChat.Identity, configured at RateLimiting:RegisterPermitsPerTenMinutes in appsettings.json:4). While this does not prevent enumeration, it imposes a meaningful floor on the speed of any attack.

2. Resource Owner Password Credentials grant (§2.6)

The SPA authenticates with the password grant (grant_type=password in HttpAuthService.ts:20). ROPC is removed in OAuth 2.1 and discouraged for public clients.

Why accepted. Both the client (jjchat-spa) and the identity provider (api.JjChat.Identity) are first-party. There are no third-party clients.

Migration path. Authorization Code + PKCE is the modern equivalent and OpenIddict supports it directly. The OpenIddict configuration already enables refresh_token alongside password (appsettings.json:25), so the grant surface will not expand when PKCE is added.

Mitigation. POST /connect/token is rate-limited to 10 requests per minute per client IP (TokenRateLimitPolicy, configured at RateLimiting:TokenPermitsPerMinute in appsettings.json:3). The token endpoint is timing-safe: when no user matches the supplied email, HandlePasswordGrant still runs a dummy PBKDF2 verify to prevent response-time based enumeration (TokenEndpoint.cs:58–61).

3. Tokens in JavaScript-readable storage (§2.7)

The access token is stored in a cookie named jjchat.token set from JavaScript (TokenStore.ts:14,27–28). The code acknowledges the trade-off directly:

"NOTE: a JS-readable cookie has the same XSS exposure as localStorage. A backend-set httpOnly cookie is the more secure future option." — TokenStore.ts:14–15

The cookie carries SameSite=Strict and conditional Secure (only when location.protocol is https:) (TokenStore.ts:16–17). No XSS sinks (dangerouslySetInnerHTML, innerHTML) exist anywhere in the frontend (verified by grep at the time of review). This is accepted as-is; the migration path to a backend-set httpOnly cookie is documented in the code.

4. Generated secrets

No secret material is tracked in this repository. .env and docker/certs/ are gitignored and produced per checkout by the certificate generator (docker/certs/generate.sh), which the launcher runs automatically on first invocation. Every clone therefore gets different database, broker, and token-signing credentials — the "every clone shares the same credentials" problem does not exist.

The generator produces:

  • A demo CA (ca.crt / ca.key) constrained to localhost and web DNS names with a 90-day validity.
  • A gateway TLS certificate signed by that CA.
  • Password-less identity signing and encryption PKCS#12 files, base64-encoded into docker/certs/out.env.
  • Fresh randomised POSTGRES_PASSWORD, POSTGRES_IDENTITY_PASSWORD, POSTGRES_PROMPTS_PASSWORD, and RABBITMQ_PASS, also written to docker/certs/out.env.

The launcher merges out.env into .env, filling only empty values so an operator-supplied DEEPSEEK_API_KEY is never overwritten.

The demo CA private key is generated per checkout — its blast radius is analysed separately in §6 — Demo CA Certificate Authority below.

Rotation procedure. To rotate all generated secrets:

  1. Revoke the DeepSeek API key at the provider (platform.deepseek.com → API Keys → revoke) before or immediately after replacing it in .env — if you have ever set a real key. Changing .env alone leaves the old key live and spendable.
  2. Delete .env and docker/certs/.
  3. Re-run the launcher (./run.sh or .\run.ps1), which regenerates everything.
  4. Postgres password rotation requires destroying the named volume. Postgres initialises the database only on first run (docker-entrypoint-initdb.d). Because the compose file defines a named volume (postgres_data), changing POSTGRES_PASSWORD and restarting the stack will not change the database password — initdb never re-runs, and the old credentials remain effective inside the running database. Either:
    • Run docker compose down -v before re-running the launcher (this deletes all persisted data).
    • Or connect to the running database and run ALTER USER manually.
  5. Regenerating the identity signing and encryption certificates invalidates every previously issued token. After rotation, all existing sessions are dead — users must re-authenticate.
  6. Remove the old demo CA from your trust store. If you previously imported ca.crt as a trusted root (browser, OS trust store, or update-ca-certificates), remove it. After rotation the old CA is no longer on disk but any machine that trusted it may still be vulnerable to certificates minted with the old ca.key.

The demo account is created per-run with a random email and password (see .shell/credentials.sh:7–10). It is never printed. Rotation of the demo account is therefore a non-operation — a new account is created on every ./run.sh invocation, and the old account's password is lost when the script exits. If you need persistent demo credentials, create a regular account through the SPA instead.

5. Self-signed gateway certificate (§2.9)

The nginx gateway terminates TLS with a self-signed certificate (docker/certs/gateway.crt). Traffic is encrypted (TLS 1.2 / 1.3), but the certificate is not issued by a public CA, so the browser cannot authenticate the server. The reviewer must click through an interstitial on first visit.

Why accepted. A CA-issued certificate for localhost is not possible (public CAs do not issue for local domains).

6. Demo CA Certificate Authority

The certificate (docker/certs/ca.crt) is a self-signed root CA generated per checkout by docker/certs/generate.sh with the following properties:

  • CA:TRUE — it can issue subordinate certificates.
  • pathlen:0 — it cannot sign intermediate CAs; it can only issue end-entity certificates.
  • nameConstraints — permitted names are limited to DNS localhost, DNS web, and IP 127.0.0.1/32. It cannot issue certificates for any other DNS name or IP address.
  • Validity: 90 days from generation.

What the CA can sign. With CA:TRUE, pathlen:0, and nameConstraints limiting permitted names to DNS localhost, DNS web, and IP 127.0.0.1/32, the CA can mint certificates only for those three names — and cannot sign intermediate CAs. This bounds what a compromised ca.key can impersonate to the project's own Docker services.

What trusts it. Two classes of machines trust this CA:

  • The jjchat-api container — Dockerfile.Api:17–18 installs ca.crt into the container's trust store so the Prompts API can fetch the OpenID discovery document from https://web:8443/ without disabling certificate validation.
  • Any reviewer machine that has imported ca.crt into its OS or browser trust store (e.g. via update-ca-certificates or a browser certificate manager).

Risk. Full MITM capability against TLS connections to localhost and web on any machine that has imported ca.crt. An attacker with ca.key can intercept, decrypt, and re-encrypt TLS traffic to those hostnames — the browser will show a valid lock icon with no warning. The nameConstraints and pathlen:0 extensions bound the blast radius to the project's own Docker services, but on a machine where the CA is trusted, traffic to localhost from any application (not just this project) is exposed.

Mitigation.

  • Do not import this CA certificate on a machine you use for general web browsing. The CA is constrained to localhost and web DNS names with pathlen:0 and 90-day validity, but a locally-trusted CA on a general-purpose machine is still a hazard — it can intercept TLS to any service the machine reaches at those hostnames. The run.sh smoke test passes --cacert to curl, trusting the CA for a single invocation without installing it system-wide.
  • When rotating, remove the old CA from your trust store before importing the new one (see rotation procedure step 6 in §4).

Identity seeding — idempotency by construction

api.JjChat.Identity seeds OpenIddict scopes and clients on startup (Startup.MigrateAndSeedAsync → SeedApiScopesAsync + SeedSpaClientAsync). The seeding uses a check-then-create pattern with a unique-violation backstop: if two instances boot concurrently, both pass the FindByNameAsync check, then one loses the CreateAsync race with a unique constraint violation. That violation is caught by matching PostgresException.SqlState == "23505" (never the message string) and treated as "another instance won the race — continue."

A pg_advisory_lock that previously serialised seeding was removed deliberately. Advisory locks couple the services to a single Postgres instance and block the startup path across all replicas while one instance holds the lock. The unique-violation-tolerant approach is lock-free, works with any number of replicas, and keeps the seeding logic self-contained within the Identity service.

Concurrency test. SeedConcurrencyTests.ConcurrentSeed_TwoInstancesCompete_OnlyOneScopeAndOneAppPersist runs the seed path twice concurrently against one database and asserts exactly one scope row and one application row afterwards. A DbCommandInterceptor with a 1‑second pause on non‑query commands guarantees both tasks reach CreateAsync before either commits — the test is not scheduling-dependent.


Trust boundary — forwarded headers

Both api.JjChat.Identity and api.JjChat.Prompts call UseForwardedHeaders with ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto and with KnownIPNetworks and KnownProxies both cleared (Identity/Startup.cs:128–130, Prompts/Startup.cs:164–166). Because the reverse proxy's IP is not knowable in Docker, there is no way to restrict which hosts the services trust for the X-Forwarded-For header.

Consequence. A client that can reach a service directly can set X-Forwarded-For to an arbitrary IP and evade IP-based rate limiting.

Deployment requirement. The api and identity containers must only be reachable through the nginx reverse proxy (web container) and must not share a network with untrusted clients. This is enforced by the compose topology: only the web service (ports 34443 and 34080) is published to the host. Postgres, RabbitMQ, the Prompts API (8080), and Identity (5001) are not published — they are reachable only over the internal Docker network. The trust-boundary constraint is a topology property, not a manual step.


Rate limiting summary

Service Endpoint Limit Window Partition key Configured in
Identity POST /connect/token 10 1 min Client IP RateLimiting:TokenPermitsPerMinute
Identity POST /v1/connect/register 5 10 min Client IP RateLimiting:RegisterPermitsPerTenMinutes
Prompts GET /v1/prompts, /v1/prompts/{id} 600 1 min Subject claim → IP RateLimiting:ReadPermitsPerMinute
Prompts POST / /retry / /cancel 120 1 min Subject claim → IP RateLimiting:WritePermitsPerMinute
Prompts GET /health/ready, GET /status 300 1 min Client IP RateLimiting:SystemPermitsPerMinute

The read limit is sized for the SPA's parallel per-page polling — the dashboard fetches one request per loaded page every polling interval, and the 600/min budget absorbs that fan-out across tabs and devices sharing a subject claim. /health is deliberately unlimited because it is the container liveness probe and must never return 429. The two dependency-touching probes (/health/ready and /status) carry an IP-partitioned limit so a public flood cannot starve the in-container probe, which arrives on loopback and therefore lands in its own partition.

Ordering detail in the Prompts service. The middleware pipeline runs authentication → rate limiter → authorization (Prompts/Startup.cs:242–244). The rate limiter sits between authentication and authorization so that unauthenticated requests still consume a permit (partitioned by IP address) and are throttled before authorization can reject them with 401. Authenticated requests are partitioned by subject claim as before.

In the Identity service the order is different: rate limiter → authentication → authorization. This is intentional — the /connect/* endpoints are unauthenticated by design (clients obtain tokens there) and must be rate-limited before anything else.


Production configuration

The stack runs as Production by default. Every service sets ASPNETCORE_ENVIRONMENT=Production (api + identity) or DOTNET_ENVIRONMENT=Production (worker). The compose file's environment blocks set all required values directly — there is no separate production compose file.

Environment variables

Every variable is enforced with ${VAR:?...} fail-fast syntax — the deploy aborts if any is missing. All are set in .env:

Variable Used by Notes
DEEPSEEK_API_KEY worker Live DeepSeek API key
POSTGRES_USER postgres Database superuser
POSTGRES_PASSWORD postgres Database superuser password
POSTGRES_DB postgres Database name
POSTGRES_IDENTITY_PASSWORD postgres, identity Identity database password
POSTGRES_PROMPTS_PASSWORD postgres, api Prompts database password
RABBITMQ_USER rabbitmq, api, worker Broker user
RABBITMQ_PASS rabbitmq, api, worker Broker password
IDENTITY_ISSUER api, identity https://web:8443/ — internal issuer URL
IDENTITY_PUBLIC_ORIGIN identity https://localhost:34443 — CORS origin
IDENTITY_SIGNING_CERTIFICATE identity Base64-encoded PKCS#12 (no password)
IDENTITY_ENCRYPTION_CERTIFICATE identity Base64-encoded PKCS#12 (no password)

There are no :- fallback defaults for any credential. All passwords in .env — including POSTGRES_PASSWORD and RABBITMQ_PASS — are generated per checkout by the certificate generator, so every clone has different credentials. The RabbitMQ credentials are no longer printed by the launch scripts and the management UI is no longer proxied through the published gateway. The demo user account is created per-run by the smoke test with a random email and password generated via .shell/credentials.sh:7–10 and is never printed.

Production defaults set in the compose file

These values are already configured on every service's environment block in docker-compose.yml — no overrides are needed:

  • Identity__CertificateSource=Configuration — OpenIddict reads certificates from the base64-encoded environment variables above.
  • Identity__DisableTransportSecurity=false — TLS is enforced. The nginx gateway sets X-Forwarded-Proto: https on every request, which the forwarded-headers middleware picks up, so OpenIddict sees Request.Scheme == "https" without disabling the check.
  • Features__SeedDevelopmentUser=false — no auto-seeded dev user.
  • Features__EnableSwagger=true — Swagger is deliberately ON in the local compose stack. The Swagger UI and the complete API schema are unauthenticated — anyone who can reach the gateway can inspect every endpoint and execute any operation through the "Try it out" console. The compensating controls are: (a) both gateway ports are bound to 127.0.0.1 (host-local only — see Published ports above), and (b) nginx declares server_name on every block with a return 444 catch-all for unknown Host headers, so requests routed by bare IP or by a guessed hostname are rejected before they reach a backend. Any deployment that publishes the gateway beyond 127.0.0.1 MUST set this flag back to false.

If you need more than one CORS origin, add additional Identity__AllowedCorsOrigins__1, __2, … entries to the identity service's environment block in docker-compose.yml — a single shell variable cannot express a list.

Container hardening

Service containers and their runtime users:

Container Base image User
postgres postgres:16 postgres (image default; no user: declared in compose)
rabbitmq rabbitmq:3-management root (image default; no user: declared in compose; the RabbitMQ process drops privileges to the rabbitmq user internally, but the container entrypoint runs as root)
identity mcr.microsoft.com/dotnet/aspnet:10.0 $APP_UID (Dockerfile.Identity:13)
api mcr.microsoft.com/dotnet/aspnet:10.0 $APP_UID (Dockerfile.Api:19)
worker mcr.microsoft.com/dotnet/runtime:10.0 $APP_UID (Dockerfile.Worker:10)
web nginxinc/nginx-unprivileged:alpine nginx (non-root, frontend/Dockerfile:11)

Published ports

Only the nginx gateway ports are published, bound to 127.0.0.1 (host-local only):

Host port Container port Purpose
34443 8443 (HTTPS) Full app — SPA, both Swagger UIs, OpenID discovery
34080 8080 (HTTP) 301 redirect to https://localhost:34443

Postgres (5432), RabbitMQ (5672/15672), Prompts API (8080), and Identity (5001) are not published. They are reachable only over the internal Docker network. This directly enforces the forwarded-headers trust boundary: a remote client cannot reach the backend services directly, so X-Forwarded-For spoofing from outside Docker is not possible.

Why the 127.0.0.1 binding is the control, not a host firewall. When Docker publishes a port on 0.0.0.0, it inserts rules into the DOCKER chain in iptables, which is consulted before the INPUT chain. A host firewall (e.g. ufw, iptables INPUT) cannot block traffic to a 0.0.0.0-published port because the packet is accepted by the DOCKER chain before the INPUT chain ever sees it. Binding to 127.0.0.1 is the only reliable way to ensure a published port is not reachable from the network — it restricts the listening socket to the loopback interface at the kernel level, before iptables is even involved. If either port were published on 0.0.0.0, a host on the same LAN could reach the gateway without authentication.

TLS

The compose stack terminates TLS inside Docker. The web container (nginx) listens on port 8443 with the self-signed gateway certificate (docker/certs/gateway.crt, docker/certs/gateway.key). The host maps port 34443 to this TLS endpoint.

Split-horizon issuer. The identity service's issuer (IDENTITY_ISSUER=https://web:8443/) uses the internal Docker service name web, which is covered by the gateway certificate's SAN list. The Prompts API fetches the OpenID discovery document from https://web:8443/.well-known/… from inside the Docker network — the web hostname resolves to the nginx container via Docker DNS. Humans and the SPA use https://localhost:34443, but a browser never inspects the issuer claim in an access token, so these two origins coexist without conflict.

Why the api container trusts the demo CA. The Prompts API's Docker image (Dockerfile.Api:17–18) installs the demo CA certificate (docker/certs/ca.crt) into the container's trust store and runs update-ca-certificates. This allows the API to make outbound TLS connections to https://web:8443/ without disabling certificate validation. Certificate validation is not disabled anywhere in the stack — the trust is anchored in the demo CA, not bypassed.

nginx sets X-Forwarded-Proto to $scheme on every proxy_set_header directive in frontend/nginx.conf. Because nginx is the outermost TLS terminator with nothing in front of it, there is no upstream value to inherit — setting it directly to $scheme prevents caller downgrade attacks. Both backend services trust this header through the forwarded-headers middleware, so Request.Scheme is "https" and OpenIddict validates the issuer correctly.

No HSTS. An Strict-Transport-Security header on localhost would pin HTTPS for every other project the reviewer runs on localhost — a hostile side effect for a demo.


RabbitMQ management UI exposure (closed)

The /rabbitmq/ location block was removed from frontend/nginx.conf and the broker credentials were removed from the launch-script summary banners. The RabbitMQ management UI is no longer reachable through the published gateway at all.

Port 15672 is not published on the host (docker-compose.yml maps only the AMQP port 5672 internally — no management port). An operator who needs the management UI can reach it deliberately via one of:

  • docker compose exec rabbitmq rabbitmq-diagnostics status (CLI inspection)
  • docker compose port rabbitmq 15672 (temporary port publication)
  • An SSH forward to the Docker host with a local port tunnel

The broker credentials (RABBITMQ_USER / RABBITMQ_PASS in .env) are generated per checkout (see §4) and are no longer printed to stdout by run.sh or run.ps1.

There aren't any published security advisories