Skip to content
Open
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
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"gnomovision",
"hookspec",
"icanon",
"itok",
"initialisation",
"initialised",
"jangregor",
Expand Down
7 changes: 7 additions & 0 deletions filefield_paths.module
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ use Drupal\filefield_paths\Hook\FieldWidgetSingleElementForm;
use Drupal\filefield_paths\Hook\File;
use Drupal\filefield_paths\Hook\FileFieldPathsFieldSettingsLegacy;
use Drupal\filefield_paths\Hook\FileFieldPathsProcessFileLegacy;
use Drupal\filefield_paths\Hook\FileUrlHooks;
use Drupal\filefield_paths\Hook\LocalTaskAlter;
use Drupal\filefield_paths\Hook\Tokens;
use Drupal\filefield_paths\MoveFileProcessorInterface;
Expand Down Expand Up @@ -212,6 +213,12 @@ function filefield_paths_file_presave(FileInterface $file): void {// phpcs:ignor
\Drupal::service(File::class)->filePresave($file);
}

// @phpstan-ignore-next-line
#[LegacyHook]
function filefield_paths_file_url_alter(string &$uri): void {// phpcs:ignore Drupal.Commenting.FunctionComment.Missing, Squiz.WhiteSpace.FunctionSpacing.Before
\Drupal::service(FileUrlHooks::class)->fileUrlAlter($uri);
}

// @phpstan-ignore-next-line
#[LegacyHook]
function filefield_paths_local_tasks_alter(array &$local_tasks): void {// phpcs:ignore Drupal.Commenting.FunctionComment.Missing, Squiz.WhiteSpace.FunctionSpacing.Before
Expand Down
11 changes: 11 additions & 0 deletions filefield_paths.routing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,14 @@ filefield_paths.admin_settings:
_title: 'File (Field) Paths settings'
requirements:
_permission: 'administer site configuration'

filefield_paths.image_style_temporary:
path: '/filefield_paths/image-style/{image_style}/temporary'
defaults:
_controller: '\Drupal\image\Controller\ImageStyleDownloadController::deliver'
scheme: 'temporary'
required_derivative_scheme: 'temporary'
requirements:
_ffp_temp_image_style: 'TRUE'
options:
no_cache: TRUE
9 changes: 9 additions & 0 deletions filefield_paths.services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ services:
class: Drupal\filefield_paths\PathProcessor
autowire: true

filefield_paths.access_checker.image_style_temporary:
class: Drupal\filefield_paths\Access\ImageStyleTemporaryAccessCheck
arguments: ['@config.factory']
tags:
- { name: access_check, applies_to: _ffp_temp_image_style }

# Legacy hook support
Drupal\filefield_paths\Hook\EntityWithFileField:
class: Drupal\filefield_paths\Hook\EntityWithFileField
Expand All @@ -45,3 +51,6 @@ services:
Drupal\filefield_paths\Hook\Tokens:
class: Drupal\filefield_paths\Hook\Tokens
autowire: true
Drupal\filefield_paths\Hook\FileUrlHooks:
class: Drupal\filefield_paths\Hook\FileUrlHooks
autowire: true
62 changes: 62 additions & 0 deletions src/Access/ImageStyleTemporaryAccessCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace Drupal\filefield_paths\Access;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\StreamWrapper\StreamWrapperManager;
use Symfony\Component\HttpFoundation\Request;

/**
* Restricts the temporary image style route to the FFP staging subdirectory.
*
* Prevents the route from serving derivatives for arbitrary temporary:// files
* outside the configured File (Field) Paths temp location.
*/
final readonly class ImageStyleTemporaryAccessCheck implements AccessInterface {

public function __construct(
private ConfigFactoryInterface $configFactory,
) {}

/**
* Checks access for the temporary image style delivery route.
*/
public function access(Request $request): AccessResultInterface {
$cacheability = (new CacheableMetadata())
->setCacheContexts(['url.query_args:file'])
->setCacheTags(['config:filefield_paths.settings']);

$file = (string) ($request->query->get('file') ?? '');
if ($file === '') {
return AccessResult::forbidden()->addCacheableDependency($cacheability);
}

$temp_location = $this->configFactory
->get('filefield_paths.settings')
->get('temp_location') ?? '';

if (StreamWrapperManager::getScheme($temp_location) !== 'temporary') {
return AccessResult::forbidden()->addCacheableDependency($cacheability);
}

$subdir = StreamWrapperManager::getTarget($temp_location);
if (!is_string($subdir) || $subdir === '') {
return AccessResult::forbidden()->addCacheableDependency($cacheability);
}

$normalized_file = ltrim(str_replace('\\', '/', $file), '/');
if (preg_match('~(^|/)\.\.(/|$)~', $normalized_file)) {
return AccessResult::forbidden()->addCacheableDependency($cacheability);
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
$allowed = str_starts_with($normalized_file, $subdir . '/');
return AccessResult::allowedIf($allowed)->addCacheableDependency($cacheability);
}

}
48 changes: 48 additions & 0 deletions src/Hook/FileUrlHooks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace Drupal\filefield_paths\Hook;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StreamWrapper\StreamWrapperManager;
use Drupal\Core\Url;

/**
* File URL hook implementations.
*/
final readonly class FileUrlHooks {

public function __construct(
private ConfigFactoryInterface $configFactory,
) {}

/**
* Implements hook_file_url_alter().
*
* Rewrites image style derivative URLs for files staged in temporary:// so
* that they route through a dedicated delivery controller instead of the
* core temporary stream wrapper (which cannot serve image derivatives).
*/
// @phpstan-ignore-next-line
#[Hook('file_url_alter')]
public function fileUrlAlter(string &$uri): void {// phpcs:ignore Squiz.WhiteSpace.FunctionSpacing.Before
$temp_location = $this->configFactory
->get('filefield_paths.settings')
->get('temp_location') ?? '';

if (StreamWrapperManager::getScheme($temp_location) !== 'temporary') {
return;
}

if (preg_match('#^temporary://styles/([^/]+)/temporary/(.+)$#', $uri, $m)) {
$uri = Url::fromRoute(
'filefield_paths.image_style_temporary',
['image_style' => $m[1]],
['query' => ['file' => $m[2]], 'absolute' => TRUE],
)->toString();
}
}

}
149 changes: 149 additions & 0 deletions tests/src/Functional/FileFieldPathsImageStyleTemporaryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php

declare(strict_types=1);

namespace Drupal\Tests\filefield_paths\Functional;

use PHPUnit\Framework\Attributes\Group;
use Drupal\Core\File\FileExists;
use Drupal\Core\File\FileSystemInterface;
use Drupal\image\Entity\ImageStyle;
use Drupal\Tests\BrowserTestBase;

/**
* Tests on-demand image style delivery for temporary:// staged files.
*
* @group filefield_paths
*/
#[Group('filefield_paths')]
class FileFieldPathsImageStyleTemporaryTest extends BrowserTestBase {

/**
* {@inheritdoc}
*/
protected static $modules = ['filefield_paths', 'image'];

/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';

/**
* Image style used across tests.
*/
protected ImageStyle $style;

/**
* URI of the test image staged in temporary://.
*/
protected string $imageUri;

/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();

// Use temporary:// as the FFP temp location so the URL alter hook fires.
\Drupal::configFactory()
->getEditable('filefield_paths.settings')
->set('temp_location', 'temporary://filefield_paths')
->save();

// Create a simple image style.
$this->style = ImageStyle::create(['name' => 'ffp_test', 'label' => 'FFP test']);
$this->style->save();

// Copy a core test image into the FFP temp location.
$temp_location = 'temporary://filefield_paths';
\Drupal::service('file_system')->prepareDirectory(
$temp_location,
FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS,
);
$source = \Drupal::root() . '/core/tests/fixtures/files/image-1.png';
$this->imageUri = \Drupal::service('file_system')
->copy($source, $temp_location . '/image-1.png', FileExists::Replace);
}

/**
* Tests that derivative URLs are rewritten to the FFP route.
*/
public function testUrlIsRewritten(): void {
$url = $this->style->buildUrl($this->imageUri);
$this->assertStringContainsString('/filefield_paths/image-style/ffp_test/temporary', $url);
$this->assertStringContainsString('file=filefield_paths/', $url);
$this->assertStringContainsString('itok=', $url);
}

/**
* Tests that image style derivatives are served for temporary:// files.
*/
public function testDerivativeIsServed(): void {
$url = $this->style->buildUrl($this->imageUri);

$this->drupalGet($url);
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->responseHeaderContains('Content-Type', 'image/');
}

/**
* Tests that a missing or invalid itok returns 404.
*/
public function testMissingTokenReturns404(): void {
$url = $this->style->buildUrl($this->imageUri);

// Strip the itok parameter.
$url_no_token = preg_replace('/[?&]itok=[^&]+/', '', $url);

$this->drupalGet($url_no_token);
$this->assertSession()->statusCodeEquals(404);
}

/**
* Tests that ?file= outside the FFP subdirectory returns 403.
*/
public function testFileOutsideSubdirReturns403(): void {
$url = $this->style->buildUrl($this->imageUri);

// Replace the FFP subdirectory prefix with a different path.
$url_outside = preg_replace('#(\?|&)file=filefield_paths/#', '$1file=other_module/', $url);

$this->drupalGet($url_outside);
$this->assertSession()->statusCodeEquals(403);
}

/**
* Tests that accessing the route without a file parameter returns 403.
*/
public function testEmptyFileParamReturns403(): void {
$this->drupalGet('/filefield_paths/image-style/ffp_test/temporary');
$this->assertSession()->statusCodeEquals(403);
}

/**
* Tests that a path traversal attempt in ?file= returns 403.
*/
public function testPathTraversalReturns403(): void {
$url = $this->style->buildUrl($this->imageUri);
$url_traversal = preg_replace('#(\?|&)file=filefield_paths/#', '$1file=filefield_paths/../', $url);
$this->drupalGet($url_traversal);
$this->assertSession()->statusCodeEquals(403);
}

/**
* Tests that non-temporary temp_location does not trigger URL rewriting.
*
* When temp_location uses private://, the alter hook must not intercept
* derivative URLs, leaving them on the standard private delivery route.
*/
public function testPrivateTempLocationUnaffected(): void {
\Drupal::configFactory()
->getEditable('filefield_paths.settings')
->set('temp_location', 'private://filefield_paths')
->save();

$url = $this->style->buildUrl($this->imageUri);
$this->assertStringNotContainsString('/filefield_paths/image-style/', $url);
}

}
Loading