diff --git a/CHANGELOG.md b/CHANGELOG.md index 714c39e..ba2e2b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] -## [1.0.0-rc.1] - 2026-09-29 +## [1.0.0-rc.1] - 2026-09-30 ### Added diff --git a/README.md b/README.md index c0d293e..4876d45 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,17 @@ ![Maatify.dev](https://www.maatify.dev/assets/img/img/maatify_logo_white.svg) -[![Status](https://img.shields.io/badge/Status-Development-blue)](README.md) +[![Status](https://img.shields.io/badge/Status-Pre--Release-blue)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-dev--main-blue)](https://packagist.org/packages/maatify/php-return-target) [![PHP](https://img.shields.io/badge/PHP-8.4-8892BF)](composer.json) [![License](https://img.shields.io/badge/License-Proprietary-green)](LICENSE) [![PHPStan](https://img.shields.io/badge/PHPStan-max-blue)](phpstan.neon) +[![Packagist](https://img.shields.io/badge/Packagist-maatify%2Fphp--return--target-blue)](https://packagist.org/packages/maatify/php-return-target) +[![Monthly Downloads](https://img.shields.io/packagist/dm/maatify/php-return-target)](https://packagist.org/packages/maatify/php-return-target) +[![Total Downloads](https://img.shields.io/packagist/dt/maatify/php-return-target)](https://packagist.org/packages/maatify/php-return-target) [![Maatify Ecosystem](https://img.shields.io/badge/Maatify-Ecosystem-blueviolet)](https://github.com/Maatify) +[![Install](https://img.shields.io/badge/Install-dev--main-brightgreen)](https://packagist.org/packages/maatify/php-return-target) [![Usage Guide](https://img.shields.io/badge/Usage%20Guide-current-blue)](docs/guides/USAGE_GUIDE.md) [![Examples](https://img.shields.io/badge/Examples-maintained-blue)](examples/) @@ -26,9 +31,9 @@ Stateless signed internal return-target handling for framework-agnostic PHP appl ## Status -**Development / Unpublished** +**Pre-Release** -The canonical Runtime API, external Consumer Verification Harness, examples, and CI gates are implemented. No externally installable release exists. +Packagist currently exposes the development version `dev-main` and development branch versions for consumer verification. The target `v1.0.0-rc.1` is not yet published; these development versions are not the SemVer RC. ## Key Features @@ -48,7 +53,13 @@ The canonical Runtime API, external Consumer Verification Harness, examples, and ## Installation -There is no external installation command while the package is **Development / Unpublished**. Use the repository's Composer development setup and the documented public workflow. +The currently published development/pre-release distribution is available through Packagist: + +```shell +composer require maatify/php-return-target:dev-main +``` + +This installs the externally resolvable development version, not the target `v1.0.0-rc.1` RC. ## Quick Usage diff --git a/RETURN_TARGET_PACKAGE_REFERENCE.md b/RETURN_TARGET_PACKAGE_REFERENCE.md index cbeb0e7..98f95a4 100644 --- a/RETURN_TARGET_PACKAGE_REFERENCE.md +++ b/RETURN_TARGET_PACKAGE_REFERENCE.md @@ -2,7 +2,7 @@ ## Package Purpose -The current package concern is stateless signed internal return-target handling. The package remains `Development / Unpublished`. +The current package concern is stateless signed internal return-target handling. A Packagist development/pre-release distribution is externally resolvable, including `dev-main`; the target `v1.0.0-rc.1` SemVer RC is not yet published. ## Public Runtime Contract diff --git a/SECURITY.md b/SECURITY.md index bfe7655..fe923d8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,10 +5,11 @@ ## Current Support State -This package is currently **Development / Unpublished**. +This package is currently in **Pre-Release**. Packagist exposes development versions, but the target `v1.0.0-rc.1` has not yet been published. There is currently no published Stable release, no supported Stable release -line, and no published SemVer Release Candidate (RC). This policy does not +line, and no published SemVer Release Candidate (RC). Development/pre-release +distribution does not create Stable support. This policy does not present `1.x` or `1.0` as a supported release line. ## Reporting a Vulnerability diff --git a/llms.txt b/llms.txt index 0b52b56..1b5a9d2 100644 --- a/llms.txt +++ b/llms.txt @@ -6,14 +6,14 @@ For consumer use, follow the Package Reference for the supported Public Contract ## Primary consumer documentation -- [README.md](README.md) — package overview, unpublished status, requirements, Quick Usage, and Public Runtime API overview. +- [README.md](README.md) — package overview, current pre-release status, requirements, installation, Quick Usage, and Public Runtime API overview. - [RETURN_TARGET_PACKAGE_REFERENCE.md](RETURN_TARGET_PACKAGE_REFERENCE.md) — canonical public/runtime/behavioral contract and complete Public Runtime API inventory. - [docs/guides/USAGE_GUIDE.md](docs/guides/USAGE_GUIDE.md) — consumer integration workflows, boundaries, inputs, outputs, and rejection semantics. - [examples/](examples/) — maintained examples using the documented Public API and production autoload. ## Project history -- [CHANGELOG.md](CHANGELOG.md) — factual change history; no release is currently published. +- [CHANGELOG.md](CHANGELOG.md) — factual change history; Packagist development versions are externally resolvable, but no SemVer RC is published yet. ## Release and support diff --git a/src/Validation/CanonicalReturnTargetValidator.php b/src/Validation/CanonicalReturnTargetValidator.php index 3aba89e..8496f16 100644 --- a/src/Validation/CanonicalReturnTargetValidator.php +++ b/src/Validation/CanonicalReturnTargetValidator.php @@ -27,8 +27,8 @@ public function inspect(string $target): ?string return null; } - $decoded = $this->decodeOnce($target); - if (! $this->isDecodedValid($decoded)) { + [$decoded, $decodedPathLength] = $this->decodeOnce($target); + if (! $this->isDecodedValid($decoded, $decodedPathLength)) { return null; } @@ -86,25 +86,38 @@ private function isRawComponentValid(string $component, bool $allowSlash, bool $ return true; } - private function decodeOnce(string $target): string + /** + * @return array{0: string, 1: int} + */ + private function decodeOnce(string $target): array { $decoded = ''; + $decodedPathLength = 0; + $questionPosition = strpos($target, '?'); for ($i = 0, $length = strlen($target); $i < $length; $i++) { if ($target[$i] !== '%') { $decoded .= $target[$i]; + if ($questionPosition === false || $i < $questionPosition) { + $decodedPathLength++; + } + continue; } $decoded .= chr($this->hexValue($target[$i + 1]) * 16 + $this->hexValue($target[$i + 2])); + if ($questionPosition === false || $i < $questionPosition) { + $decodedPathLength++; + } + $i += 2; } - return $decoded; + return [$decoded, $decodedPathLength]; } - private function isDecodedValid(string $target): bool + private function isDecodedValid(string $target, int $decodedPathLength): bool { - if (! str_starts_with($target, '/') || str_starts_with($target, '//') || $this->hasDotSegment($this->pathPart($target))) { + if (! str_starts_with($target, '/') || str_starts_with($target, '//') || $this->hasDotSegment(substr($target, 0, $decodedPathLength))) { return false; } @@ -118,13 +131,6 @@ private function isDecodedValid(string $target): bool return true; } - private function pathPart(string $target): string - { - $questionPosition = strpos($target, '?'); - - return $questionPosition === false ? $target : substr($target, 0, $questionPosition); - } - private function hasDotSegment(string $path): bool { foreach (explode('/', $path) as $segment) { diff --git a/tests/System/Service/HmacReturnTargetServiceSystemTest.php b/tests/System/Service/HmacReturnTargetServiceSystemTest.php index 890b4a2..b3df113 100644 --- a/tests/System/Service/HmacReturnTargetServiceSystemTest.php +++ b/tests/System/Service/HmacReturnTargetServiceSystemTest.php @@ -170,7 +170,8 @@ public function testCanonicallyInvalidTargetDoesNotReachHostPolicy(): void $policy = new RecordingSystemPolicy(); $service = $this->service(new SystemFixedClock(1790000000), $policy, 60); - self::assertFalse($service->accepts('/%2F%2Fevil.example')); + self::assertFalse($service->accepts('/safe%3F/%2E%2E/admin')); + self::assertNull($service->issue('/safe%3F/%2E%2E/admin')); self::assertSame([], $policy->inspectionTargets); } @@ -248,6 +249,8 @@ public static function rejectedPathTargets(): iterable yield 'raw dot-dot segment' => ['target' => '/orders/../15', 'case' => 'raw dot-dot segment must be rejected']; yield 'encoded dot segment' => ['target' => '/orders/%2E/15', 'case' => 'encoded dot segment must be rejected']; yield 'encoded dot-dot segment' => ['target' => '/orders/%2E%2E/15', 'case' => 'encoded dot-dot segment must be rejected']; + yield 'decoded question boundary dot-dot segment' => ['target' => '/safe%3F/%2E%2E/admin', 'case' => 'decoded raw-path dot-dot segment must be rejected']; + yield 'decoded question boundary dot segment' => ['target' => '/safe%3F/%2E/admin', 'case' => 'decoded raw-path dot segment must be rejected']; yield 'second-stage percent escape' => ['target' => '/orders/%252F%252Fevil.example', 'case' => 'second-stage percent escape must be rejected']; } diff --git a/tests/Unit/Validation/CanonicalReturnTargetValidatorTest.php b/tests/Unit/Validation/CanonicalReturnTargetValidatorTest.php index 5081bf4..76ef584 100644 --- a/tests/Unit/Validation/CanonicalReturnTargetValidatorTest.php +++ b/tests/Unit/Validation/CanonicalReturnTargetValidatorTest.php @@ -45,6 +45,8 @@ public function testRejectsRawAndEncodedUnsafeForms(): void '/a%0Ab', '/a%7Fb', '/a%25E0%25A4%25A', + '/safe%3F/%2E%2E/admin', + '/safe%3F/%2E/admin', '/./orders', '/../orders', '/a/./b',