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
45 changes: 3 additions & 42 deletions php-transformer/src/ArtifactCompiler/ArtifactCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Automattic\BlocksEngine\PhpTransformer\Contract\TransformerResult;
use Automattic\BlocksEngine\PhpTransformer\FormatBridge\FormatBridge;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer;
use Automattic\BlocksEngine\PhpTransformer\Path\ArtifactPath;
use Automattic\BlocksEngine\PhpTransformer\StaticSite\MaterializationPlanBuilder;

final class ArtifactCompiler
Expand Down Expand Up @@ -1066,29 +1067,7 @@ private function findAssetByHtmlReference(string $reference, string $entryPath,

private function resolveHtmlReferencePath(string $reference, string $entryPath): string
{
$reference = strtok($reference, '?#') ?: '';
$reference = str_replace('\\', '/', trim($reference));
if ( '' === $reference || str_starts_with($reference, '/') || preg_match('#^[a-z][a-z0-9+.-]*:#i', $reference) ) {
return '';
}

$base = '' === $entryPath || ! str_contains($entryPath, '/') ? '' : dirname($entryPath) . '/';
$parts = array();
foreach ( explode('/', $base . $reference) as $part ) {
if ( '' === $part || '.' === $part ) {
continue;
}
if ( '..' === $part ) {
if ( array() === $parts ) {
return '';
}
array_pop($parts);
continue;
}
$parts[] = $part;
}

return implode('/', $parts);
return ArtifactPath::resolveRelativePath($reference, $entryPath);
}

/**
Expand Down Expand Up @@ -1345,8 +1324,7 @@ private function resolveComponentImport(string $importPath, string $sourcePath,
return '';
}

$base = dirname($sourcePath);
$path = $this->normalizeRelativeImportPath(('.' === $base ? '' : $base . '/') . $importPath);
$path = ArtifactPath::resolveRelativePath($importPath, $sourcePath, true);
if ( '' === $path ) {
return '';
}
Expand All @@ -1366,23 +1344,6 @@ private function resolveComponentImport(string $importPath, string $sourcePath,
return '';
}

private function normalizeRelativeImportPath(string $path): string
{
$segments = array();
foreach ( explode('/', str_replace('\\', '/', $path)) as $segment ) {
if ( '' === $segment || '.' === $segment ) {
continue;
}
if ( '..' === $segment ) {
array_pop($segments);
continue;
}
$segments[] = preg_replace('/[^A-Za-z0-9._-]/', '-', $segment);
}

return implode('/', array_filter($segments));
}

/**
* @param array<string, mixed> $frontmatter
* @param array<int, string> $keys
Expand Down
26 changes: 4 additions & 22 deletions php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

namespace Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler;

use Automattic\BlocksEngine\PhpTransformer\Path\ArtifactPath;

/**
* Normalizes loose website artifact envelopes into compiler-ready file records.
*
Expand Down Expand Up @@ -43,7 +45,7 @@ public function normalize(array $artifact): array
$rawFiles = $this->rawFiles($artifact);
$safeEntrypoints = array();
foreach ( array_unique($entrypoints) as $entrypoint ) {
$path = $this->safeRelativePath($entrypoint);
$path = ArtifactPath::safeRelativePath($entrypoint);
if ( '' === $path ) {
$diagnostics[] = $this->diagnostic('unsafe_entrypoint_path', 'warning', 'An artifact entrypoint was ignored because its path is empty, absolute, or escapes the artifact root.', array('path' => $entrypoint));
continue;
Expand All @@ -58,7 +60,7 @@ public function normalize(array $artifact): array
break;
}

$path = $this->safeRelativePath((string) ($file['path'] ?? ''));
$path = ArtifactPath::safeRelativePath((string) ($file['path'] ?? ''));
if ( '' === $path ) {
++$rejected;
$diagnostics[] = $this->diagnostic('unsafe_artifact_path', 'warning', 'An artifact file was ignored because its path is empty, absolute, or escapes the artifact root.', array('index' => $index));
Expand Down Expand Up @@ -227,26 +229,6 @@ private function payload(array $file, string $path): array
return array('accepted' => true, 'content' => $content, 'content_base64' => '', 'encoding' => 'text', 'binary' => false, 'bytes' => strlen($content), 'diagnostics' => array());
}

private function safeRelativePath(string $path): string
{
$path = str_replace('\\', '/', trim($path));
if ( '' === $path || str_starts_with($path, '/') || preg_match('#^[A-Za-z]:/#', $path) ) {
return '';
}
$parts = array();
foreach ( explode('/', $path) as $part ) {
if ( '' === $part || '.' === $part ) {
continue;
}
if ( '..' === $part ) {
return '';
}
$parts[] = $part;
}

return implode('/', $parts);
}

private function kind(string $kind, string $path, string $content, string $mimeType): string
{
$kind = $this->sanitizeKey($kind);
Expand Down
70 changes: 70 additions & 0 deletions php-transformer/src/Path/ArtifactPath.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);

namespace Automattic\BlocksEngine\PhpTransformer\Path;

final class ArtifactPath
{
public static function safeRelativePath(string $path): string
{
$path = self::cleanInput($path);
if ( '' === $path || str_starts_with($path, '/') || (bool) preg_match('#^[A-Za-z]:/#', $path) ) {
return '';
}

$parts = array();
foreach ( explode('/', $path) as $part ) {
if ( '' === $part || '.' === $part ) {
continue;
}
if ( '..' === $part ) {
return '';
}
$parts[] = $part;
}

return implode('/', $parts);
}

public static function resolveRelativePath(string $reference, string $sourcePath = '', bool $sanitizeSegments = false): string
{
$reference = self::stripQueryAndFragment($reference);
$reference = self::cleanInput($reference);
if ( '' === $reference || self::isAbsoluteReference($reference) ) {
return '';
}

$base = '' === $sourcePath || ! str_contains($sourcePath, '/') ? '' : dirname($sourcePath) . '/';
$parts = array();
foreach ( explode('/', $base . $reference) as $part ) {
if ( '' === $part || '.' === $part ) {
continue;
}
if ( '..' === $part ) {
if ( array() === $parts ) {
return '';
}
array_pop($parts);
continue;
}
$parts[] = $sanitizeSegments ? (preg_replace('/[^A-Za-z0-9._-]/', '-', $part) ?? '') : $part;
}

return self::safeRelativePath(implode('/', $parts));
}

public static function stripQueryAndFragment(string $reference): string
{
return strtok($reference, '?#') ?: '';
}

private static function cleanInput(string $path): string
{
return str_replace('\\', '/', trim($path));
}

private static function isAbsoluteReference(string $path): bool
{
return str_starts_with($path, '/') || (bool) preg_match('#^[A-Za-z][A-Za-z0-9+.-]*:#', $path);
}
}
18 changes: 15 additions & 3 deletions php-transformer/tests/contract/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Automattic\BlocksEngine\PhpTransformer\FormatBridge\FormatAdapterInterface;
use Automattic\BlocksEngine\PhpTransformer\FormatBridge\FormatBridge;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer;
use Automattic\BlocksEngine\PhpTransformer\Path\ArtifactPath;
use Automattic\BlocksEngine\PhpTransformer\StaticSite\MaterializationPlanBuilder;

if ( ! function_exists('serialize_blocks') ) {
Expand Down Expand Up @@ -59,6 +60,14 @@ function serialize_blocks(array $blocks): string
$assert(false, $message, 'Canonical envelope validation unexpectedly passed.');
};

$assert('assets/logo.png' === ArtifactPath::safeRelativePath(' ./assets//logo.png '), 'artifact paths trim relative markers and duplicate separators');
$assert('' === ArtifactPath::safeRelativePath('/assets/logo.png'), 'artifact paths reject root-absolute paths');
$assert('' === ArtifactPath::safeRelativePath('C:\\assets\\logo.png'), 'artifact paths reject drive-absolute paths');
$assert('' === ArtifactPath::safeRelativePath('../secrets/logo.png'), 'artifact paths reject traversal paths');
$assert('assets/logo.png' === ArtifactPath::resolveRelativePath('../assets/logo.png?version=1#hash', 'pages/home.html'), 'artifact references resolve relative paths without query or fragment');
$assert('' === ArtifactPath::resolveRelativePath('https://example.com/logo.png', 'pages/home.html'), 'artifact references reject URL references');
$assert('' === ArtifactPath::resolveRelativePath('../../logo.png', 'pages/home.html'), 'artifact references reject traversal above the artifact root');

$fixture = file_get_contents(dirname(__DIR__) . '/fixtures/simple-html.html');
$result = ( new HtmlTransformer() )->transform($fixture . "\n<ul><li>One</li><li><strong>Two</strong></li></ul><aside>Fallback</aside>")->toArray();

Expand Down Expand Up @@ -261,14 +270,17 @@ function serialize_blocks(array $blocks): string
array(
'entrypoints' => array('../unsafe.html'),
'files' => array(
'../secret.html' => '<main>Nope</main>',
'safe.html' => '<main>Safe</main>',
'../secret.html' => '<main>Nope</main>',
'/absolute.html' => '<main>Nope</main>',
'safe.html' => '<main>Safe</main>',
'assets//nested/style.css' => '.safe{}',
),
)
)->toArray();
$assert('success_with_warnings' === $unsafe['status'], 'unsafe paths produce warning status', (string) $unsafe['status']);
$assert(1 === ($unsafe['source_reports']['artifact']['rejected_count'] ?? null), 'unsafe paths are rejected');
$assert(2 === ($unsafe['source_reports']['artifact']['rejected_count'] ?? null), 'unsafe paths are rejected');
$assert('unsafe_entrypoint_path' === ($unsafe['diagnostics'][0]['code'] ?? ''), 'unsafe entrypoints are diagnosed');
$assert(! empty(array_filter($unsafe['assets'], static fn (array $asset): bool => 'assets/nested/style.css' === ($asset['path'] ?? ''))), 'safe artifact paths collapse duplicate separators');

$binary = $compiler->compile(
array(
Expand Down
Loading