Security
Security Report
What Food Info defends against, the control that provides the defence, and the evidence from the most recent code audit and Kali-based penetration test. Each row is a claim; each claim cites the file or scan output that backs it up.
Last reviewed:
How this report is produced
Two independent audits feed this page, re-run at every major release. A theoretical review sweeps the codebase across fourteen attack dimensions, authentication, authorisation, injection, cryptography, headers, CSRF, billing/webhook, file upload, server-side request forgery, dependencies, logging and PII, transactional email, rate-limiting and AI prompt-injection. Every candidate finding is then put through an adversarial verification pass against the actual source before it makes the report. A practical sweep runs from a freshly-built kalilinux/kali-rolling Docker container with nmap, testssl.sh, nuclei, OWASP ZAP, nikto, ffuf, sqlmap and curl. Both runs are reproducible, the multi-agent harness is committed at security-sweep-workflow.js and writes raw artefacts to pentest/results-YYYY-MM-DD/.
The most recent sweep (2026-07-02) ran both halves to completion. The theoretical pass covered fifteen dimensions with an adversarial verification round and returned no Critical findings, three High findings, and a set of lower-severity items; all three High findings were remediated the same day (see Closed since the 2026-07-02 sweep below). The Kali practical half then ran its full check battery against a local build and surfaced no new exploitable findings, corroborating the code review, no SQL injection, no open redirect, no authorisation bypass, no CORS leak, and no secrets in the rendered HTML. Raw artefacts are committed under pentest/results-2026-07-02/.
Authentication
Credential storage
Brute-force resistant Passwords are stored only as PBKDF2 hashes with a per-user salt via the ASP.NET Core Identity v3 password hasher (current .NET 10 framework defaults). We cannot recover a password, only reset it.
Bearer-token sessions
No long-lived secrets on disk Sign-in returns a short-lived access token plus a separate refresh token, both produced by ASP.NET Core's Data-Protection-backed bearer scheme. The encryption key ring is persisted to disk and itself encrypted with an X.509 certificate; on a process restart the same keys are reused, so existing tokens stay valid but a leaked filesystem snapshot is useless without the certificate password. (See Backend/FoodInfo/Program.cs.)
Authorisation enforcement
Verified by black-box probe Every protected endpoint refuses unauthenticated traffic. The pen-test harness sent three probes against the backend with no token, no header, and a syntactically-bogus bearer:
GET /api/me, 401 in every case.GET /api/preferences/search-history, 401 in every case.GET /api/mewithAuthorization: Bearer aaaa.bbbb.cccc, 401.
(Captured in pentest/results-2026-07-02/auth-probe.txt.)
OAuth (Google)
No password ever reaches us "Sign in with Google" delegates authentication to Google. We receive only the stable identifier and the email Google releases. The OAuth round-trip cookie (IdentityConstants.ExternalScheme) is single-use and lives for five minutes, configured in Program.cs. The post-authentication returnUrl is validated against an origin allow-list, external logins are no longer auto-linked into unconfirmed local accounts, and a PKCE-bound exchange code remains on the backlog, see Planned hardening.
Transport & security headers
Hardened headers shipped Every SSR response sets the full Helmet-default header bundle plus an explicit Permissions-Policy. Captured live from http://localhost:4000/ during the post-fix re-scan:
- Strict-Transport-Security
max-age=31536000; includeSubDomains, pins HTTPS for one year once a client connects over HTTPS.- Content-Security-Policy
default-src 'self', withscript-srcextended to Google Tag Manager / Analytics and Cloudflare Insights (plus'unsafe-inline'),style-src 'self' 'unsafe-inline',img-src 'self' data: https:,connect-src 'self' https:, andframe-srclimited to'self'.frame-ancestors 'none'blocks clickjacking on every route except the embeddable food-search widget, which deliberately opts in to cross-origin framing;object-src 'none'blocks plugins;base-uri 'self'blocks base-tag injection; andupgrade-insecure-requestsforces HTTPS sub-resources.- X-Frame-Options
SAMEORIGIN, second line of defence against clickjacking for legacy UAs that don't honourframe-ancestors.- X-Content-Type-Options
nosniff, disables MIME sniffing.- Referrer-Policy
no-referrer, no leak of search queries or paths to off-site links.- Cross-Origin-Opener-Policy
same-origin, isolates the browsing context; mitigates Spectre-class cross-origin window attacks.- Cross-Origin-Resource-Policy
same-origin, embeddable only by us.- Permissions-Policy
camera=(), microphone=(), geolocation=(), interest-cohort=(), opts the site out of FLoC and disables hardware APIs we don't use.- X-Powered-By
- removed, Express's default fingerprint header is disabled (
app.disable('x-powered-by')insrc/server.ts).
Backend hardened too The API service emits X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy and Cross-Origin-Resource-Policy via a small middleware in Program.cs. HTTPS redirection is enabled in non-development environments (app.UseHttpsRedirection()); HSTS is left to the production reverse-proxy that terminates TLS.
SSRF & host-header smuggling
Verified blocked Angular SSR's security.allowedHosts is set to ["localhost", "127.0.0.1"] in angular.json. The pen-test harness probed the SSR with three different Host: headers from inside the Kali container:
Host: localhost:4000→ 200 OK.Host: evil.com→ 400 Bad Request.Host: host.docker.internal(untrusted) → 400 Bad Request.
(Captured in pentest/results-2026-07-02/host-header-probe.txt.)
Cross-origin requests
Origin allowlist enforced A pre-flight from Origin: https://evil.com against both servers receives no Access-Control-Allow-Origin header in the response, so a browser will reject the cross-origin call. The CORS policy reads its allow-list from configuration, https://food-info.org / https://www.food-info.org in production, the localhost dev ports otherwise, and never uses AllowAnyOrigin() alongside credentials.
(Captured in pentest/results-2026-07-02/cors-probe.txt.)
Injection (SQL / OS / LDAP)
EF Core parameterisation Every database call goes through Entity Framework Core repositories, no FromSqlRaw, no string concatenation against the DB. sqlmap was pointed at the most-exposed user-controlled parameter, /foods/search?q=, with --level=1 --risk=1 (boolean, error-based, time-based, and UNION techniques). Verdict:
[WARNING] GET parameter 'q' does not seem to be injectable
[ERROR] all tested parameters do not appear to be injectableWe don't shell out, build LDAP filters, or evaluate user input, there are no other injection sinks to probe.
(Captured in pentest/results-2026-07-02/sqlmap-summary.txt.)
Cross-site scripting
- Angular auto-escapes by default Templates render text bindings as text. The codebase has zero uses of
[innerHTML],bypassSecurityTrust*,eval()orFunction()in user-data paths. - CSP blocks injected script Even if a template binding regression slipped through, the CSP's
script-src 'self'would refuse to execute injected<script src=...>. - No leaked secrets in SSR HTML The harness greps the rendered home page for the patterns
password, secret, token, api[_-]?key, AIza, AKIA, ghp_, sk-, BEGIN. All matched zero times. (Captured inpentest/results-2026-07-02/ssr-leak.txt.)
Path enumeration / forgotten endpoints
No surprisesffuf fuzzed 31 of the most-targeted admin / backup / dev paths (.env, .git/config, backup.zip, dump.sql, phpmyadmin, graphql, actuator, debug, etc.) against both servers. Hits found:
- Frontend:
/robots.txt(40 B) and/favicon.ico, both intentional. - Backend:
/swagger(301), only mounted in development. Its registration is gated behindapp.Environment.IsDevelopment()inProgram.cs, so production never exposes it.
(Captured in pentest/results-2026-07-02/ffuf-frontend.json and ffuf-backend.json.)
Secrets & dependencies
- No production secrets in source Google OAuth client secrets are read from
builder.ConfigurationatProgram.cs(i.e. user-secrets in dev, environment variables / managed-secret store in prod) and never committed. The DataProtection certificate password is similarly read from configuration. - Dev passwords clearly marked Files committed to the repo contain only the literal string
devpasswordin dev-only configuration (appsettings.Development.json,docker-compose.yml,FoodInfo.DataLoader/appsettings.json). These have no production validity. - All dependencies on permissive licences Every bundled dependency is on a permissive licence (MIT / Apache-2.0 / 0BSD / OFL-1.1 / PostgreSQL Licence); the full inventory is at /licenses, with no GPL-family obligations.
- Backend clean
dotnet list package --vulnerable --include-transitivereports no advisories. The 2026-07-02 dependency pass moved ASP.NET Core / Identity / EF Core to 10.0.9, which cleared two transitive advisories the sweep had flagged, a Critical inMicrosoft.AspNetCore.DataProtectionand a High inSystem.Security.Cryptography.Xml, and updated Stripe.net to 52.1.0 and SkiaSharp to 4.148.0. - Frontend upgraded The same pass moved the Angular family to 22 and TypeScript to 6, closing the earlier
@angular/platform-server/@angular/ssradvisories. Thenpm audititems that remain are build- and dev-server-only transitives bundled inside@angular/build(vite / esbuild), never shipped to browsers, and clear when Angular publishes a toolchain patch.
Logging & observability
- Application logs capture request paths, status codes and timings.
- Passwords, full tokens, and request bodies are never logged.
- The dev-only
ConsoleEmailSenderwrites confirmation and password-reset codes to stdout for local testing; it is not registered in production. Production deployments must inject a realIEmailSender<ApplicationUser>. - Logs rotate on a 90-day window.
Container posture
- API container runs as a non-root user (
USER $APP_UIDin the backend Dockerfile) with a multi-stage build that excludes SDKs and source. - Database is reached via internal Compose network; only the API container's port 8080 is exposed in the dev compose file.
- The pen-test container itself is built from
kalilinux/kali-rollingwith only the tools listed atpentest/Dockerfile, no shell, ssh, or development tools beyond what's needed to run a scan.
OWASP Top 10 (2021) at a glance
- A01 Broken Access Control
- Bearer-token enforcement verified on
/api/meand/api/preferences/*; CORS allowlist verified rejectingevil.com; host-header allowlist rejects untrusted Host values. - A02 Cryptographic Failures
- PBKDF2-SHA256 password hashing; Data-Protection key ring encrypted with X.509 certificate; HTTPS redirect in production.
- A03 Injection
- EF Core parameterisation only; sqlmap negative on
/foods/search?q=. No shell-out, LDAP, or expression-eval sinks. - A04 Insecure Design
- Sensitive flows (auth, OAuth) use ASP.NET Identity primitives, not bespoke crypto; bearer tokens never persisted server-side.
- A05 Security Misconfiguration
- Helmet on Express, security-headers middleware on Kestrel; Swagger gated to
IsDevelopment();UseHttpsRedirectionin non-dev.X-Powered-Byremoved. - A06 Vulnerable / Outdated Components
- Latest minor versions of Angular 21, ASP.NET Core 10, Identity 10, Npgsql 10, Express 5; clean
npm audit. - A07 Identification & Authentication Failures
- OAuth round-trip cookie single-use, 5-minute lifetime; bearer-token sessions via Data-Protection; no password is ever logged. Planned hardening: per-IP rate limiting on
/auth/login,/auth/registerand/auth/forgotPassword, see "Planned hardening" below. - A08 Software & Data Integrity
- Build artefacts produced by Angular CLI / dotnet publish; both ship integrity information for their bundles. The SSR server inlines and serves only files from
dist/Frontend/browser/, never user-controlled paths. - A09 Security Logging & Monitoring
- Structured logs at the framework level; production deployment is expected to forward to a centralised store with alerting.
- A10 Server-Side Request Forgery
- Angular SSR's
security.allowedHostsrejects untrusted Host headers, confirmed by Kali probe. No backend endpoint fetches arbitrary user-supplied URLs.
Planned hardening (open items)
Findings from the audits that we have not yet closed. We list them here for transparency rather than burying them. Items closed since the previous review are noted inline.
Pre-launch, closed
The four production-cutover blockers from the 2026-06-07 review have all been closed in code. Detail moved to the Closed since subsection below so the open-items list reflects only items still pending.
Closed since the 2026-07-02 sweep
- OAuth account pre-hijacking. External sign-in no longer auto-links a Google identity into a pre-existing but unconfirmed local account, and rejects a provider email explicitly marked
email_verified=false. This closes the "register the victim's email first, then take over their Google sign-in" pattern. (ExternalAuthController.) - Reset code / email no longer leaks into analytics. The first-party usage tracker strips the query string and fragment before sending, the backend strips it again before an event path is stored, and the GA4 layer redacts
email/code/tokenparameters, so a password-reset link can no longer persist a live reset credential or a user's address into the analytics tables or a third party. - Mail-bomb resistance independent of IP. Confirmation and password-reset emails are now capped per recipient address (5 / hour), so a forged
CF-Connecting-IPcan't flood a victim even if it evades the per-IP limit. The rate limiter's trust ofCF-Connecting-IPis additionally gated on a shared secret injected by a Cloudflare Transform Rule; locking the origin to Cloudflare-only traffic remains an infrastructure follow-up. - Search denial-of-service guard.
/foods/searchnow rejects queries with no token of three or more characters, so a sub-trigramILIKEcan't force a full-table scan that exhausts the connection pool. - Production audit trail restored. The production log level now raises the
FoodInfocategory toInformation, so the AUDIT mutation trail and the GDPR erasure / retention log lines are actually emitted in production. - ErrorLog query-string redaction. Sensitive query parameters (
token,code,email,api_key,password) are redacted before a request path is persisted toErrorLog. (Closes the M6 item previously listed under defence-in-depth.)
Defence-in-depth, scheduled post-launch / pre-Cyber Essentials
- HIBP Pwned Passwords breach check. Custom
IPasswordValidatorthat calls the HIBP k-anonymity endpoint at registration and password-change time, refusing any password that's appeared in a breach corpus. The framework policy itself (length, complexity) was raised to 12 / 3 unique chars on 2026-06-07, see "Closed since" below. - TOTP 2FA on Admin and Practitioner.
/auth/manage/2fais exposed but unsurfaced. Plan: SPA enrolment flow, require 2FA for Admin via a claim transform, recommend for Practitioner before the tier is marketed externally. - HSTS at the API origin. SSR layer ships HSTS via Helmet; the API origin emits the other baseline headers but not
Strict-Transport-Security. Plan: addmax-age=31536000; includeSubDomains; preloadin the existing security-headers middleware (non-Development). - Cache-Control + Vary on authenticated responses. Add
Cache-Control: no-storeandVary: AuthorizationwhenUser.Identity.IsAuthenticated. Defence-in-depth against shared-device and bfcache residual. - Minimal CSP on the JSON API. The API emits five baseline headers but no
Content-Security-Policy. Plan:default-src 'none'; frame-ancestors 'none'; base-uri 'none'on every API response. - Stripe partial-refund prorating. Today, any
charge.refundedrevokes 100% of the credit grant. Plan: readcharge.AmountRefundedand prorate the revocation; key the idempotency entry onRefund.Idso multiple partials each register. - Stripe webhook event-id ledger. Current handlers are each individually idempotent, but a future handler that omits dedup would silently double-apply on at-least-once redelivery. Plan:
ProcessedStripeEvents(Id PK, ReceivedAt)table with early-return. - Email confirmation link from
PublicSiteUrl.MapIdentityApi's default builds the link fromRequest.Host; the password-reset path correctly overrides withPublicSiteUrl. Plan: mirror that pattern for the confirmation link, plus pinAllowedHoststo canonical hosts inappsettings.Production.json. - Practitioner logo MIME hardening + EXIF strip. Logo data URI is length-capped (280 KB) but the subtype isn't allow-listed. Plan: server-side allow-list of
image/png+image/jpeg, magic-byte sniff after base64-decode, re-encode through SkiaSharp to a known- clean PNG (also strips EXIF / GPS metadata). - Practitioner invite throttling. Today only gated by
MaxActiveClients=50. Plan: per-practitioner cap (10 invites / 24 h), per-recipient cooldown (refuse the same invitee email more than once / 24 h across all practitioners), List-Unsubscribe headers, impersonation denylist onPracticeName. - Self-serve GDPR erasure. Privacy policy promises a 30-day-SLA right to erasure; only mechanism today is manual SQL. Plan:
DELETE /api/methat cancels the Stripe subscription, callscustomer.delete, transactionally clears the user and all satellite tables, writes an audit row, and is surfaced in account settings. - Refresh-token rotation. Refresh tokens are not single-use. Plan: reissue on use and revoke the previous refresh token. (Open from 2026-05-03 review.)
- Centralised security logging. Login success/failure events not yet forwarded to an external SIEM. (Open from 2026-05-03 review.)
- Refresh token + SRI on SPA bundle. Refresh token currently co-resides with the access token in
localStorage; SRI not yet on SPA<script>tags. Plan: move the refresh token to an HttpOnly cookie even if the access token stays in memory; add SRI hashes to the SPA build.
Closed since the 2026-05-03 review
- OAuth
returnUrlorigin allow-list.ExternalAuthController.StartandCallbacknow both validate the supplied URL against the union ofCors:AllowedOriginsandPublicSiteUrl; onlyhttp(s)schemes are accepted, localhost is trusted in development. An unrecognised origin is rejected with HTTP 400invalid_return_url. PKCE-bound exchange code is on the defence-in-depth backlog (it needs coordinated SPA changes). - ASP.NET Core rate limiter. Composite global limiter wired in
Program.cscovering:/auth/login(10 / 15 min / IP),/auth/refresh(30 / min / IP),/auth/forgotPassword+/auth/resendConfirmationEmail+/auth/register(5 / hr / IP, mail-bomb cap),/auth/resetPassword(10 / hr / IP),/auth/external/*(30 / min / IP),/foods/search(60 / min / IP),/api/meal-plans/generate(concurrency 1 / user),/api/billing/checkout&/portal(30 / hr / user). Stripe webhook explicitly bypassed. - Forwarded-headers + true client IP.
UseForwardedHeadersruns beforeUseAuthentication; the rate-limiter partition key prefersCF-Connecting-IPwhen present and falls back to the X-Forwarded-For chain. - Angular 21 SSR patches.
@angular/platform-serverupgraded 21.2.10 → 21.2.16 (GHSA-rfh7-fxqc-q52v).@angular/ssrupgraded 21.2.8 → 21.2.14 (GHSA-69xr-m8h6-h664). The full Angular 21 family is realigned to 21.2.16 to satisfy peer ranges.npm audit --omit=devreports zero vulnerabilities. - AI credit-cap fail-closed.
AnthropicMealPlanServicenow refuses (HTTP 500 + error log) whenAiCredits:Pricinghas no entry for the configuredAnthropic:Model. Production startup additionally fails fast on the same drift so the operator notices before the API serves traffic. A 50-calls-per- UTC-day per-user hard cap rides alongside the dollar cap as an independent fallback brake. - Password policy raised to 12 characters & 3 unique chars. Identity framework defaults to a 6-character floor; this codebase now sets
RequiredLength = 12andRequiredUniqueChars = 3inProgram.cs. Matches NIST SP 800-63B and Cyber Essentials guidance for user-chosen secrets, sits comfortably below the 16+ chars browser-generated passwords produce, doesn't disturb the Continue-with-Google + browser-password happy path. (audit M1) - Account-confirmation email in production.
Resendis wired as the productionIEmailSender<ApplicationUser>; all paid endpoints (BillingController.Checkout,CreditCheckout) refuse a user whoseEmailConfirmedisfalse. The signup flow itself is intentionally not gated so users can browse free tier without confirming.
Responsible disclosure
If you believe you've found a security issue affecting Food Info or DCPNET LTD, please report it privately to
- Avoid privacy violations, destruction of data, and interruption or degradation of service.
- Use only their own accounts and don't access or modify other users' data.
- Give us reasonable time (at least 30 days) to respond before disclosure.
We aim to acknowledge reports within two working days.