Skip to content

Limit grant-uploads storage access to each file's uploader - #302

Merged
SpikeyCoder merged 4 commits into
mainfrom
claude/grant-uploads-owner-policies
Oct 13, 2026
Merged

SpikeyCoder merged 4 commits into
mainfrom
claude/grant-uploads-owner-policies

Conversation

@SpikeyCoder

@SpikeyCoder SpikeyCoder commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Problem

grant-uploads is a private bucket, but its storage.objects policies (created outside migrations) only checked bucket_id:

Policy Role Check
Allow anon uploads / reads / deletes anon bucket_id = 'grant-uploads'
Allow authenticated uploads / reads / deletes authenticated bucket_id = 'grant-uploads'

The anon key ships in the frontend, so anyone could list, download and delete every user's uploaded past grants (AI Grant Writer) and reference documents (Project Workspace), signed in or not, and upload files into the bucket. I confirmed against live pg_policies that these six are the bucket's only policies and that each one covers only grant-uploads.

Fix

  • supabase/migrations/20261013030000_grant_uploads_owner_policies.sql drops those six policies and adds three for authenticated only:

    • INSERT: a user can upload only files recorded as theirs.
    • SELECT: a user can list and read only their own files.
    • DELETE: a user can delete only their own files.

    Each one requires owner_id = auth.uid()::text. Anon gets no access. There's no UPDATE policy: nothing overwrites an upload (upsert: false). Ownership is checked by owner_id, which storage sets from the uploader's JWT and grant-writer already checks, not by path. The Grant Writer's paths (<session>/<uuid>.<ext>) carry no user id. Both existing objects have owner_id set.

  • supabase/functions/ai-draft/index.ts: ai-draft downloads a knowledge-base row's storage_path with the service role. storage_path is user-writable, so a user could point their own row at another user's file and get its text into their draft.

    A path-prefix check alone isn't enough. storage-js puts the path into the request URL unencoded, so reference-docs/<me>/%2e%2e/%2e%2e/<victim>/<file> resolves as ... ai-draft now reads a file only when all of these hold:

    • its path has Project Workspace's upload shape, reference-docs/<uuid>/<uuid>/<digits>.<ext>, where <ext> has no dot, slash, backslash, %, ?, # or control character, so nothing can resolve elsewhere;
    • the path is in the caller's own folder;
    • storage.objects records the caller as owner_id, the same check grant-writer does.

    A failed ownership lookup is logged, and no file is read.

  • src/pages/GrantWriter.tsx: signed-out visitors see a "Sign in to upload past grants" link in place of the past-grants drop zone. It appears only once auth has loaded, so signed-in users never see it while their session restores. grant-writer needs a signed-in user anyway, so their uploads were never usable. The upload function keeps a sign-in check as a backstop.

Testing

Ran against a local Postgres 16 with storage.objects, auth.uid(), the anon/authenticated roles and the current production policies recreated.

Check Before After
anon lists grant-uploads sees all 2 sees 0
anon uploads allowed RLS error
anon deletes deletes all deletes 0
anon uploads to bug-screenshots (bug-reports/) allowed allowed (unchanged)
user A lists sees A's and B's sees only A's
user A uploads own file allowed allowed
user A uploads a file with B's owner_id, or no owner allowed RLS error
user A deletes B's file deletes it deletes 0
user A deletes own file allowed allowed

The ai-draft path rule accepts real upload paths, including names without a dot such as 1728.Final Proposal. It rejects %2e%2e, .., extra segments, backslashes, ?, # and newlines.

The migration also applies cleanly twice in a row. tsc -b, eslint on GrantWriter.tsx and npm test (70/70) pass. deno check on ai-draft reports the same 2 errors as main, both pre-existing.

/code-review and /security-review were run, then run again on the fixes until both came back with nothing left to fix.

Deploy

  1. Apply the migration.
  2. Deploy ai-draft.

The frontend ships with the merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm

The grant-uploads bucket is private, but its storage.objects policies only
checked bucket_id, so anyone with the public anon key could list, download
and delete every user's uploaded past grants and reference documents, and
upload into the bucket. Replace the six anon/authenticated policies with
owner-scoped ones for signed-in users: upload, read and delete only where
owner_id is the caller's auth uid. Both uploaders (AI Grant Writer and
Project Workspace reference docs) are signed in, and grant-writer already
treats owner_id as the owner; the edge functions read with the service role.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Deploying funder-finder with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0ee45e5
Status: ✅  Deploy successful!
Preview URL: https://b38c8f48.funder-finder.pages.dev
Branch Preview URL: https://claude-grant-uploads-owner-p.funder-finder.pages.dev

View logs

…rs to sign in to upload

Review follow-ups on the grant-uploads owner policies:

- ai-draft downloads a knowledge-base row's storage_path with the service
  role, and storage_path is user-writable, so a user could point their own
  row at another user's file. Only read paths under the caller's
  reference-docs/<user>/ folder, where Project Workspace uploads them.
- Signed-out visitors on the AI Grant Writer could drop files into the
  bucket; with anon access removed they would get a raw RLS error. Ask them
  to sign in instead (grant-writer needs a signed-in user anyway).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
…gn-in link

Review follow-ups:

- The ai-draft path-prefix check could be bypassed with percent-encoded dot
  segments: storage-js puts the path into the URL unencoded, so
  reference-docs/<me>/%2e%2e/%2e%2e/<victim>/<file> resolved to another
  user's file. Now a file is read only if its path has Project Workspace's
  exact upload shape in the caller's folder and storage.objects records the
  caller as owner_id, as grant-writer checks.
- The Grant Writer shows signed-out visitors a sign-in link in place of the
  past-grants drop zone (after auth finishes loading, so signed-in users
  restoring a session never see it), instead of rejecting each file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
…failed ownership lookups

Project Workspace takes the extension from the file name's last dot, or the
whole name if it has none, so paths like `.../1728.Final Proposal` are
legitimate; the 1-10 alphanumeric rule skipped them. Allow anything but a
dot, slash, backslash, %, ?, # or control character, none of which the
segment can then use to resolve elsewhere. Log the storage.objects lookup
error instead of dropping it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
@SpikeyCoder
SpikeyCoder merged commit a3610f3 into main Oct 13, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants