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.
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.
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.
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).
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.
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 tolocalhostandwebDNS 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, andRABBITMQ_PASS, also written todocker/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:
- 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.envalone leaves the old key live and spendable. - Delete
.envanddocker/certs/. - Re-run the launcher (
./run.shor.\run.ps1), which regenerates everything. - 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), changingPOSTGRES_PASSWORDand restarting the stack will not change the database password —initdbnever re-runs, and the old credentials remain effective inside the running database. Either:- Run
docker compose down -vbefore re-running the launcher (this deletes all persisted data). - Or connect to the running database and run
ALTER USERmanually.
- Run
- Regenerating the identity signing and encryption certificates invalidates every previously issued token. After rotation, all existing sessions are dead — users must re-authenticate.
- Remove the old demo CA from your trust store. If you previously
imported
ca.crtas a trusted root (browser, OS trust store, orupdate-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 oldca.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.
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).
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 DNSlocalhost, DNSweb, and IP127.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-apicontainer —Dockerfile.Api:17–18installsca.crtinto the container's trust store so the Prompts API can fetch the OpenID discovery document fromhttps://web:8443/without disabling certificate validation. - Any reviewer machine that has imported
ca.crtinto its OS or browser trust store (e.g. viaupdate-ca-certificatesor 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
localhostandwebDNS names withpathlen:0and 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. Therun.shsmoke test passes--cacerttocurl, 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).
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.
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.
| 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.
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.
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.
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 setsX-Forwarded-Proto: httpson every request, which the forwarded-headers middleware picks up, so OpenIddict seesRequest.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 to127.0.0.1(host-local only — see Published ports above), and (b) nginx declaresserver_nameon every block with areturn 444catch-all for unknownHostheaders, so requests routed by bare IP or by a guessed hostname are rejected before they reach a backend. Any deployment that publishes the gateway beyond127.0.0.1MUST set this flag back tofalse.
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.
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) |
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.
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.
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.