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
| ID | Finding | Severity | Status |
|---|---|---|---|
| SEC-001 | Brain static API key fallback authenticates against staging (prod verified safe) | High | Fix in security/hardening-2026-08; rotate staging key after deploy |
| SEC-002 | Clerk sign-in email enumeration oracle | Medium | Open (dashboard config, no code) |
| SEC-003 | Media signed-URL scheme (mechanism + cache nuance) | Info | Documented |
| SEC-004 | Missing browser security headers on app.foxy.ai and foxy.ai | Low | Open (dashboard/next.config, not yet done) |
| SEC-005 | Dead client-supplied testPsp field on checkout RPC | Low | Fixed in security/hardening-2026-08 |
| SEC-006 | Ondato webhook lacks timestamp skew check | Low | Fixed in security/hardening-2026-08 |
| SEC-007 | Broken/vestigial DNS records on public subdomains | Low | Open (dashboard config, no code) |
| SEC-008 | Staging environment publicly reachable | Low | Accepted / monitor |
| SEC-009 | Sirloin gRPC relies on network isolation; reflection enabled | Info | Documented |
| SEC-010 | robots.txt/sitemap.xml redirect to sign-in on app.foxy.ai | Low | Fixed 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:
| Host | Role | Live verification |
|---|---|---|
brain.sexty.dev | Brain API | 401 without key; hardcoded staging key rejected |
hooks.sexty.dev | Sirloin 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.dev | Foxy360 MCP server (/foxy360/mcp) | 401 without Bearer token; 403 ORIGIN_NOT_ALLOWED for disallowed browser origins; /foxy360/health public but leaks nothing |
strip.sexty.dev | Strip admin SSR frontend | 302 → /login; its Clerk instance (clerk.strip.sexty.dev) has sign-ups restricted (invite-only) |
flank.sexty.dev | Flank workflow editor | 307 → /sign-in |
fennec.sexty.dev | Fennec dashboard SPA | SPA 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.userIdto Sirloin; client-supplieduserIdinputs are ignored by the Zod schemas and never forwarded. - IDOR probes rejected.
character.getCharacterwith a foreign/random UUID returnsnull;character.getUploadRefImageUrlandmedia.toggleMediaFavoriteByIdwith random UUIDs fail withnot_foundbecauseGetCharacter/GetMedia(apps/sirloin/internal/pkg/storage/characters.go:376,apps/sirloin/internal/pkg/storage/media.go) filter on bothuser_idandid. 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
riskSignalsto Sirloin as thex-risky-actionsheader, 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:
Remove the hardcoded fallback; requireDone inSTATIC_TESTS_API_KEYfrom environment only, and fail closed when it is absent.security/hardening-2026-08:mocks.tsno 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.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).- Rotate the static key after the fix deploys.
- Keep
brain.foxy.aidecommissioned 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:
- The effective TTL is the WAF rule’s
300000s, 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. images.foxy.aitransformation responses are cached withcache-control: max-age=31536000(one year), and the transform URL embeds the signed origin URL (includingverify=) 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 inapps/brisket/next.config.tsor 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 1034old.foxy.ai— Cloudflare error 526 (invalid origin certificate)brain.foxy.ai— Cloudflare error 1033 (dead tunnel; production Brain actually serves atbrain.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 onredirect_url). - All Brisket tRPC procedures are
protectedProcedurewith 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.aitransformation origin allowlist rejects external origins.- No secrets, source maps, or
.env/.gitexposure found in public bundles.