Skip to content

CasTyou Security Audit — SEC-AUDIT-001

Date: 2026-06-15 Scope: castyou-backend (Apollo GraphQL / Prisma+Postgres / Mongoose+MongoDB / Redis / JWT+RBAC) and castyou-frontend (apps/app, apps/landing, packages/design-system). Method: Static review of resolvers, middleware, services, schema, transport config, and frontend token/link handling. Every finding cites file:line. Status: Audit only — no vulnerabilities were fixed. Remediation is tracked under BE-SEC-001 (backend) / FE-SEC-001 (frontend).


🚨 P0 — fix immediately

None found. No active exploit, no unauthenticated data leak, no authn bypass was identified. The two highest-impact issues are P1 (a PII + moderation-state field leak that requires authentication, and an unauthenticated object-overwrite in the upload presigner). They are serious and should be fixed this epic, but neither is a remotely-trivial mass data dump or an auth bypass, so they are rated P1 rather than P0.


What is handled well (verified, not just assumed)

  • IDOR ownership guards are consistently present on the object-mutating resolvers. Portfolio (portfolio.ts:26-45 requireOwnedItem/requireOwnedFolder), folders (folders.ts:16-25 getProducerProfile + service-level profile.id scoping), job edit/delete/applications (job.ts:283-285,635-637,656-659,707-709,728-730,779-781,815-817), application status (job.ts:1210-1212), flier upload/save (job.ts:776-782,812-817), agency bulk-apply (agency.ts:189-199) all verify the caller owns the target before acting.
  • Admin impersonation cannot escalate or persist. The impersonation token carries scope:'IMPERSONATION' and isAdmin is force-set to false in context (context.ts:103); requireAdmin throws if ctx.impersonation is set (auth.ts:16-20); the DB session is re-validated (endedAt/expiresAt) on every request (context.ts:62-67); admins cannot be impersonated and self-impersonation is blocked (impersonation.ts:51-52); a 30-min session with a 2-hour hard cap and single extension (impersonation.ts:7-8,96-99); every operation is audit-logged with sensitive variables redacted (impersonationAudit.ts, impersonation.ts:114-124); impersonated follows are silenced (social.ts:74-77).
  • Raw SQL is fully parameterized via tagged templates — no string interpolation. featureFlags/index.ts:51-110, castars/index.ts:161, bountyHunter/index.ts:233.
  • Account-status enforcement on HTTP is centralized and default-deny (accountStatus.ts), and suspendUser/banUser/logout/changePassword/refreshToken/admin resetUserPassword all bump tokenVersion so existing access tokens are invalidated (admin.ts:212,236,257,359, auth.ts:394,405,431). authenticateToken rejects any token whose tokenVersion is stale (context.ts:83).
  • Upload size/type are enforced server-side, and Content-Length is pinned into the presigned signature so a client cannot under-report then upload larger (media/index.ts:177-215).
  • Admin-only signals do not leak: User.activeStrikeCount resolves to 0 for non-admins (adminStrikes.ts:16-20); SupportTicket.moderationScan returns null for non-admins (support.ts:51-58); REMOVED comment/message bodies are withheld from non-admins (feed.ts:97-104, messaging.ts:238-246).
  • CORS is an explicit allowlist, not *, with credentials:true (index.ts:52). Introspection is disabled in production (index.ts:91). .env is git-ignored and not tracked. No secrets found in the frontend source/bundle.
  • Refresh tokens rotate (single-use: tokenVersion increments on every refresh, auth.ts:392-396). Liveness challenge token is HMAC'd + nonce-invalidated (verification.ts:37-47).

Findings

SEC-F01 — PII + moderation state leak through Conversation.participants / Message.sender · P1 · BE-SEC-001

Evidence. The User GraphQL type exposes email, status, suspendedAt, suspendedUntil, banReason, verifiedAt, isAdmin (schema/index.ts:248-276). There is no field-level guard on these fields — profileResolvers.User only computes hasPassword and defaults status (profiles.ts:14-17); the rest are returned straight off the parent Prisma row.

Conversation.participants: [User!]! (schema/index.ts:146) is resolved by returning the full Prisma User rows for every participant (messaging.ts:186-194), and Message.sender: User! (schema/index.ts:157) returns the full sender row (messaging.ts:230-231). Both are reachable by any authenticated participant of the conversation via conversation(id) / conversations (messaging.ts:52-60).

Reproduction.

  1. Authenticate as user A; open or hold any conversation with user B.
  2. Run:
    graphql
    query { conversation(id: "<convId>") {
      participants { id email status banReason suspendedUntil isAdmin }
      messages(first: 1) { edges { sender { email banReason } } }
    } }
  3. User B's private email and full moderation/standing state are returned to user A.

Impact. Email-address harvesting of anyone you can DM (the platform lets you cold-DM arbitrary users), plus disclosure of another user's suspension/ban status and reason. The same User type is also returned by AuthPayload.user, SupportTicketReply.author, SupportTicketNote.admin, JobEditLog.admin — those paths are admin/self-scoped, but the messaging path is not.

Remediation (BE-SEC-001). Add field resolvers on the User type that return email/status/suspendedAt/suspendedUntil/banReason/isAdmin only when parent.id === ctx.user.sub or ctx.user.isAdmin (and not impersonating for admin-only fields); otherwise null/redacted. Alternatively, have Conversation.participants and Message.sender resolve to a PublicUser-style projection instead of the full User.


SEC-F02 — getMediaUploadUrl lets a client choose an arbitrary object key prefix (cross-user object overwrite) · P1 · BE-SEC-001

Evidence. The resolver accepts a client-supplied folder and passes it straight through:

ts
// portfolio.ts:96-111
const resolvedFolder = folder ?? `media/${ctx.user.sub}`;
const safeName = filename.replace(/[^a-z0-9._-]/gi, '_');
return generateMediaUploadUrl(resolvedFolder, `${ts}_${safeName}`, contentType, sizeBytes ?? null);

generateMediaUploadUrl builds the key as ${folder}/${filename}.${ext} with no validation that the folder is scoped to the caller (media/index.ts:202-218). folder is not sanitized for .. or leading /, and is not prefixed with the user id when the client supplies it. The default-on-omission path is per-user, but the client controls the value, so the per-user default is not a guarantee.

Reproduction.

  1. Authenticate as any user.
  2. graphql
    mutation { getMediaUploadUrl(
      filename: "<victimUserId>", contentType: "image/jpeg", folder: "profile-photos") {
      uploadUrl key
    } }
    This presigns a PUT for key profile-photos/<ts>_<victimUserId>.jpg. Note uploadProfilePhoto writes deterministic key profile-photos/${userId}.${ext} (media/index.ts:81); the prefix is shared. A crafted folder/filename can target other deterministic prefixes (e.g. flier-assets/{entityId}/logo.png, fliers/{jobId}/render.png) and, because R2 PUT overwrites, replace another tenant's served asset (stored XSS-via-image / defacement / cache poisoning depending on content-type).

Impact. Authenticated cross-user object overwrite / write into arbitrary key namespaces in the shared media bucket. Bounded by the presigned content-type+size, but still allows overwriting victim-owned images.

Remediation (BE-SEC-001). Ignore the client folder (or treat it as an enum of allowed sub-folders) and always force the key under a server-derived media/${ctx.user.sub}/… prefix. Reject .., leading /, and absolute prefixes. Apply the same server-side prefixing to the deterministic-key helpers so cross-entity ownership is enforced before issuing/writing.


SEC-F03 — No GraphQL query depth / complexity limit (DoS) · P1 · BE-SEC-001

Evidence. The Apollo server is constructed with only introspection, drain, impersonation-audit, and account-status plugins (index.ts:89-111). No graphql-depth-limit, graphql-query-complexity, or graphql-armor is present in package.json or wired as a validation rule. The only protection is the per-IP HTTP rate limiter (rateLimiter.ts), which counts requests, not query cost.

The schema has deeply recursive, fan-out-heavy edges that can be nested arbitrarily: PublicUser ↔ follow graph (followers/followingFollowEdge.user: PublicUser), Post.author → TalentProfile.posts → Post …, Conversation.participants → User.talentProfile → … , and lazy count fields on every Post (feed.ts:33-40).

Reproduction. A single authenticated request can request feed { edges { author { posts { edges { author { posts { … } } } } } } (or a deep follow graph), forcing a large multiplicative number of DB round-trips from one cheap-to-send query — well within the 600-req/15-min budget.

Impact. Amplified database load / denial of service from a single request, not throttled by the request-count limiter.

Remediation (BE-SEC-001). Add a max-depth (~8–10) and/or query-complexity validation rule (e.g. graphql-armor maxDepth+costLimit, or graphql-depth-limit passed to validationRules). Also bound the recursive list edges with hard page-size caps where missing.


SEC-F04 — WebSocket subscription transport is not gated by account status; existing connections survive ban/suspend · P1 · BE-SEC-001

Evidence. Account-status enforcement lives in the accountStatusPlugin, which is an Apollo (HTTP) plugin registered only on the Apollo server (index.ts:105). The graphql-ws server uses a separate context factory createSubscriptionContext (index.ts:74-87, context.ts:144-153) and has no equivalent status gate — its only auth is authenticateToken at connection time. The messaging subscriptions themselves check only requireAuth/participant membership (messaging.ts:46-48,141-181), never user.status.

Reproduction.

  1. User A opens a WS subscription (messageAdded/conversationUpdated).
  2. An admin bans/suspends A. banUser bumps tokenVersion (admin.ts:236), which blocks new HTTP requests and new WS connections — but the already-established WS connection is not re-authenticated per operation, so A keeps receiving real-time messages and inbox updates until the socket drops.

Impact. A banned/suspended user retains live message delivery on an open socket. Lower severity than SEC-F01/02 because it requires an already-open connection and tokenVersion blocks reconnection, but it is a real gap in "does suspension block EVERY transport."

Remediation (BE-SEC-001). Re-check user.status inside the subscription subscribe/filter (cheap — already in context) and reject non-ACTIVE; and/or run a per-operation status check in the graphql-ws onSubscribe/onOperation hook mirroring accountStatusPlugin. Consider closing live sockets for a user on ban (publish a force-disconnect).


SEC-F05 — Login / register / reset reveal account existence (user enumeration) · P2 · BE-SEC-001

Evidence.

  • login distinguishes a social-only account ("This account uses social login…", auth.ts:146-150) from a wrong password ("Invalid credentials.", auth.ts:152-154) — the first message confirms the email exists.
  • register returns a generic message (good, auth.ts:110-114), but bcrypt.compare only runs when the user exists; a missing user returns immediately (auth.ts:142-145) → a timing oracle distinguishing existing vs non-existing accounts.
  • joinWaitlist returns "This email is already on the waitlist." (waitlist.ts:69-73) — waitlist-membership enumeration.
  • searchMessageRecipients matches users by email substring for any authenticated caller (messaging/index.ts:99); it returns only display summaries (not the email), but enables email-prefix probing.

Impact. Account/membership enumeration aiding targeted phishing and credential-stuffing.

Remediation (BE-SEC-001). Return a uniform "invalid credentials" for login regardless of social-vs-password; run a dummy bcrypt compare when the user is missing to equalize timing; make joinWaitlist idempotent-success without confirming prior membership; drop email from the searchMessageRecipients OR clause.


SEC-F06 — Access token persisted to localStorage + 7-day JWT lifetime (XSS token theft) · P2 · FE-SEC-001

Evidence. The Zustand auth store persists accessToken to localStorage under castyou-auth (apps/app/src/stores/auth.ts:44-101, partialize returns accessToken at lines 92-99). Access tokens are signed with JWT_EXPIRES_IN default 7d (config/index.ts:9, auth.ts:49-54). Any XSS on the app origin can read localStorage and exfiltrate a token valid for up to 7 days.

Impact. Standard SPA trade-off, but the long access-token lifetime widens the theft window. There is no "log out everywhere" UI surface beyond logout (which bumps tokenVersion, so it does effectively revoke — that part is good).

Remediation (FE-SEC-001 / BE-SEC-001). Shorten the access-token lifetime (e.g. 15–60 min) and lean on the existing refresh rotation; prefer an in-memory access token with the refresh token in an HttpOnly, Secure, SameSite cookie. At minimum reduce JWT_EXPIRES_IN.


SEC-F07 — Weak password policy (length-only, min 8) · P2 · BE-SEC-001

Evidence. RegisterInputSchema/ChangePasswordInputSchema enforce only min(8) (validation/index.ts:15,35); admin resetUserPassword enforces only length >= 8 (admin.ts:351). No complexity, no breached-password (HIBP) check, no max length.

Impact. Weak/common passwords accepted; combined with the login enumeration (SEC-F05) this lowers the bar for credential stuffing. Note: login brute-force is partially mitigated by the 10-attempts/15-min authRateLimiter (rateLimiter.ts:44-54).

Remediation (BE-SEC-001). Raise to a reasonable policy (length + zxcvbn-style strength or a breached-password check) shared by register / changePassword / reset.


SEC-F08 — Captcha and Turnstile fail open; security headers / CSP gaps · P2 · BE-SEC-001 / FE-SEC-001

Evidence.

  • verifyTurnstileToken is a no-op when TURNSTILE_SECRET_KEY is unset — it only logs a warning even in production (captcha/turnstile.ts:29-34). If the prod env is misconfigured, joinWaitlist (and any captcha-gated mutation) silently runs with no bot protection.
  • LIVENESS_SECRET has a hardcoded dev default (config/index.ts:28) that will be used in prod if the env var is unset — predictable HMAC key for liveness challenge tokens.
  • Helmet's contentSecurityPolicy is only enabled in production (index.ts:51); confirm the apps/landing and apps/app static hosts also send CSP, X-Content-Type-Options, frame-ancestors/X-Frame-Options, and Referrer-Policy — these were not found configured in the frontend hosting config during this review.
  • formatError returns the raw formatted error to the client in all environments (index.ts:107-110); verify stack traces / internal messages are not leaked in production.

Remediation. Fail closed in production when TURNSTILE_SECRET_KEY is missing (refuse the mutation, not just warn). Make LIVENESS_SECRET required in prod (drop the default, or hard-fail when NODE_ENV==='production'). Add security headers/CSP to the frontend hosts (FE-SEC-001). Strip error detail in production formatError.


SEC-F09 — Container runs as root; pnpm/npm audit not in CI · P2 · BE-SEC-001

Evidence. The production Dockerfile never drops privileges — there is no USER node directive before CMD (Dockerfile:14-28), so the Node process runs as root. (EXPOSE 3000 also mismatches the app's default PORT 4000 (index.ts:32) — operational, not security.) No pnpm audit/npm audit step was found in CI.

Remediation (BE-SEC-001). Add USER node (node:22-alpine ships a node user) after copying artifacts and chown the app dir. Add a pnpm audit --prod (or npm audit) gate to CI. To run now: pnpm audit in both repos and triage.


Evidence. inviteTalentToAgency creates an agencyTalentLink with status:'ACTIVE' immediately and only fires a notification (agency.ts:140-148); there is no talent acceptance step. An agency can unilaterally mark any talent as an active member, and bulkApplyToJob then submits applications on that talent's behalf (agency.ts:189-220) without per-application talent consent.

Impact. A talent can be represented (and have job applications filed in their name) by an agency they never joined. More an authorization-model/abuse issue than a classic IDOR, but it lets one user act as another.

Remediation (BE-SEC-001). Make the link PENDING until the talent accepts; only ACTIVE links should be eligible for bulkApplyToJob.


SEC-F11 — pet(id) query has no auth/ownership guard (IDOR read) · P2 · BE-SEC-001

Evidence. Unlike its siblings (myPets/updatePet/deletePet, which call requireProfile('PET_OWNER') and scope by userId), the single-pet read does neither:

ts
// petOwner.ts:46-48
pet: async (_: unknown, { id }: { id: string }, ctx: GraphQLContext) => {
  return ctx.prisma.pet.findUnique({ where: { id } });
},

No requireAuth, no ownership check. Any caller (including anonymous) can read any pet row by guessing/enumerating its id.

Reproduction. query { pet(id: "<anyPetId>") { id name breed photos ownerId … } } returns another owner's pet record. (Discovered via the authz-matrix coverage test — the anon-deny probe for Query.pet did not throw.)

Impact. Disclosure of pet records (and the linked owner id) belonging to other users. Low sensitivity data, hence P2, but it is an unauthenticated IDOR read and should get the same guard as the rest of the pet surface.

Remediation (BE-SEC-001). Add requireProfile(ctx,'PET_OWNER') and assert pet.ownerId === <caller's petOwnerProfile.id> (or admin), matching updatePet/deletePet.

Also noted (informational): featureFlag(key) uses requireAuth, not requireAdmin (featureFlags.ts:13-20), so any logged-in user can read any feature flag's on/off state by key. Impact is limited to boolean flag states; tightening to admin is a backlog consideration, not a tracked finding.


Summary table

IDTitleSeverityScope
SEC-F01Email + moderation state leak via Conversation.participants / Message.senderP1BE-SEC-001
SEC-F02getMediaUploadUrl arbitrary object-key prefix → cross-user overwriteP1BE-SEC-001
SEC-F03No GraphQL depth/complexity limit (DoS)P1BE-SEC-001
SEC-F04WS subscriptions not gated by account status; open sockets survive banP1BE-SEC-001
SEC-F05User/account enumeration via login/register/reset/waitlistP2BE-SEC-001
SEC-F06Access token in localStorage + 7-day JWT lifetime (XSS theft)P2FE-SEC-001
SEC-F07Weak password policy (length-only)P2BE-SEC-001
SEC-F08Captcha/liveness fail-open + frontend security-header/CSP gapsP2BE/FE-SEC-001
SEC-F09Container runs as root; no dependency-audit CI gateP2BE-SEC-001
SEC-F10inviteTalentToAgency links a talent without consentP2BE-SEC-001
SEC-F11pet(id) query has no auth/ownership guard (IDOR read)P2BE-SEC-001

Counts: P0 = 0 · P1 = 4 · P2 = 7. (11 findings total.)

Prioritized fix list

  1. SEC-F02 — force server-derived key prefix in getMediaUploadUrl (smallest change, stops cross-user object overwrite).
  2. SEC-F01 — field-guard User.email/status/ban fields (or project messaging to PublicUser).
  3. SEC-F04 — add account-status gate to the graphql-ws path.
  4. SEC-F03 — add depth + complexity validation rules.
  5. SEC-F11 — add the missing auth/ownership guard to pet(id) (one-line fix, closes an unauthenticated IDOR read).
  6. SEC-F05 / SEC-F07 — uniform auth errors + timing equalization + stronger password policy (same epic).
  7. SEC-F06 — shorten access-token lifetime / move refresh to HttpOnly cookie.
  8. SEC-F08 / SEC-F09 / SEC-F10 — fail-closed captcha+liveness, CSP/headers, non-root container + audit CI, agency-consent flow.

Test artifact

castyou-backend/src/__tests__/security/authzMatrix.test.ts — a table-driven authorization matrix over every root Query/Mutation/Subscription field × role, with a guard that fails when a new root field is added without a matrix entry (the field list is introspected from the live typeDefs AST). Rows whose current behavior is a vulnerability (SEC-F01) are marked it.todo/skipped with a comment linking the finding, so the suite stays green while tracking the gap.


Remediation — P1/P2 (2026-06-15)

Ticket: BE-SEC-001 (backend P1 + backend P2). Branch dev. P0 = none. Deferred findings (SEC-F06/F08/F09) are flagged at the bottom of this section. npx tsc --noEmit clean; npm test green (1357 passed, 0 todo — the 4 authzMatrix it.todo rows were converted to real passing assertions). New maintained deps: @escape.tech/graphql-armor-max-depth, @escape.tech/graphql-armor-cost-limit.

SEC-F01 — PII / moderation-state leak via Conversation.participants / Message.sender ✅ FIXED

  • Change: Added field-level guards on the User type (src/graphql/resolvers/profiles.ts). email, status, suspendedAt, suspendedUntil, banReason, verifiedAt, isAdmin now resolve to null/redacted (statusACTIVE, isAdminfalse) unless the viewer is the row's owner (ctx.user.sub === parent.id) or a non-impersonating admin. The User object returned inside an AuthPayload (login/register/social/2FA/refresh) is tagged markSelfUser() so the caller still sees their own email at sign-in even before the token is attached (src/graphql/resolvers/auth.ts). SDL types unchanged (frontend participants {...} selections never requested the private fields).
  • Tests: authzMatrix.test.ts → new describe('SEC-F01 …') (was it.todo): asserts a non-self viewer gets null/ACTIVE/false, self + admin see the real values, anon sees null.

SEC-F02 — getMediaUploadUrl arbitrary object-key prefix → cross-user overwrite ✅ FIXED

  • Change: The resolver (src/graphql/resolvers/portfolio.ts) now ALWAYS forces a server-derived media/<userId>/… key prefix. The client folder is no longer a raw prefix — it is sanitized to a single safe sub-segment (slashes/dots stripped, leading dots removed, capped at 64 chars) nested under the user prefix; filename is sanitized the same way. Defense-in-depth: generateMediaUploadUrl (src/services/media/index.ts) now rejects .., leading /, and // with BAD_USER_INPUT before building the key.
  • Tests: src/__tests__/security/beSec001.test.ts — forces media/<uid> prefix, ignores client folder hint, defeats ../profile-photos and absolute /flier-assets/... (stays under the caller's namespace); plus a service-layer traversal-reject test.

SEC-F03 — No GraphQL depth/complexity limit (DoS) ✅ FIXED

  • Change: Added validationRules to the Apollo server (src/index.ts): maxDepthRule({ n: 12, ignoreIntrospection: true }) + costLimitRule({ maxCost: 5000, objectCost: 2, scalarCost: 1, depthCostFactor: 1.5, ignoreIntrospection: true }) from @escape.tech/graphql-armor.
  • Limits chosen: maxDepth = 12 — the deepest real frontend operation is the messaging CONVERSATION_QUERY at field-depth ~4 (conversation → messages → edges → moderation); the next deepest are ~5, and there are no nested fragments. 12 leaves generous headroom for legitimate deep queries while blocking the audit's abusive author→posts→author… / follow-graph recursion (needs 8+). maxCost = 5000 with object=2/scalar=1/depthFactor=1.5 bounds total resolver fan-out. Introspection is exempted. Verified the real CONVERSATION_QUERY validates clean against the limits.
  • Tests: beSec001.test.ts — rules accept a shallow realistic query and reject a 20-level recursive query.

SEC-F04 — WS subscriptions not gated by account status ✅ FIXED

  • Change: Two layers in src/index.ts + src/graphql/resolvers/messaging.ts. (1) isParticipant (the withFilter predicate for every messaging subscription) now also rejects any non-ACTIVE user, so per-payload delivery stops the moment status flips. (2) The graphql-ws useServer config gained an onSubscribe hook that re-authenticates the connection token per subscribe (re-reading DB tokenVersion + status, lazily lifting expired suspensions) and throws ACCOUNT_BANNED/ACCOUNT_SUSPENDED for non-ACTIVE callers — parity with the HTTP accountStatusPlugin.
  • Tests: authzMatrix.test.ts → new describe('SEC-F04 …') (was it.todo): isParticipant delivers to ACTIVE participants, rejects SUSPENDED/BANNED, and rejects non-participants.

SEC-F05 — User / account enumeration ✅ FIXED

  • Change: (1) login (auth.ts) now returns a uniform Invalid credentials. (UNAUTHENTICATED) for unknown-email, social-only, and wrong-password alike, and ALWAYS runs a bcrypt.compare — against a real dummy cost-12 hash when the user is missing/passwordless — to equalize timing. (2) joinWaitlist (waitlist.ts) is now idempotent-success (returns the existing row) instead of throwing ALREADY_ENLISTED. (3) searchMessageRecipients (src/services/messaging/index.ts) dropped email from its OR match clause.
  • Tests: auth.test.ts (uniform error + bcrypt-runs-for-missing-user), waitlist.test.ts (idempotent success, no second create), src/__tests__/services/messaging.test.ts (OR clause has no email).

SEC-F07 — Weak password policy ✅ FIXED

  • Change: Added a shared PasswordSchema (src/graphql/validation/index.ts): 10–128 chars + ≥3 of 4 character classes (lower/upper/digit/symbol). Wired into RegisterInputSchema, ChangePasswordInputSchema, createAdminUser, and admin resetUserPassword (admin.ts now validates via the shared schema instead of an inline length >= 8). No heavyweight zxcvbn/HIBP dependency added.
  • Tests: admin.test.ts (rejects too-short AND long-but-single-class). Register/changePassword success-path tests updated to compliant passwords.
  • Change: inviteTalentToAgency (src/graphql/resolvers/agency.ts) now creates the link as PENDING (not auto-ACTIVE). Added respondToAgencyInvite(agencyId, accept) (TALENT-gated, scoped to the caller's own profile): accept flips PENDING→ACTIVE, decline deletes the link. bulkApplyToJob already filters status:'ACTIVE', so a PENDING invite can no longer be used to apply on a talent's behalf. SDL: new respondToAgencyInvite mutation (AgencyTalentLink.status is a free String, so PENDING needs no migration).
  • Tests: agency.test.ts — invite creates PENDING; respond accept→ACTIVE, decline→delete, no-pending→NOT_FOUND, non-talent→FORBIDDEN. authzMatrix inviteTalentToAgency finding-tag removed (now a real anon-deny probe) + respondToAgencyInvite classified TALENT.

SEC-F11 — pet(id) unguarded IDOR read ✅ FIXED

  • Change: pet(id) (src/graphql/resolvers/petOwner.ts) now requireAuth then asserts the pet's ownerId matches the caller's PetOwnerProfile.id (admins exempt for moderation); reads nothing before authenticating.
  • Tests: petOwner.test.ts — owner sees own pet, anon→UNAUTHENTICATED (no DB read), other owner→FORBIDDEN, admin reads any, missing→NOT_FOUND. authzMatrix pet finding-tag removed (now a real anon-deny probe).

Extra — logoutEverywhere (revoke-all-sessions) ✅ ADDED

  • Change: New logoutEverywhere mutation (auth.ts + SDL), AUTH-tier, blocked during impersonation. Bumps the caller's tokenVersion, so every outstanding access + refresh token fails authenticateToken's version check (including the current one). Frontend Settings will wire to it later.
  • Tests: beSec001.test.ts — bumps tokenVersion; rejects anon. authzMatrix entry added.

Extra — PERF-F15 (portfolio reorder N+1) ✅ FIXED (routed from the perf audit)

  • Change: reorderPortfolioItems / reorderPortfolioFolders (portfolio.ts) replaced the $transaction([...N updates]) with a single parameterized UPDATE … SET "order" = CASE id WHEN … END WHERE id IN (…) via Prisma.sql/Prisma.join (helper applyOrderByCase). N round-trips → 1; ownership check unchanged.
  • Tests: portfolio.test.ts — reorder issues exactly one $executeRaw and zero per-row updates.

Deferred (NOT implemented this ticket — flagged)

  • SEC-F06 (access token in localStorage / 7-day JWT) — frontend + an architectural decision (httpOnly-cookie migration / shorten JWT_EXPIRES_IN). Belongs to FE-SEC-001. Note: the new logoutEverywhere gives the Settings UI a real revoke-all hook to lean on in the interim.
  • SEC-F08 (security headers / CSP, Turnstile/liveness fail-open) — the header/CSP portion is nginx / Next host config (infra/frontend), not resolvers. NOTE: the fail-closed-captcha + required-LIVENESS_SECRET-in-prod portions are backend env/config and were left for a follow-up — they are config hardening, not a resolver change, and out of this ticket's resolver scope.
  • SEC-F09 (container non-root + pnpm/npm audit CI gate) — Dockerfile + CI; belongs to TEST-PSA-001.

Remediation — P1/P2 (2026-06-15): FE-SEC-001 (frontend)

Scope: the FE-scoped findings (SEC-F06 partial, SEC-F08 FE half) + the FE-SEC hardening called for in the epic. pnpm typecheck + pnpm --filter @castyou/app lint pass; app suite 555/555, DS suite 39/39.

All ad-hoc external/user-content anchors were replaced with the DS ExternalLink (scheme-allowlisted, always rel="noopener noreferrer", inert <span> for unsafe schemes). Replaced in: ProfilePage (talent socials, producer portfolio links + legacy URLs, IMDb, producer socials, portfolio link tiles), ProducerProfilePage (portfolio links, website, IMDb), TalentProfilePage (portfolio link tiles), PetOwnerProfilePage (instagram/tiktok/website), ConversationPage + AdminConversationPage (message media URLs — user content), ApplicantDetailModal (submitted-material files), AdminUserDetailPage (user website), ExportPitchModal (download), JobFlierPage + PetJobFlierPage (job share URL), and landing privacy/page.tsx (Cloudflare/Google/EDPB). Raw target="_blank" after: landing = 0; app = 5, all in 2 admin files (AdminSupportTicketPage, AdminPostDetailPage) and all pointing at internal same-origin /admin/... routes. Those are intentionally NOT converted: they are not user-content/external links (no window.opener/referrer/javascript: risk), they already carry rel="noopener noreferrer", and ExternalLink by design rejects relative URLs (would render them inert/broken).

Logout clears the query cache (CLOSED)

apps/app/src/stores/auth.tsclearAuth() now calls queryClient.clear() before resetting the store. Because every logout path (manual logout, the 401 handler in graphqlClient, AccountBlockedPage, both error boundaries) funnels through clearAuth, no previous-session data can linger in the React Query cache after logout / user switch.

PasswordStrengthMeter (CLOSED)

Added the DS PasswordStrengthMeter under the password field in the register form (apps/app/src/pages/auth/RegisterPage.tsx) and the change-password form (ProfilePage.tsx ChangePasswordForm), shown once the user starts typing. UX hint only — server-side policy (SEC-F07, BE) remains the control.

Error sanitization (SEC-F08 FE half, CLOSED)

apps/app/src/lib/errorBus.ts parseGraphqlError (feeds the global toast bridge) no longer echoes raw backend strings: it surfaces the server message only for an allowlist of user-facing codes (BAD_USER_INPUT, FORBIDDEN, NOT_FOUND, QUOTA_EXCEEDED, rate-limit, account-status, …). Any other / unknown / INTERNAL_SERVER_ERROR / no-code error → a generic "An unexpected error occurred" with the real detail console.error-logged. Plain network failures get a fixed "cannot reach the server" message instead of the raw fetch text. ErrorBoundary (components/ErrorBoundary.tsx) likewise stopped rendering Server error: ${parsed.message} — graphql/render crashes now use ErrorPage's generic copy (the detail is still reported via REPORT_CLIENT_ERROR). Covered by apps/app/src/__tests__/lib/errorBus.test.ts.

DEFERRED — "Log out everywhere" / active sessions (SEC-F06)

Not built: needs a backend logoutEverywhere mutation (bump tokenVersion server-side) + regenerated GraphQL codegen, neither present in this repo. A clear // TODO(Epic 38 FE-SEC): wire to logoutEverywhere mutation once codegen is regenerated placeholder sits in apps/app/src/pages/profile/ProfilePage.tsx, immediately after the "Change password" button (where the control will live). No gql operation was invented against the non-existent schema field, to keep codegen/typecheck green.

Out of FE scope (noted)

SEC-F06's access-token-lifetime/HttpOnly-cookie move and SEC-F08's frontend-host CSP/security headers are infra/hosting-config (not source) — not changed here.