Appearance
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-45requireOwnedItem/requireOwnedFolder), folders (folders.ts:16-25getProducerProfile+ service-levelprofile.idscoping), 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'andisAdminis force-set tofalsein context (context.ts:103);requireAdminthrows ifctx.impersonationis 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), andsuspendUser/banUser/logout/changePassword/refreshToken/adminresetUserPasswordall bumptokenVersionso existing access tokens are invalidated (admin.ts:212,236,257,359,auth.ts:394,405,431).authenticateTokenrejects any token whosetokenVersionis stale (context.ts:83). - Upload size/type are enforced server-side, and
Content-Lengthis 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.activeStrikeCountresolves to0for non-admins (adminStrikes.ts:16-20);SupportTicket.moderationScanreturns 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
*, withcredentials:true(index.ts:52). Introspection is disabled in production (index.ts:91)..envis git-ignored and not tracked. No secrets found in the frontend source/bundle. - Refresh tokens rotate (single-use:
tokenVersionincrements 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.
- Authenticate as user A; open or hold any conversation with user B.
- Run:graphql
query { conversation(id: "<convId>") { participants { id email status banReason suspendedUntil isAdmin } messages(first: 1) { edges { sender { email banReason } } } } } - 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.
- Authenticate as any user.
- graphqlThis presigns a
mutation { getMediaUploadUrl( filename: "<victimUserId>", contentType: "image/jpeg", folder: "profile-photos") { uploadUrl key } }PUTfor keyprofile-photos/<ts>_<victimUserId>.jpg. NoteuploadProfilePhotowrites deterministic keyprofile-photos/${userId}.${ext}(media/index.ts:81); the prefix is shared. A craftedfolder/filenamecan 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/following → FollowEdge.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.
- User A opens a WS subscription (
messageAdded/conversationUpdated). - An admin bans/suspends A.
banUserbumpstokenVersion(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.
logindistinguishes 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.registerreturns a generic message (good,auth.ts:110-114), butbcrypt.compareonly runs when the user exists; a missing user returns immediately (auth.ts:142-145) → a timing oracle distinguishing existing vs non-existing accounts.joinWaitlistreturns "This email is already on the waitlist." (waitlist.ts:69-73) — waitlist-membership enumeration.searchMessageRecipientsmatches users byemailsubstring 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.
verifyTurnstileTokenis a no-op whenTURNSTILE_SECRET_KEYis 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_SECREThas 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
contentSecurityPolicyis only enabled in production (index.ts:51); confirm theapps/landingandapps/appstatic hosts also send CSP,X-Content-Type-Options,frame-ancestors/X-Frame-Options, andReferrer-Policy— these were not found configured in the frontend hosting config during this review. formatErrorreturns 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.
SEC-F10 — inviteTalentToAgency links a talent without consent · P2 · BE-SEC-001
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)usesrequireAuth, notrequireAdmin(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
| ID | Title | Severity | Scope |
|---|---|---|---|
| SEC-F01 | Email + moderation state leak via Conversation.participants / Message.sender | P1 | BE-SEC-001 |
| SEC-F02 | getMediaUploadUrl arbitrary object-key prefix → cross-user overwrite | P1 | BE-SEC-001 |
| SEC-F03 | No GraphQL depth/complexity limit (DoS) | P1 | BE-SEC-001 |
| SEC-F04 | WS subscriptions not gated by account status; open sockets survive ban | P1 | BE-SEC-001 |
| SEC-F05 | User/account enumeration via login/register/reset/waitlist | P2 | BE-SEC-001 |
| SEC-F06 | Access token in localStorage + 7-day JWT lifetime (XSS theft) | P2 | FE-SEC-001 |
| SEC-F07 | Weak password policy (length-only) | P2 | BE-SEC-001 |
| SEC-F08 | Captcha/liveness fail-open + frontend security-header/CSP gaps | P2 | BE/FE-SEC-001 |
| SEC-F09 | Container runs as root; no dependency-audit CI gate | P2 | BE-SEC-001 |
| SEC-F10 | inviteTalentToAgency links a talent without consent | P2 | BE-SEC-001 |
| SEC-F11 | pet(id) query has no auth/ownership guard (IDOR read) | P2 | BE-SEC-001 |
Counts: P0 = 0 · P1 = 4 · P2 = 7. (11 findings total.)
Prioritized fix list
- SEC-F02 — force server-derived key prefix in
getMediaUploadUrl(smallest change, stops cross-user object overwrite). - SEC-F01 — field-guard
User.email/status/ban fields (or project messaging toPublicUser). - SEC-F04 — add account-status gate to the
graphql-wspath. - SEC-F03 — add depth + complexity validation rules.
- SEC-F11 — add the missing auth/ownership guard to
pet(id)(one-line fix, closes an unauthenticated IDOR read). - SEC-F05 / SEC-F07 — uniform auth errors + timing equalization + stronger password policy (same epic).
- SEC-F06 — shorten access-token lifetime / move refresh to HttpOnly cookie.
- 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
Usertype (src/graphql/resolvers/profiles.ts).email,status,suspendedAt,suspendedUntil,banReason,verifiedAt,isAdminnow resolve tonull/redacted (status→ACTIVE,isAdmin→false) unless the viewer is the row's owner (ctx.user.sub === parent.id) or a non-impersonating admin. TheUserobject returned inside anAuthPayload(login/register/social/2FA/refresh) is taggedmarkSelfUser()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 (frontendparticipants {...}selections never requested the private fields). - Tests:
authzMatrix.test.ts→ newdescribe('SEC-F01 …')(wasit.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-derivedmedia/<userId>/…key prefix. The clientfolderis 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;filenameis sanitized the same way. Defense-in-depth:generateMediaUploadUrl(src/services/media/index.ts) now rejects.., leading/, and//withBAD_USER_INPUTbefore building the key. - Tests:
src/__tests__/security/beSec001.test.ts— forcesmedia/<uid>prefix, ignores client folder hint, defeats../profile-photosand 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
validationRulesto 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_QUERYat 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 abusiveauthor→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 realCONVERSATION_QUERYvalidates 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(thewithFilterpredicate for every messaging subscription) now also rejects any non-ACTIVEuser, so per-payload delivery stops the moment status flips. (2) Thegraphql-wsuseServerconfig gained anonSubscribehook that re-authenticates the connection token per subscribe (re-reading DBtokenVersion+status, lazily lifting expired suspensions) and throwsACCOUNT_BANNED/ACCOUNT_SUSPENDEDfor non-ACTIVE callers — parity with the HTTPaccountStatusPlugin. - Tests:
authzMatrix.test.ts→ newdescribe('SEC-F04 …')(wasit.todo):isParticipantdelivers to ACTIVE participants, rejects SUSPENDED/BANNED, and rejects non-participants.
SEC-F05 — User / account enumeration ✅ FIXED
- Change: (1)
login(auth.ts) now returns a uniformInvalid credentials.(UNAUTHENTICATED) for unknown-email, social-only, and wrong-password alike, and ALWAYS runs abcrypt.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 throwingALREADY_ENLISTED. (3)searchMessageRecipients(src/services/messaging/index.ts) droppedemailfrom itsORmatch 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 noemail).
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 intoRegisterInputSchema,ChangePasswordInputSchema,createAdminUser, and adminresetUserPassword(admin.tsnow validates via the shared schema instead of an inlinelength >= 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.
SEC-F10 — inviteTalentToAgency links a talent without consent ✅ FIXED
- Change:
inviteTalentToAgency(src/graphql/resolvers/agency.ts) now creates the link asPENDING(not auto-ACTIVE). AddedrespondToAgencyInvite(agencyId, accept)(TALENT-gated, scoped to the caller's own profile): accept flipsPENDING→ACTIVE, decline deletes the link.bulkApplyToJobalready filtersstatus:'ACTIVE', so aPENDINGinvite can no longer be used to apply on a talent's behalf. SDL: newrespondToAgencyInvitemutation (AgencyTalentLink.statusis a freeString, soPENDINGneeds no migration). - Tests:
agency.test.ts— invite createsPENDING; respond accept→ACTIVE, decline→delete, no-pending→NOT_FOUND, non-talent→FORBIDDEN. authzMatrixinviteTalentToAgencyfinding-tag removed (now a real anon-deny probe) +respondToAgencyInviteclassifiedTALENT.
SEC-F11 — pet(id) unguarded IDOR read ✅ FIXED
- Change:
pet(id)(src/graphql/resolvers/petOwner.ts) nowrequireAuththen asserts the pet'sownerIdmatches the caller'sPetOwnerProfile.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. authzMatrixpetfinding-tag removed (now a real anon-deny probe).
Extra — logoutEverywhere (revoke-all-sessions) ✅ ADDED
- Change: New
logoutEverywheremutation (auth.ts+ SDL), AUTH-tier, blocked during impersonation. Bumps the caller'stokenVersion, so every outstanding access + refresh token failsauthenticateToken's version check (including the current one). Frontend Settings will wire to it later. - Tests:
beSec001.test.ts— bumpstokenVersion; 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 parameterizedUPDATE … SET "order" = CASE id WHEN … END WHERE id IN (…)viaPrisma.sql/Prisma.join(helperapplyOrderByCase). N round-trips → 1; ownership check unchanged. - Tests:
portfolio.test.ts— reorder issues exactly one$executeRawand zero per-rowupdates.
Deferred (NOT implemented this ticket — flagged)
- SEC-F06 (access token in
localStorage/ 7-day JWT) — frontend + an architectural decision (httpOnly-cookie migration / shortenJWT_EXPIRES_IN). Belongs to FE-SEC-001. Note: the newlogoutEverywheregives 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 auditCI 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.
ExternalLink rollout (user-content + external links)
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.ts — clearAuth() 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.