-
Notifications
You must be signed in to change notification settings - Fork 7
fix(#3121826): add on-demand image style delivery for temporary:// staged files #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Decipher
wants to merge
6
commits into
8.x-1.x
Choose a base branch
from
feature/3121826-temporary_image_style
base: 8.x-1.x
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
c188263
fix(#3121826): add on-demand image style delivery for temporary:// st…
Decipher 33d0c5d
fix(#3121826): harden temporary image style delivery route
Decipher 48cf08d
fix(#3121826): harden access checker cache metadata and path traversal
Decipher 29273da
fix(#3121826): add LegacyHook wrapper for file_url_alter on Drupal 10
Decipher 01b6b90
test(#3121826): add kernel tests for image style access check and URL…
Decipher 4ccc6a5
test: convert PHPUnit docblock annotation to attribute
Decipher File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,6 +42,7 @@ | |
| "gnomovision", | ||
| "hookspec", | ||
| "icanon", | ||
| "itok", | ||
| "initialisation", | ||
| "initialised", | ||
| "jangregor", | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
|
|
||
| $allowed = str_starts_with($normalized_file, $subdir . '/'); | ||
| return AccessResult::allowedIf($allowed)->addCacheableDependency($cacheability); | ||
| } | ||
|
|
||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
149
tests/src/Functional/FileFieldPathsImageStyleTemporaryTest.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
|
|
||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.