Skip to content

feat(mirrors): reject prefixes that overlap another product - #444

Open
alukach wants to merge 2 commits into
mainfrom
mirror-prefix-overlap-check
Open

feat(mirrors): reject prefixes that overlap another product#444
alukach wants to merge 2 commits into
mainfrom
mirror-prefix-overlap-check

Conversation

@alukach

@alukach alukach commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

What

Prevents a product mirror's prefix from overlapping another product's prefix on the same data connection (= same bucket). Follow-up to the deferred ponytail: note in #440.

Because storage keys are built by literal concatenation (${prefix}${key}), if one product's slash-normalized prefix is a string-prefix of another's, their keyspaces overlap and one product could list/read the other's objects (e.g. alice/foo/ vs alice/foo/bar/, or a root "" prefix vs anything).

How

  • Adds findPrefixConflict(connectionId, prefix, self) reusing the existing productsTable.listProductsByConnectionId (full scan, app-side filter — already robust to legacy region-keyed mirror map keys). Excludes the product being edited.
  • Overlap = nested either direction (mutual startsWith on slash-normalized prefixes; equal counts; root "" overlaps everything since it owns the whole bucket).
  • Enforced in both updateMirrorPrefix (free-form user input) and addProductMirror (guards a prefix_template that omits {{repository.repository_id}}, or a second product attached at a shared root prefix).

Tests

src/lib/actions/product-mirrors.test.ts: adds listProductsByConnectionId to the mock (defaults to [] so existing tests are unaffected) and covers nested-under, contains, self-exclusion (allowed), non-overlapping (allowed), plus an addProductMirror collision case. npx jest ... --forceExit → 25 passed. tsc --noEmit clean.

Note on base branch

Stacked on worktree-fix-mirror-links-prefix-edit (#440) because updateMirrorPrefix/addProductMirror prefix handling only exists there, not on main. Retarget to main once #440 merges.

Trade-off

listProductsByConnectionId is a full table scan (no GSI on the mirror-map connection_id). Fine for this low-traffic admin path; marked with a ponytail: note pointing to a denormalized connection index if product volume grows.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Jul 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
source-cooperative Ready Ready Preview, Comment Jul 20, 2026 11:28pm

Request Review

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 2m 44s —— View job


✅ No blocking issues — safe to merge.

Reviewed findPrefixConflict and its two call sites (addProductMirror, updateMirrorPrefix) plus the new tests in src/lib/actions/product-mirrors.test.ts. The core logic (slash-normalization, mutual startsWith, self-exclusion) is correct and well-tested for the common cases.

Two non-blocking notes for follow-up:

  • src/lib/actions/product-mirrors.ts:61-63Object.values(p.metadata.mirrors).find((mm) => mm.connection_id === connectionId) only inspects the first mirror entry on a sibling that matches connectionId. The file's own docstring for listProductsByConnectionId (src/lib/clients/database/products.ts:110-115) notes it must stay "robust to legacy mirrors keyed by region... rather than by connection id" — implying a product's mirrors map can hold more than one entry pointing at the same connection_id (e.g. a legacy region-keyed entry plus a new connection-id-keyed one). If a sibling has two such entries with different prefixes, only the first one find() happens to return is checked for overlap; a real overlap on the second entry would be silently missed. Since this is precisely the security boundary the PR is adding, consider checking all matching mirrors, not just the first:

    const overlapping = Object.values(p.metadata.mirrors).some(
      (mm) =>
        mm.connection_id === connectionId &&
        prefixesOverlap(normalized, normalizePrefix(mm.prefix))
    );
    if (overlapping) return p;
  • src/lib/actions/product-mirrors.ts:50-69findPrefixConflict is a scan-then-decide check with no locking against the other product's write path, so two concurrent addProductMirror/updateMirrorPrefix calls for different products could both read a clean state and then both write overlapping prefixes (the existing optimistic-lock in isConcurrentEdit only guards against concurrent edits of the same product). Given this is an admin/owner-gated, low-traffic path, this is likely an acceptable trade-off (similar to the ponytail scan-cost trade-off already called out) — just flagging that the race exists in case it matters for the threat model.

Both are narrow edge cases; nothing here blocks merging.

@alukach
alukach marked this pull request as ready for review July 20, 2026 23:17
Base automatically changed from worktree-fix-mirror-links-prefix-edit to main July 20, 2026 23:25
alukach and others added 2 commits July 20, 2026 16:26
On a shared data connection (= same bucket), storage keys are built by
literal concatenation, so if one product's slash-normalized prefix is a
string-prefix of another's their keyspaces overlap. Add findPrefixConflict
(reusing productsTable.listProductsByConnectionId) and enforce it in both
updateMirrorPrefix (free-form input) and addProductMirror (guards a
prefix_template missing {{repository_id}}). Overlap is nested-either-
direction; root prefix overlaps everything. Replaces the earlier deferral.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The overlap check normalized both sides for comparison, but the raw
resolveMirrorPrefix() value was still stored and later concatenated into
keys. A prefix_template without a trailing slash (optional in the schema)
therefore stored "acct/prod", which literally matches keys under a sibling
"acct/prod2/" — the exact overlap the check exists to prevent, slipping
past it silently. Normalize once, before both checking and storing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant