Skip to content

security(padid): reject ueberdb delimiter ':' in pad ids (copyPad/movePad injection)#8073

Open
JohnMcLear wants to merge 2 commits into
developfrom
security/padid-colon-injection
Open

security(padid): reject ueberdb delimiter ':' in pad ids (copyPad/movePad injection)#8073
JohnMcLear wants to merge 2 commits into
developfrom
security/padid-colon-injection

Conversation

@JohnMcLear

Copy link
Copy Markdown
Member

Fixes GHSA-wg58-mhwv-35pq (triage). Verified still present on develop.

copyPad / movePad / copyPadWithoutHistory take their destination via the destinationID API field, which — unlike padID/padName — is never run through sanitizePadId. Because isValidPadId only forbade $, a destinationID like victim:revs:0 survived into the engine. The embedded : (the ueberdb key-namespace delimiter, pad:<id>:revs:<n>) let it address another pad's internal records, which:

  1. bypassed the force=false overwrite guarddoesPadExist checks the top-level atext, but a :revs: record stores atext under meta, so it read as "doesn't exist"; and
  2. clobbered the victim pad's revision history (persistent, silent — the head text survives).

Requires the global API key / OAuth-admin (PR:H), so low severity, but it's a clean cross-pad corruption primitive.

Fix

  • isValidPadId now rejects : (never legal in a pad id — it's the DB key delimiter). Name portion excludes both $ and :.
  • Pad.copy() and Pad.copyPadWithoutHistory() validate destinationID via isValidPadId before any db write. movePad routes through copy(), so the check runs before the source is removed (a bad destination can't delete the source either).

Tests

  • isValidPadId unit cases for : rejection (incl. group-pad form).
  • Integration test: copyPad/copyPadWithoutHistory reject a :-bearing destinationID with force=false and leave the victim's rev-0 changeset untouched; a normal copy still works.
  • Existing 54 API pad tests still pass; tsc --noEmit clean.

🤖 Generated with Claude Code

…ePad injection)

GHSA-wg58-mhwv-35pq. copyPad / movePad / copyPadWithoutHistory take their
destination via the `destinationID` API field, which — unlike padID /
padName — is never run through sanitizePadId. Because isValidPadId only
forbade `$`, a destinationID like `victim:revs:0` survived into the
engine: the embedded `:` (the ueberdb key-namespace delimiter) let it
address another pad's internal `pad:<id>:revs:<n>` records. That both
bypassed the force=false "destination already exists" guard (which checks
only the top-level `atext`) and clobbered the victim pad's revision
history.

- isValidPadId now rejects `:` (never legal in a pad id; it's the DB
  key delimiter). The name portion excludes `$` and `:`.
- Pad.copy() and Pad.copyPadWithoutHistory() validate destinationID via
  isValidPadId before any db write; movePad routes through copy(), so the
  check runs before the source is removed.

Adds isValidPadId unit cases and an integration test proving copyPad /
copyPadWithoutHistory reject a `:`-bearing destinationID with force=false
and leave the victim's rev-0 changeset untouched, while a normal copy
still works. Reported privately (finder credited on the advisory).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 25, 2026 17:37
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jul 25, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Legacy ':' redirect regresses ✓ Resolved 🐞 Bug ≡ Correctness
Description
The /p/:pad route rejects pad IDs containing : before calling sanitizePadId(), so legacy
colon→underscore canonicalization no longer runs and the route returns 404. This regression is
triggered by isValidPadId() now forbidding : even though sanitizePadId() still has a `/:+/g ->
'_'` transform intended to preserve legacy access.
Code

src/node/db/PadManager.ts[R207-208]

exports.isValidPadId = (padId: string) =>
-  /^(g.[a-zA-Z0-9]{16}\$)?[^$]{1,50}$/.test(padId) && !dotSegmentPadId.test(padId);
+  /^(g.[a-zA-Z0-9]{16}\$)?[^$:]{1,50}$/.test(padId) && !dotSegmentPadId.test(padId);
Relevance

⭐⭐ Medium

They often preserve backward compatibility, but security hardening may intentionally break legacy
':' access.

PR-#7714
PR-#7962

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The sanitizer explicitly supports legacy :_ mapping, but the pad URL handler rejects any
:-containing padId before the sanitizer can run, due to the updated isValidPadId() regex.

src/node/db/PadManager.ts[169-173]
src/node/hooks/express/padurlsanitize.ts[9-28]
src/node/db/PadManager.ts[202-209]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`padurlsanitize` validates the raw URL padId with `isValidPadId()` before it calls `sanitizePadId()`. With `isValidPadId()` now rejecting `:`, requests that previously would have been normalized (e.g., `foo:bar` → `foo_bar`) are rejected early.
### Issue Context
`sanitizePadId()` still contains a legacy transform that maps `:` to `_`, but it is now bypassed for HTTP pad URLs.
### Fix Focus Areas
- src/node/hooks/express/padurlsanitize.ts[9-28]
- src/node/db/PadManager.ts[169-173]
- src/node/db/PadManager.ts[195-209]
### Suggested fix
In `padurlsanitize.ts`, compute `sanitizedPadId = await padManager.sanitizePadId(padId)` first, then validate `sanitizedPadId` with `isValidPadId(sanitizedPadId)`.
- If `sanitizedPadId !== padId`, redirect to `sanitizedPadId` (as before).
- If `!isValidPadId(sanitizedPadId)` (e.g., an actual stored legacy pad ID containing `:`), return 404.
This preserves legacy `:`→`_` redirect behavior while still preventing `:` from being treated as a valid canonical pad ID.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jul 25, 2026

Copy link
Copy Markdown

PR Summary by Qodo

Reject ':' in pad IDs to block copyPad/movePad destinationID injection

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Reject pad IDs containing ':' to prevent ueberdb key-namespace injection.
• Validate copyPad/copyPadWithoutHistory destinationID before any DB writes.
• Add unit + integration tests to ensure victim pad revision records are untouched.
Diagram

graph TD
  API["API (copy/move)"] --> V{"Validate destinationID"} --> PM["PadManager.isValidPadId"] --> PAD("Pad.copy / copyNoHistory") --> DB[("ueberdb records")]
  V --> ERR["apierror: invalid padId"]
  PAD --> OK["Copy/move proceeds"]

  subgraph Legend
    direction LR
    _api["API/Module"] ~~~ _dec{"Decision"} ~~~ _fn("Function") ~~~ _db[("Database")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Validate/sanitize destinationID at API boundary
  • ➕ Stops bad input earlier and keeps Pad methods simpler
  • ➕ Centralizes validation for all API endpoints that accept destinationID-like fields
  • ➖ Easy to miss non-API call paths (internal callers) and reintroduce bypasses
  • ➖ Sanitization can be risky if it transforms attacker-controlled IDs into unexpected but valid IDs
2. Refactor copy/move to only operate on sanitized/getPad-derived IDs
  • ➕ Reduces direct construction of ueberdb keys from raw input
  • ➕ Encourages consistent ID handling across code paths
  • ➖ More invasive change with higher regression risk
  • ➖ May require broader changes to existing call sites and behavior expectations

Recommendation: Keep the PR’s current approach: reject ':' at the validator level and enforce destinationID validation inside Pad.copy()/copyPadWithoutHistory() before any writes. This is a low-risk, defense-in-depth fix that directly protects the vulnerable write path (including movePad routing through copy) without relying on all callers to remember sanitization.

Files changed (4) +88 / -1

Bug fix (2) +21 / -1
Pad.tsValidate destinationID in copy paths before DB writes +15/-0

Validate destinationID in copy paths before DB writes

• Adds early destinationID validation to Pad.copy() and Pad.copyPadWithoutHistory() using padManager.isValidPadId(). Throws an apierror on invalid IDs (notably those containing ':'), preventing ueberdb sub-record clobbering before any write occurs.

src/node/db/Pad.ts

PadManager.tsReject ':' in isValidPadId (ueberdb delimiter hardening) +6/-1

Reject ':' in isValidPadId (ueberdb delimiter hardening)

• Updates isValidPadId() to forbid ':' in the pad name segment (in addition to '$') and documents the ueberdb key-namespace risk. This prevents colon-delimited IDs from addressing internal pad sub-record namespaces such as revs/chat.

src/node/db/PadManager.ts

Tests (2) +67 / -0
PadManager.tsAdd unit coverage for ':' rejection in isValidPadId +10/-0

Add unit coverage for ':' rejection in isValidPadId

• Adds regression assertions ensuring padManager.isValidPadId() rejects IDs containing ':' for both normal and group-pad forms.

src/tests/backend/specs/PadManager.ts

copyPadColonInjection.tsIntegration regression test for copyPad destinationID ':' injection +57/-0

Integration regression test for copyPad destinationID ':' injection

• Introduces a backend integration suite that attempts to copy into a victim pad’s revs namespace (victim:revs:0) and asserts rejection plus no mutation of the victim’s revision-0 changeset. Also verifies copyPadWithoutHistory rejects ':' destinations and that a normal copyPad still succeeds.

src/tests/backend/specs/copyPadColonInjection.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses a pad ID injection/cross-pad corruption vector by disallowing ueberdb’s key-namespace delimiter (:) in pad IDs and by validating destinationID early in the copyPad/movePad/copyPadWithoutHistory code paths before any database writes.

Changes:

  • Update PadManager.isValidPadId() to reject : (and add unit coverage for the new rule).
  • Add destinationID validation to Pad.copy() and Pad.copyPadWithoutHistory() to prevent delimiter-based DB key targeting.
  • Add an integration regression test that ensures copyPad/copyPadWithoutHistory reject :-bearing destination IDs and do not clobber another pad’s revision records.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
src/node/db/PadManager.ts Reject : in isValidPadId() to prevent ueberdb key-namespace delimiter abuse.
src/node/db/Pad.ts Validate destinationID via isValidPadId() before performing any copy-related DB writes.
src/tests/backend/specs/PadManager.ts Add unit tests ensuring :-containing pad IDs are rejected (including group-pad form).
src/tests/backend/specs/copyPadColonInjection.ts Add integration regression coverage for the destinationID : injection/corruption scenario.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/node/db/PadManager.ts
Comment on lines 207 to +208
exports.isValidPadId = (padId: string) =>
/^(g.[a-zA-Z0-9]{16}\$)?[^$]{1,50}$/.test(padId) && !dotSegmentPadId.test(padId);
/^(g.[a-zA-Z0-9]{16}\$)?[^$:]{1,50}$/.test(padId) && !dotSegmentPadId.test(padId);
Comment thread src/node/db/PadManager.ts
Comment on lines +202 to +208
// `:` is the ueberdb key-namespace delimiter (records are stored under
// `pad:<id>`, `pad:<id>:revs:<n>`, `pad:<id>:chat:<n>`). A pad id containing a
// `:` can therefore address another pad's internal sub-records, so it is never
// valid — the name portion excludes `$` (group-pad separator) and `:`.
// (GHSA-wg58-mhwv-35pq: copyPad/movePad destinationID injection.)
exports.isValidPadId = (padId: string) =>
/^(g.[a-zA-Z0-9]{16}\$)?[^$]{1,50}$/.test(padId) && !dotSegmentPadId.test(padId);
/^(g.[a-zA-Z0-9]{16}\$)?[^$:]{1,50}$/.test(padId) && !dotSegmentPadId.test(padId);
@qodo-code-review

qodo-code-review Bot commented Jul 25, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Legacy ':' redirect regresses ✓ Resolved 🐞 Bug ≡ Correctness
Description
The /p/:pad route rejects pad IDs containing : before calling sanitizePadId(), so legacy
colon→underscore canonicalization no longer runs and the route returns 404. This regression is
triggered by isValidPadId() now forbidding : even though sanitizePadId() still has a `/:+/g ->
'_'` transform intended to preserve legacy access.
Code

src/node/db/PadManager.ts[R207-208]

exports.isValidPadId = (padId: string) =>
-  /^(g.[a-zA-Z0-9]{16}\$)?[^$]{1,50}$/.test(padId) && !dotSegmentPadId.test(padId);
+  /^(g.[a-zA-Z0-9]{16}\$)?[^$:]{1,50}$/.test(padId) && !dotSegmentPadId.test(padId);
Relevance

⭐⭐ Medium

They often preserve backward compatibility, but security hardening may intentionally break legacy
':' access.

PR-#7714
PR-#7962

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The sanitizer explicitly supports legacy :_ mapping, but the pad URL handler rejects any
:-containing padId before the sanitizer can run, due to the updated isValidPadId() regex.

src/node/db/PadManager.ts[169-173]
src/node/hooks/express/padurlsanitize.ts[9-28]
src/node/db/PadManager.ts[202-209]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`padurlsanitize` validates the raw URL padId with `isValidPadId()` before it calls `sanitizePadId()`. With `isValidPadId()` now rejecting `:`, requests that previously would have been normalized (e.g., `foo:bar` → `foo_bar`) are rejected early.

### Issue Context
`sanitizePadId()` still contains a legacy transform that maps `:` to `_`, but it is now bypassed for HTTP pad URLs.

### Fix Focus Areas
- src/node/hooks/express/padurlsanitize.ts[9-28]
- src/node/db/PadManager.ts[169-173]
- src/node/db/PadManager.ts[195-209]

### Suggested fix
In `padurlsanitize.ts`, compute `sanitizedPadId = await padManager.sanitizePadId(padId)` first, then validate `sanitizedPadId` with `isValidPadId(sanitizedPadId)`.
- If `sanitizedPadId !== padId`, redirect to `sanitizedPadId` (as before).
- If `!isValidPadId(sanitizedPadId)` (e.g., an actual stored legacy pad ID containing `:`), return 404.
This preserves legacy `:`→`_` redirect behavior while still preventing `:` from being treated as a valid canonical pad ID.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread src/node/db/PadManager.ts
…s still redirect

Addresses Qodo review on #8073. Rejecting ":" in isValidPadId made
padurlsanitize's validate-first ordering return 404 for a browser
visiting a legacy `/p/<id with ":">` URL, instead of redirecting it to
the sanitized `_` form.

Reorder padurlsanitize to sanitize FIRST (sanitizePadId maps whitespace
and ":" to "_"), then validate the sanitized id. `/p/foo:bar` now
redirects to `/p/foo_bar` as before; an id that stays invalid after
sanitizing (e.g. containing "$") is still forbidden. This restores the
pre-fix redirect behavior while keeping ":" out of stored pad ids.

Adds a regression test for the ":" redirect, the "$" 404, and a clean id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 25, 2026 18:04
@JohnMcLear

Copy link
Copy Markdown
Member Author

Thanks @qodo-free-for-open-source-projects — correct, rejecting : in isValidPadId broke the legacy /p/foo:bar/p/foo_bar sanitize-redirect (it 404'd instead). Fixed in the latest commit: padurlsanitize now sanitizes before validating, so : URLs redirect to the _ form again while : stays out of stored pad ids. Added a regression test.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Thanks @qodo-free-for-open-source-projects — correct, rejecting : in isValidPadId broke the legacy /p/foo:bar/p/foo_bar sanitize-redirect (it 404'd instead). Fixed in the latest commit: padurlsanitize now sanitizes before validating, so : URLs redirect to the _ form again while : stays out of stored pad ids. Added a regression test.

Thanks for the update. Sanitizing before validation preserves the legacy redirect behavior while still preventing : from being accepted in stored pad IDs. The regression coverage looks appropriate.

@qodo-code-review

Copy link
Copy Markdown

Thanks @qodo-free-for-open-source-projects — correct, rejecting : in isValidPadId broke the legacy /p/foo:bar/p/foo_bar sanitize-redirect (it 404'd instead). Fixed in the latest commit: padurlsanitize now sanitizes before validating, so : URLs redirect to the _ form again while : stays out of stored pad ids. Added a regression test.

Thanks for the update. The sanitize-before-validation ordering preserves legacy : URL redirects while keeping : invalid for stored pad IDs and copy destinations. The regression coverage looks appropriate.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

src/node/db/PadManager.ts:208

  • The updated isValidPadId regex still allows ':' in the group-pad prefix because g. uses . (any character) instead of a literal dot. That means pad IDs like g:AAAAAAAAAAAAAAAA$pad would pass validation even though they contain the ueberdb delimiter, undermining the intent of rejecting ':' everywhere and also loosening the documented groupID format (g.<16 chars>).
exports.isValidPadId = (padId: string) =>
  /^(g.[a-zA-Z0-9]{16}\$)?[^$:]{1,50}$/.test(padId) && !dotSegmentPadId.test(padId);

Comment on lines +11 to +27
// Reject URLs ending in `/` outright.
if (/\/$/.test(req.url)) {
res.status(404).send('Such a padname is forbidden');
return;
}

// Sanitize FIRST, then validate the sanitized result. sanitizePadId maps
// legacy characters (whitespace and the ueberdb delimiter `:`) to `_`, so
// a URL like `/p/foo:bar` still redirects to `/p/foo_bar` even though `:`
// is not itself a valid pad id (GHSA-wg58-mhwv-35pq). An id that stays
// invalid after sanitizing (e.g. one containing `$`) is forbidden.
const sanitizedPadId = await padManager.sanitizePadId(padId);

if (!padManager.isValidPadId(sanitizedPadId)) {
res.status(404).send('Such a padname is forbidden');
return;
}
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