Skip to content

Security: unify package artifact-id normalization and reject path traversal - #486

Draft
lezama wants to merge 1 commit into
Automattic:mainfrom
lezama:fix/artifact-id-normalization-shared
Draft

Security: unify package artifact-id normalization and reject path traversal#486
lezama wants to merge 1 commit into
Automattic:mainfrom
lezama:fix/artifact-id-normalization-shared

Conversation

@lezama

@lezama lezama commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Package artifact-id / source-path normalization was duplicated across four sites with three incompatible rule-sets, and the adoption orchestrator's copy validated nothing — a path-traversal seam (CWE-22 Path Traversal / CWE-20 Improper Input Validation) into the artifact_key it builds and into the installed-artifact snapshots it records.

The four divergent copies:

  • src/Packages/class-wp-agent-package-adoption-orchestrator.php:204artifact_id() only did trim( str_replace( '\\', '/', … ) ): no leading-/ check, no .. check. Because this value flows into artifact_key (the canonical index key) and into snapshot_from_target(), it was the loose end — the no-op copy (CWE-22 / CWE-20).
  • src/Packages/class-wp-agent-package-update-planner.php:176normalize_artifact_id() rejected a leading / and str_contains( '..' ). Correct on traversal, but over-broad: it also rejected the legitimate name a..b.
  • src/Packages/class-wp-agent-package-installed-artifact.php:184prepare_id() rejected str_contains( '..' ): same over-broad rule, and inconsistent with the source check below.
  • src/Packages/class-wp-agent-package-artifact.php:241prepare_source() rejected .. only as a whole path segment (the correct rule) plus a drive-letter guard, so a..b passed here but was rejected by the planner — the three rule-sets disagreed on the same input.

Fix

Introduces one shared normalizer, WP_Agent_Package_Artifact_Identity (src/Packages/class-wp-agent-package-artifact-identity.php), following the existing class-wp-agent-package-* static-utility convention (private constructor, string_value helper), and routes all four call sites through it:

  • normalize_id() — rejects empty, rejects a leading / (absolute), and rejects any .. as a whole traversal segment; normalizes backslashes to / before the segment check so a Windows-style separator can't smuggle a .. past it. Throws InvalidArgumentException — the same rejection mechanism the existing callers already used.
  • normalize_source() — same traversal/absolute rule, additionally allows an empty value (no payload location), keeps the drive-letter guard (A:/…), and collapses empty segments.

Net effect: the traversal/leading-slash rule is now uniform across the orchestrator, planner, installed-artifact, and source paths; the orchestrator's unvalidated artifact_key path is closed first; and .. is correctly treated as dangerous only as a whole segment, so the legitimate name a..b is accepted everywhere instead of being rejected in two of four places.

Testing

Adds tests/package-artifact-id-normalization-smoke.php (registered in the composer smoke array). It fails without this fix (the orchestrator seam accepts ../x, /abs, a/../b) and passes with it, asserting:

  • Traversal / absolute rejected at every seam../x, /abs, a/../b, plus backslash variants ..\x, a\..\b, across the orchestrator (via reflection, since its copy is private), planner, installed-artifact, and both source normalizers.
  • a..b segment edge accepted consistently — previously rejected by planner + installed-artifact, now uniformly accepted.
  • Legitimate ids pass unchanged (no regression)foo, foo/bar, foo-bar_baz, and the slashed memory/agent/SOUL.md; underscores and case preserved.
  • Empty source allowed; empty id rejected; backslash normalization verified.

Gates:

  • composer smoke — all green (exit 0), including the existing package smokes.
  • vendor/bin/phpstan analyse --no-progress --memory-limit=2GNo errors.

This change came from an automated tech-debt / security audit of Automattic/agents-api. Opening as a draft for review — please sanity-check the unified traversal rule (segment-based .., backslash-normalized) against any downstream consumers of artifact_id / source before merge.

…versal

Package artifact-id / source-path normalization was duplicated across four
sites with three incompatible rule-sets, and the adoption orchestrator's copy
validated nothing at all -- an unauthenticated path-traversal seam (CWE-22 /
CWE-20) into the artifact_key it builds.

Divergent copies unified behind one shared normalizer
(WP_Agent_Package_Artifact_Identity):

- src/Packages/class-wp-agent-package-adoption-orchestrator.php artifact_id():
  only trimmed and normalized backslashes -- NO leading-slash check, NO ".."
  check. This built artifact_key, so it was the loose end (the no-op copy).
- src/Packages/class-wp-agent-package-update-planner.php normalize_artifact_id():
  rejected leading "/" and str_contains("..") -- too strict, also rejected the
  legitimate name "a..b".
- src/Packages/class-wp-agent-package-installed-artifact.php prepare_id():
  rejected str_contains("..") -- same over-broad rule.
- src/Packages/class-wp-agent-package-artifact.php prepare_source():
  rejected ".." only as a whole path segment (correct) plus a drive-letter
  guard, so "a..b" passed here but was rejected by the planner.

The shared normalizer applies the strictest CORRECT rule uniformly: reject a
leading "/", reject any ".." as a whole traversal segment (so "a..b" stays a
valid name), normalize backslashes before the segment check, and keep the
drive-letter guard on sources. All four call sites now route through it.

Adds tests/package-artifact-id-normalization-smoke.php (registered in the
composer "smoke" array), which fails without this fix: it asserts traversal /
absolute ids (../x, /abs, a/../b, plus backslash variants) are rejected at
every seam, the a..b segment edge is accepted consistently, and legitimate ids
(foo, foo/bar, foo-bar_baz, memory/agent/SOUL.md) pass unchanged.

composer smoke green; phpstan analyse reports no errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant