Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand All @@ -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

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion RETURN_TARGET_PACKAGE_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 19 additions & 13 deletions src/Validation/CanonicalReturnTargetValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion tests/System/Service/HmacReturnTargetServiceSystemTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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'];
}

Expand Down
2 changes: 2 additions & 0 deletions tests/Unit/Validation/CanonicalReturnTargetValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading