Skip to content

Security Findings 2026-08

Security Findings 2026-08

Assessment of the public foxy.ai surface (marketing site, app, auth, media delivery, billing webhooks, staging) performed 2026-08-23. Findings below carry an ID, severity, status, and evidence. Fix owners should update status as items land. Do not add secrets or live credentials to this page.

Method: live read-only probing of production and staging, static review of this repository, and read-only Cloudflare inspection (wrangler + REST API).

Register

IDFindingSeverityStatus
SEC-001Brain static API key fallback authenticates against staging (prod verified safe)HighFix in security/hardening-2026-08; rotate staging key after deploy
SEC-002Clerk sign-in email enumeration oracleMediumOpen (dashboard config, no code)
SEC-003Media signed-URL scheme (mechanism + cache nuance)InfoDocumented
SEC-004Missing browser security headers on app.foxy.ai and foxy.aiLowOpen (dashboard/next.config, not yet done)
SEC-005Dead client-supplied testPsp field on checkout RPCLowFixed in security/hardening-2026-08
SEC-006Ondato webhook lacks timestamp skew checkLowFixed in security/hardening-2026-08
SEC-007Broken/vestigial DNS records on public subdomainsLowOpen (dashboard config, no code)
SEC-008Staging environment publicly reachableLowAccepted / monitor
SEC-009Sirloin gRPC relies on network isolation; reflection enabledInfoDocumented
SEC-010robots.txt/sitemap.xml redirect to sign-in on app.foxy.aiLowFixed in security/hardening-2026-08

Production surface on sexty.dev (verified 2026-08-23)

The sexty.dev zone hosts production infrastructure beyond Brain. All were probed live and enforce authentication:

HostRoleLive verification
brain.sexty.devBrain API401 without key; hardcoded staging key rejected
hooks.sexty.devSirloin public HTTP (webhooks + admin)Primer payments/disputes, Chargebee, Brain completion, Ondato webhooks all return 401 unauthenticated; /internal/character-admin/* not routed here; no pprof/metrics exposure
mcp.sexty.devFoxy360 MCP server (/foxy360/mcp)401 without Bearer token; 403 ORIGIN_NOT_ALLOWED for disallowed browser origins; /foxy360/health public but leaks nothing
strip.sexty.devStrip admin SSR frontend302 → /login; its Clerk instance (clerk.strip.sexty.dev) has sign-ups restricted (invite-only)
flank.sexty.devFlank workflow editor307 → /sign-in
fennec.sexty.devFennec dashboard SPASPA shell public (static HTML); all API backends authenticated; its configured API host is a dead tunnel (SEC-007)

Note: the Fennec runtime config at fennec.sexty.dev/env.js reveals internal hostnames (hooks.sexty.dev, fennec-prod-api.sexty.dev) and the R2 bucket name (tenderloin-prod). Hostname disclosure is low risk — all revealed surfaces require auth — but it is free recon for attackers.

Authenticated probe results (2026-08-23)

End-to-end verification with a throwaway account against production app.foxy.ai (Clerk sign-in via API, short-lived session JWT as Bearer token). All results clean:

  • User-scoping is session-derived everywhere. tRPC procedures pass ctx.session.userId to Sirloin; client-supplied userId inputs are ignored by the Zod schemas and never forwarded.
  • IDOR probes rejected. character.getCharacter with a foreign/random UUID returns null; character.getUploadRefImageUrl and media.toggleMediaFavoriteById with random UUIDs fail with not_found because GetCharacter/GetMedia (apps/sirloin/internal/pkg/storage/characters.go:376, apps/sirloin/internal/pkg/storage/media.go) filter on both user_id and id. Verified in code across media, character, collection, and nudify-reference paths; the same pattern holds for upload-URL presigning (apps/sirloin/internal/app/services/characters/getreferenceimageuploadurl.go:18).
  • Token hygiene. Clerk session JWTs minted with ~60s expiry; replaying an expired JWT returns a 307 to sign-in.
  • /auth-content/* stealth behavior. Unauthenticated requests get a bare 404 (no redirect); authenticated requests pass through. Path traversal sequences (..%2f) are rejected with 400.
  • Error message leakage (negligible). Some Sirloin errors surface raw SQL messages (sql: no rows in result set) to clients via tRPC 500s. Cosmetic; no schema exposure beyond the phrase itself.

Additional clean checks (2026-08-23)

  • Repo secret scan. A pattern sweep of the repository (live API keys, AWS/GCP/GitHub/Slack tokens, private key blocks, hardcoded JWTs) found nothing beyond the known STATIC_TESTS_API_KEY (SEC-001).
  • Client-attested fraud metadata. Brisket forwards client-supplied riskSignals to Sirloin as the x-risky-actions header, but Sirloin only copies it into Primer checkout/vaulting metadata (apps/sirloin/internal/pkg/clientmeta/clientmeta.go:21, apps/sirloin/internal/app/services/billing/createprimercheckout.go:499). No authorization or server-side fraud decision consumes it, so a lying client can only degrade Primer-side fraud signal quality, not bypass a control. Acceptable as-is; do not build future server-side decisions on this header.

SEC-001 — Brain static API key fallback (High)

apps/brain/src/modules/application/auth/mocks.ts hardcodes a fallback value for STATIC_TESTS_API_KEY. ApiKeyStrategy.validate (apps/brain/src/modules/application/auth/strategies/api-key.strategy.ts) accepts that value whenever the resolved stage is development or sandbox. Stage resolution (apps/brain/src/common/runtime/stage.ts) defaults to development when RAILWAY_ENVIRONMENT_NAME, BRAIN_STAGE, and NODE_ENV are all unset, so a missing environment variable silently enables the bypass.

Verified on staging: a single unauthenticated GET to the Brain workflow API with the hardcoded key returns the full published workflow catalog (names, graphs, node wiring), including the execute/execute-by-id mutations.

Verified on production (brain.sexty.dev, publicly reachable): the hardcoded key is rejected — both X-API-Key and Bearer forms return 401 Invalid API key. Prod’s stage resolves to production, so the bypass branch never fires there today. The exposure is staging-only as currently configured, but it is one configuration drift away from prod: if prod’s stage env is unset or misnamed (see the fail-open default above), the same key unlocks the production API.

Because prod Brain is internet-facing with the API key as its only barrier (see below), the AUTHORIZED_KEYS values are crown-jewel credentials: a leak gives full workflow API access including execute, which dispatches paid provider compute.

Fix:

  1. Remove the hardcoded fallback; require STATIC_TESTS_API_KEY from environment only, and fail closed when it is absent. Done in security/hardening-2026-08: mocks.ts no longer carries a fallback value, and both auth strategies only honor the bypass when a stage env var is explicitly set to a non-production stage (unset stage now fails closed). Regression tests cover the retired key, unset stage, and production stage.
  2. Fail closed on unset/unknown stage — implemented at the two auth strategies (the general stage helper still defaults for observability config, which is not security-relevant).
  3. Rotate the static key after the fix deploys.
  4. Keep brain.foxy.ai decommissioned or point it at an authenticated proxy.

Public prod Brain endpoint (brain.sexty.dev)

Production Brain is reachable at https://brain.sexty.dev and authenticates every API route with the API-key/Clerk guards. Non-issues verified there: unauthenticated requests 401; the hardcoded staging key 401; CORS does not reflect arbitrary origins (allowlist holds — no Access-Control-Allow-Origin on disallowed preflights); the only @Public routes are / (Hello World!) and /health (OK).

SEC-002 — Clerk sign-in email enumeration (Medium)

POST https://clerk.foxy.ai/v1/client/sign_ins (no auth) returns form_identifier_not_found — "Couldn't find your account." for emails without an account, and a normal needs_first_factor response with the account’s available first factors for emails that exist. Differential verified on production. For a service with NSFW content, enumerable membership is a privacy issue (an attacker can confirm a specific person has an account).

Mitigations (pick one or more):

  • Enable aggressive CAPTCHA / bot protection on sign-in attempts in the Clerk dashboard to blunt bulk enumeration.
  • Rate-limit the FAPI from your edge (Cloudflare WAF rule on clerk.foxy.ai/v1/client/sign_ins).
  • Accept the risk: identifier revelation at sign-in is common consumer-app behavior; weigh accordingly.

SEC-003 — Media signed URLs (Info)

tenderloin.foxy.ai serves the tenderloin-prod R2 bucket. Signature enforcement is a Cloudflare WAF custom rule using the rules language’s timed HMAC function is_timed_hmac_valid_v0() over http.request.uri, matching the verify=<unix_ts>-<base64url_hmac> scheme generated by apps/sirloin/internal/pkg/s3/hmac.go (HMAC-SHA256 over path + timestamp, raw URL-safe base64). Invalid, missing, and future-dated signatures are blocked at the edge (403). The TTL is enforced by Cloudflare as the ttl argument of the rule — this is why the Go generator’s duration parameter is ignored (generateSignedURL discards it).

Rule configuration (confirmed 2026-08-23, key redacted):

(http.host == "tenderloin.foxy.ai"
and not is_timed_hmac_valid_v0("<secret>",
http.request.uri,
300000,
http.request.timestamp.sec,
8,
"s"))

Verified consistent with Sirloin’s generator: TTL 300000 s (≈83.3 h) exceeds the 72 h signed-URL cache lifetime (presignedExpiry, apps/sirloin/internal/pkg/s3/presign.go) with ~11 h margin, so cached URLs never expire early; separator length 8 matches both ?verify= and &verify=; flag "s" matches the raw URL-safe base64 signature encoding.

Two nuances to keep in mind:

  1. The effective TTL is the WAF rule’s 300000 s, not the 72 h cache value. If the WAF TTL is ever set below 72 h, clients will observe 403s on cached URLs until the cache entry expires. Keep the two values in lockstep.
  2. images.foxy.ai transformation responses are cached with cache-control: max-age=31536000 (one year), and the transform URL embeds the signed origin URL (including verify=) in its path. Any signed URL that has been wrapped in a transform remains retrievable from the transform cache long after the origin signature expires. This is acceptable for first-party display flows (the app always builds transforms from fresh URLs) but extends the lifetime of any captured link from TTL to up to a year. If shorter effective lifetimes are required for sensitive uploads, transform URLs for those paths must not be long-cached.

SEC-004 — Missing browser security headers (Low)

Neither app.foxy.ai nor foxy.ai sends Content-Security-Policy, X-Frame-Options/frame-ancestors, Referrer-Policy, or Permissions-Policy. HSTS and X-Content-Type-Options are present.

Fix:

  • Brisket (app.foxy.ai): add a headers() block in apps/brisket/next.config.ts or middleware; a strict CSP needs the Clerk, Stape/metrics, Primer, and image CDN hosts allowed.
  • foxy.ai is Framer-hosted; set headers via a proxied custom domain or accept the gap for the marketing site.

SEC-005 — Dead testPsp field on checkout RPC (Low) — remediated

At assessment time, CreatePrimerCheckoutRequest.test_psp flowed from the Brisket client input (apps/brisket/src/server/api/routers/subscription.ts) into Sirloin checkout params but was never read — a client-controlled “test PSP” concept wired next to payment routing, inviting future misuse even while dead.

Remediated in security/hardening-2026-08: test_psp is removed from CreatePrimerCheckoutRequest and CreateVaultingSessionRequest in proto/sirloin/v5/billing.proto, all generated consumers are regenerated, and the Brisket plumbing (tRPC inputs, testPspAtom, the flag-gated test-PSP dropdown) is deleted. The internal-test-psp PostHog flag can be retired.

SEC-006 — Ondato webhook timestamp window (Low) — remediated

At assessment time, verifyOndatoSignature (apps/sirloin/internal/app/services/characters/ondatowebhook.go) verified HMAC-SHA256 over timestamp + "." + body with constant-time comparison but — unlike the Primer webhook — never checked that the timestamp was recent, so a captured webhook body could be replayed indefinitely.

Remediated in security/hardening-2026-08: signature verification enforces a ±5-minute timestamp window (sign-safe future/past comparisons; a far-future timestamp regression test covers the Duration-overflow edge). Regression tests cover fresh, stale, future, far-future, edge-of-window, and non-numeric timestamps.

SEC-007 — Broken/vestigial DNS records (Low)

Publicly resolving but broken hostnames on foxy.ai:

  • pay.foxy.ai — Cloudflare error 1003 (DNS points to prohibited IP)
  • support.foxy.ai — Cloudflare error 1034
  • old.foxy.ai — Cloudflare error 526 (invalid origin certificate)
  • brain.foxy.ai — Cloudflare error 1033 (dead tunnel; production Brain actually serves at brain.sexty.dev, so this record is pure vestige)
  • fennec-prod-api.sexty.dev — Cloudflare error 1033 (dead tunnel). The production Fennec dashboard (fennec.sexty.dev) still ships this URL in its runtime config, so the dashboard’s API calls fail. Operational, not security; fix by reviving the tunnel or removing the deployment.

These leak topology and confuse users. Delete the records or repoint them.

SEC-008 — Staging publicly reachable (Low)

The full staging stack is internet-reachable on *.letsfoxy.com (app, brain, strip admin login, flank, fennec). Each service enforces its own auth (Clerk / API key / service token), and staging data is a lower-value target, so this is recorded as accepted risk. If tightening later: put Cloudflare Access in front of *.letsfoxy.com.

SEC-009 — Sirloin gRPC network trust model (Info)

Sirloin’s gRPC server (apps/sirloin/cmd/app/main.go) has no authentication interceptor: RPCs carry client-supplied user_id and rely on every handler scoping queries by that ID (verified for media, characters, collections, nudify-reference paths), plus Railway-private networking (no public ports — confirmed). gRPC reflection is registered unconditionally, which aids recon if the port is ever exposed. Consider a service-token interceptor for all RPCs (the pattern exists for Strip/CharacterRuntime) and disabling reflection outside development.

SEC-010 — robots.txt/sitemap.xml behind auth (Low) — remediated

At assessment time, https://app.foxy.ai/robots.txt and /sitemap.xml returned 307 -> /sign-in?redirect_url=... for unauthenticated requests: the middleware matcher (apps/brisket/src/middleware.ts) does not exclude .txt/.xml extensions and these paths were not in isPublicRoute, so auth.protect() fired and search crawlers could not read robots.txt.

Remediated in security/hardening-2026-08: robots.txt and sitemap.xml are added to isPublicRoute in the middleware; both now serve unauthenticated. robots.ts only disallows /auth-content/.

Verified-secure areas

No action needed; recorded for future assessments.

  • Webhook signatures: Primer payments (HMAC + timestamp skew), Primer disputes, Chargebee (constant-time basic auth), Brain→Sirloin completion (shared secret, hmac.Equal), Brisket Clerk webhook (svix), Ondato (HMAC).
  • Brisket middleware auth-gating (tRPC, /auth-content, open-redirect validation on redirect_url).
  • All Brisket tRPC procedures are protectedProcedure with session-derived user IDs; Sirloin storage queries are user-scoped.
  • Checkout amounts are server-derived from the Chargebee catalog (Turnstile, fraud velocity, idempotency keys present).
  • Character Admin API and Foxy360 MCP require Clerk JWT + role checks, with CORS and DNS-rebinding guards respectively.
  • images.foxy.ai transformation origin allowlist rejects external origins.
  • No secrets, source maps, or .env/.git exposure found in public bundles.