Skip to content

Fix path-injection in /paths/compare by confining inputs to the sites root - #4267

Merged
wojtekn merged 1 commit into
trunkfrom
fix-path-injection-paths-compare
Jul 23, 2026
Merged

Fix path-injection in /paths/compare by confining inputs to the sites root#4267
wojtekn merged 1 commit into
trunkfrom
fix-path-injection-paths-compare

Conversation

@wojtekn

@wojtekn wojtekn commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Related issues

  • Related to code scanning alerts #207 and #208

How AI was used in this PR

Claude Code fetched both CodeQL alerts, traced the taint flow, proposed the "confine to a safe root" mitigation, wrote the helper and tests, and ran lint/typecheck/tests. I reviewed the approach and the diff.

Proposed Changes

Two high-severity CodeQL js/path-injection alerts flagged the unauthenticated, loopback-only POST /api/paths/compare endpoint in the local server: it passed request-body path1/path2 straight into arePathsEqual, which calls fs.statSync(path.resolve(...)). A crafted ../ payload could probe arbitrary filesystem paths outside the sites directory.

This confines both operands to the server's sitesRoot before they reach any filesystem call. Anything outside sitesRoot cannot be a Studio site, so a non-match is the correct answer and the untrusted value never reaches statSync unguarded. The shared arePathsEqual helper is intentionally left untouched, since its other callers (CLI, desktop IPC) legitimately compare arbitrary absolute site paths that come from trusted local config.

No user-visible behavior change for legitimate use: the UI only ever compares real site paths under sitesRoot.

Testing Instructions

  • npm test -- packages/common/lib/tests/fs-utils.test.ts — new isPathWithin tests cover the root itself, descendants, ../ escapes, and the sibling-prefix edge case (sites-other must not count as inside sites).
  • npm run typecheck passes across all workspaces.
  • Manual: POST /api/paths/compare with two real site paths under the sites root returns { equal: true } when they match; a body with a ../ traversal path returns { equal: false }.

Pre-merge Checklist

  • Have you checked for TypeScript, React or other console errors?
… alerts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@wpmobilebot

Copy link
Copy Markdown
Collaborator

📊 Performance Test Results

Comparing ac6b06f vs trunk

app-size

Metric trunk ac6b06f Diff Change
App Size (Mac) 1355.67 MB 1355.67 MB +0.00 MB ⚪ 0.0%

site-editor

Metric trunk ac6b06f Diff Change
load 1040 ms 1092 ms +52 ms 🔴 5.0%

site-startup

Metric trunk ac6b06f Diff Change
siteCreation 7004 ms 7002 ms 2 ms ⚪ 0.0%
siteStartup 2356 ms 2354 ms 2 ms ⚪ 0.0%

Results are median values from multiple test runs.

Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change (<50ms diff)

@wojtekn
wojtekn requested review from a team and youknowriad July 21, 2026 09:02

@epeicher epeicher left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I have tested and reviewed the changes, and they LGTM!

Image
@wojtekn
wojtekn merged commit 0c1f22f into trunk Jul 23, 2026
15 checks passed
@wojtekn
wojtekn deleted the fix-path-injection-paths-compare branch July 23, 2026 09:25
wojtekn added a commit that referenced this pull request Jul 28, 2026
…solve (#4324)

## Related issues

- Related to code scanning alerts
[#207](https://github.com/Automattic/studio/security/code-scanning/207)
and
[#208](https://github.com/Automattic/studio/security/code-scanning/208)
- Follow-up to #4267, which added a sound runtime guard but left both
CodeQL alerts open

## How AI was used in this PR

Claude Code confirmed via the GitHub API that both alerts were still
`open` on the latest `trunk` scan (which already contains #4267), traced
why CodeQL's taint tracker did not treat the previous `isPathWithin`
guard as a barrier, designed the restructured barrier pattern, wrote the
helper and tests, and ran lint/typecheck/tests. I reviewed the approach
and the diff.

## Proposed Changes

PR #4267 confined the unauthenticated, loopback-only `POST
/api/paths/compare` inputs to the sites root, which is a correct runtime
mitigation — but the two high-severity `js/path-injection` alerts stayed
**open** after it merged. CodeQL does not recognize the standalone
`isPathWithin(...)` boolean check as a sanitizer: the untrusted values
that actually reached `fs.statSync` (via `path.resolve` inside
`arePathsEqual`) were the *raw* request values, so the taint flow
`req.body → path1/path2 → statSync` was never broken in CodeQL's model.

This restructures the endpoint into the resolve → confine-to-root →
use-the-returned-value pattern that CodeQL treats as a barrier:

- A new `confineToRoot( root, candidate )` helper resolves the candidate
against the root, checks it is the root or a descendant, and **returns
the normalized absolute path** (or `null` when it escapes).
`isPathWithin` now delegates to it.
- The `/paths/compare` handler passes only the confined, resolved paths
into `arePathsEqual` — the raw, untrusted request values never reach any
filesystem call.
- As additional hardening, relative candidates now resolve against the
sites root rather than the process cwd.

No user-visible behavior change for legitimate use: the UI only ever
compares real site paths under the sites root, which still compare
correctly. The shared `arePathsEqual` helper is intentionally left
untouched, since its other callers (CLI, desktop IPC, AI sessions)
legitimately compare arbitrary absolute site paths that come from
trusted local config.

> Note: CodeQL can only re-confirm the alerts as resolved on the next
scan after this lands on `trunk`. If the model still flags it, the
fallback is a CodeQL sanitizer model or a justified dismissal — but the
restructured flow is expected to clear it at the source.

## Testing Instructions

- `npm test -- packages/common/lib/tests/fs-utils.test.ts` — new
`confineToRoot` tests cover the root itself, descendants, in-root `..`
normalization, `../` escapes, the sibling-prefix edge case
(`studio-sites-other` must not count as inside `studio-sites`), and
relative-candidate resolution against the root. Existing `isPathWithin`
tests still pass.
- `npm run typecheck` passes across all workspaces.
- Manual: `POST /api/paths/compare` with two real site paths under the
sites root returns `{ equal: true }` when they match; a body with a
`../` traversal path returns `{ equal: false }`.

## Pre-merge Checklist

- [x] Have you checked for TypeScript, React or other console errors?

---------

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

None yet

3 participants