Skip to content

Employers can read job seekers' private application notes #72

Description

@TortoiseWolfe

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:

  1. 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.
  2. 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.
  3. Filter out status = 'not_applied' so bookmarks aren't surfaced as applications.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsecurity

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions