Summary
Employers can read job seekers' private, free-text notes about them. The notes field is part of the worker's own private job-hunt tracker, it is rendered verbatim in the employer's application drawer, and nothing in the worker UI discloses that an employer will ever see it.
A worker writing "manager seemed sketchy, this is my backup option" is showing that sentence to that employer.
Chain
1. RLS grants the whole row. Feature 063 (migration ~2773-2795) lets an employer linked to a shared_company SELECT every job_applications row pointing at that company. The policy is row-level; it cannot exclude a column.
2. The hook selects everything. src/hooks/useEmployerApplications.ts:
const APP_SELECT = `
*,
user_profiles!job_applications_user_id_profile_fkey(display_name, username),
shared_companies!job_applications_shared_company_id_fkey(name)
`;
The leading * pulls notes (migration line 1495, TEXT CHECK (length(notes) <= 5000)) along with priority, follow_up_date, and outcome.
3. It is rendered. src/components/molecular/ApplicationDetailDrawer/ApplicationDetailDrawer.tsx:166-172 renders {app.notes} directly. The drawer is imported at src/app/employer/page.tsx:8 and mounted at line 350 — so it is on the employer's Applications tab, one row-click away.
Note that even if the render were removed, the data would still reach the employer's browser in the network response. The fix has to be at the query or policy layer, not the component.
Also exposed by the same *
priority (1–5) — the worker's private ranking of this employer
follow_up_date
outcome, including 'ghosted'
Related: no status filter
useEmployerApplications never filters by status — both the meta query and the page query use .in('shared_company_id', ids) with no status predicate. job_applications.status defaults to 'not_applied', so employers also see rows for workers who merely bookmarked them as a target and never applied. Combined with the notes leak, an employer can read a private shortlist and the candidate's unfiltered opinion of them.
Suggested fix
Decide the contract first — which columns are the worker's and which are shared — then enforce it in this order:
- Replace
* with an explicit column list in APP_SELECT. Fastest mitigation, but not a security boundary on its own since RLS still permits the columns.
- Add a database view or column-limited grant for the employer read path so the private columns cannot be selected at all, regardless of client code.
- Filter out
status = 'not_applied' so bookmarks aren't surfaced as applications.
- If a shared note is genuinely wanted, add a separate
applicant_message column that the worker knowingly fills in, and leave notes private.
Tests
- An employer query for a company's applications returns no
notes, priority, or follow_up_date
- A worker's
not_applied row does not appear in the employer's list
- Contract/RLS test asserting the private columns are unreachable from an employer session
Disclosure
Worth checking whether any real worker data exists in production before deciding how quietly to fix this.
Summary
Employers can read job seekers' private, free-text notes about them. The notes field is part of the worker's own private job-hunt tracker, it is rendered verbatim in the employer's application drawer, and nothing in the worker UI discloses that an employer will ever see it.
A worker writing "manager seemed sketchy, this is my backup option" is showing that sentence to that employer.
Chain
1. RLS grants the whole row. Feature 063 (migration ~2773-2795) lets an employer linked to a
shared_companySELECT everyjob_applicationsrow pointing at that company. The policy is row-level; it cannot exclude a column.2. The hook selects everything.
src/hooks/useEmployerApplications.ts:The leading
*pullsnotes(migration line 1495,TEXT CHECK (length(notes) <= 5000)) along withpriority,follow_up_date, andoutcome.3. It is rendered.
src/components/molecular/ApplicationDetailDrawer/ApplicationDetailDrawer.tsx:166-172renders{app.notes}directly. The drawer is imported atsrc/app/employer/page.tsx:8and mounted at line 350 — so it is on the employer's Applications tab, one row-click away.Note that even if the render were removed, the data would still reach the employer's browser in the network response. The fix has to be at the query or policy layer, not the component.
Also exposed by the same
*priority(1–5) — the worker's private ranking of this employerfollow_up_dateoutcome, including'ghosted'Related: no status filter
useEmployerApplicationsnever filters by status — both the meta query and the page query use.in('shared_company_id', ids)with no status predicate.job_applications.statusdefaults to'not_applied', so employers also see rows for workers who merely bookmarked them as a target and never applied. Combined with the notes leak, an employer can read a private shortlist and the candidate's unfiltered opinion of them.Suggested fix
Decide the contract first — which columns are the worker's and which are shared — then enforce it in this order:
*with an explicit column list inAPP_SELECT. Fastest mitigation, but not a security boundary on its own since RLS still permits the columns.status = 'not_applied'so bookmarks aren't surfaced as applications.applicant_messagecolumn that the worker knowingly fills in, and leavenotesprivate.Tests
notes,priority, orfollow_up_datenot_appliedrow does not appear in the employer's listDisclosure
Worth checking whether any real worker data exists in production before deciding how quietly to fix this.