diff --git a/app/Extensions/helper/helpers.php b/app/Extensions/helper/helpers.php index ece174cd1..076f52373 100644 --- a/app/Extensions/helper/helpers.php +++ b/app/Extensions/helper/helpers.php @@ -304,6 +304,19 @@ if (! function_exists('escapeString')) { } } +if (! function_exists('regex_display_value')) { + /** + * Decode entity-encoded regex text at the presentation boundary. + * + * Blade should still render the returned value with escaped {{ }} output so regexes that + * contain HTML-looking text remain safe while displaying named groups and quotes readably. + */ + function regex_display_value(mixed $value): string + { + return html_entity_decode((string) ($value ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } +} + if (! function_exists('realDuration')) { function realDuration(mixed $milliseconds): string diff --git a/resources/views/admin/regexes/category-edit.blade.php b/resources/views/admin/regexes/category-edit.blade.php index 9d3e87794..30a582f8d 100644 --- a/resources/views/admin/regexes/category-edit.blade.php +++ b/resources/views/admin/regexes/category-edit.blade.php @@ -43,7 +43,7 @@ id="group_regex" name="group_regex" class="pl-10 w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - value="{{ htmlspecialchars($regex->group_regex ?? '') }}" + value="{{ $regex->group_regex ?? '' }}" required>

@@ -66,7 +66,7 @@ name="regex" class="pl-10 w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm" rows="4" - required>{{ htmlspecialchars($regex->regex ?? '') }} + required>{{ regex_display_value($regex->regex ?? '') }}

Regex to use when categorizing releases.
@@ -87,7 +87,7 @@ + rows="3">{{ $regex->description ?? '' }}

Description for this regex. You can include an example usenet subject this regex would match on. diff --git a/resources/views/admin/regexes/category-list.blade.php b/resources/views/admin/regexes/category-list.blade.php index 550e5ed18..48aa286b6 100644 --- a/resources/views/admin/regexes/category-list.blade.php +++ b/resources/views/admin/regexes/category-list.blade.php @@ -94,8 +94,8 @@

- - {{ htmlspecialchars($row->regex) }} + + {{ regex_display_value($row->regex) }}
diff --git a/resources/views/admin/regexes/collection-edit.blade.php b/resources/views/admin/regexes/collection-edit.blade.php index 36a7ddbbf..8665fbbe5 100644 --- a/resources/views/admin/regexes/collection-edit.blade.php +++ b/resources/views/admin/regexes/collection-edit.blade.php @@ -43,7 +43,7 @@ id="group_regex" name="group_regex" class="pl-10 w-full px-4 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - value="{{ htmlspecialchars($regex->group_regex ?? '') }}" + value="{{ $regex->group_regex ?? '' }}" required>

@@ -66,7 +66,7 @@ name="regex" class="pl-10 w-full px-4 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm" rows="4" - required>{{ htmlspecialchars($regex->regex ?? '') }} + required>{{ regex_display_value($regex->regex ?? '') }}

Regex to use when grouping binaries into collections.
@@ -87,7 +87,7 @@ + rows="3">{{ $regex->description ?? '' }}

Description for this regex. You can include an example usenet subject this regex would match on. diff --git a/resources/views/admin/regexes/collection-list.blade.php b/resources/views/admin/regexes/collection-list.blade.php index cb14fd9c4..c6ce2d28b 100644 --- a/resources/views/admin/regexes/collection-list.blade.php +++ b/resources/views/admin/regexes/collection-list.blade.php @@ -93,8 +93,8 @@

- - {{ htmlspecialchars($row->regex) }} + + {{ regex_display_value($row->regex) }}
diff --git a/resources/views/admin/regexes/collection-test.blade.php b/resources/views/admin/regexes/collection-test.blade.php index a0bd5531e..74d0b2f9a 100644 --- a/resources/views/admin/regexes/collection-test.blade.php +++ b/resources/views/admin/regexes/collection-test.blade.php @@ -73,7 +73,7 @@ class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm" rows="4" required - placeholder="/^(?P.*?)([\. ]S\d{1,3}[\. ]?E\d{1,3})/i">{{ $regex }} + placeholder="/^(?P.*?)([\. ]S\d{1,3}[\. ]?E\d{1,3})/i">{{ regex_display_value($regex) }}

Enter the regex pattern to test. Include delimiters and flags.

diff --git a/resources/views/admin/regexes/release-naming-edit.blade.php b/resources/views/admin/regexes/release-naming-edit.blade.php index 251e9f920..c592249fb 100644 --- a/resources/views/admin/regexes/release-naming-edit.blade.php +++ b/resources/views/admin/regexes/release-naming-edit.blade.php @@ -43,7 +43,7 @@ id="group_regex" name="group_regex" class="pl-10 w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - value="{{ htmlspecialchars($regex->group_regex ?? '') }}" + value="{{ $regex->group_regex ?? '' }}" required>

@@ -66,7 +66,7 @@ name="regex" class="pl-10 w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm" rows="4" - required>{{ htmlspecialchars($regex->regex ?? '') }} + required>{{ regex_display_value($regex->regex ?? '') }}

Regex to use when renaming releases.
@@ -87,7 +87,7 @@ + rows="3">{{ $regex->description ?? '' }}

Description for this regex. You can include an example release name this regex would match on. diff --git a/resources/views/admin/regexes/release-naming-list.blade.php b/resources/views/admin/regexes/release-naming-list.blade.php index d3bbc4b4c..e65ddc969 100644 --- a/resources/views/admin/regexes/release-naming-list.blade.php +++ b/resources/views/admin/regexes/release-naming-list.blade.php @@ -93,8 +93,8 @@

- - {{ htmlspecialchars($row->regex) }} + + {{ regex_display_value($row->regex) }}
diff --git a/resources/views/admin/regexes/release-naming-test.blade.php b/resources/views/admin/regexes/release-naming-test.blade.php index 19d57ee93..7bfb1786c 100644 --- a/resources/views/admin/regexes/release-naming-test.blade.php +++ b/resources/views/admin/regexes/release-naming-test.blade.php @@ -90,7 +90,7 @@ class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm" rows="4" required - placeholder="/^(?P.*?)([\. ]S\d{1,3}[\. ]?E\d{1,3})/i">{{ $regex }} + placeholder="/^(?P.*?)([\. ]S\d{1,3}[\. ]?E\d{1,3})/i">{{ regex_display_value($regex) }}

Enter the regex pattern to test. Include delimiters and flags.

diff --git a/tests/Feature/AdminRegexesControllerTest.php b/tests/Feature/AdminRegexesControllerTest.php index e789d4b6f..2c0f7cd5d 100644 --- a/tests/Feature/AdminRegexesControllerTest.php +++ b/tests/Feature/AdminRegexesControllerTest.php @@ -166,6 +166,63 @@ class AdminRegexesControllerTest extends TestCase ->assertNotFound(); } + public function test_admin_regex_list_pages_decode_entity_encoded_regexes_without_double_escaping(): void + { + $admin = $this->createUserWithRole('Admin'); + $rawRegex = '/^(?P.+?) - "(?P.+?)"$/i'; + $entityEncodedRegex = '/^(?P<name>.+?) - "(?P<title>.+?)"$/i'; + $scriptTag = '<'.'script>alert("x")</'.'script>'; + $htmlLookingRegex = '/^(?P<name>.+?)'.$scriptTag.'$/i'; + + foreach (['release_naming_regexes', 'collection_regexes', 'category_regexes'] as $table) { + $this->insertRegexFixture($table, $rawRegex, 'Raw regex fixture'); + $this->insertRegexFixture($table, $entityEncodedRegex, 'Entity encoded regex fixture'); + $this->insertRegexFixture($table, $htmlLookingRegex, 'HTML-looking regex fixture'); + } + + foreach ([ + route('admin.release_naming_regexes-list'), + route('admin.collection_regexes-list'), + route('admin.category_regexes-list'), + ] as $url) { + $response = $this->actingAs($admin)->get($url); + + $response->assertOk() + ->assertSee(e($rawRegex), false) + ->assertSee(e(html_entity_decode($entityEncodedRegex, ENT_QUOTES | ENT_HTML5, 'UTF-8')), false) + ->assertSee(e(html_entity_decode($htmlLookingRegex, ENT_QUOTES | ENT_HTML5, 'UTF-8')), false) + ->assertDontSee('&quot;', false) + ->assertDontSee('&lt;', false) + ->assertDontSee('&gt;', false) + ->assertDontSee($scriptTag, false); + } + } + + public function test_admin_regex_edit_pages_decode_entity_encoded_regexes_without_double_escaping(): void + { + $admin = $this->createUserWithRole('Admin'); + $entityEncodedRegex = '/^(?P<name>.+?) - "(?P<title>.+?)"$/i'; + $expectedRenderedRegex = e(html_entity_decode($entityEncodedRegex, ENT_QUOTES | ENT_HTML5, 'UTF-8')); + + $releaseNamingRegexId = $this->insertRegexFixture('release_naming_regexes', $entityEncodedRegex, 'Release naming encoded regex'); + $collectionRegexId = $this->insertRegexFixture('collection_regexes', $entityEncodedRegex, 'Collection encoded regex'); + $categoryRegexId = $this->insertRegexFixture('category_regexes', $entityEncodedRegex, 'Category encoded regex'); + + foreach ([ + route('admin.release_naming_regexes-edit', ['id' => (string) $releaseNamingRegexId]), + route('admin.collection_regexes-edit', ['id' => (string) $collectionRegexId]), + route('admin.category_regexes-edit', ['id' => (string) $categoryRegexId]), + ] as $url) { + $this->actingAs($admin) + ->get($url) + ->assertOk() + ->assertSee($expectedRenderedRegex, false) + ->assertDontSee('&quot;', false) + ->assertDontSee('&lt;', false) + ->assertDontSee('&gt;', false); + } + } + private function setEnvironmentValue(string $key, ?string $value): void { if ($value === null) { @@ -373,6 +430,23 @@ class AdminRegexesControllerTest extends TestCase return $user->fresh(); } + private function insertRegexFixture(string $table, string $regex, string $description): int + { + $data = [ + 'group_regex' => 'alt\\.binaries\\.example', + 'regex' => $regex, + 'description' => $description, + 'ordinal' => 10, + 'status' => 1, + ]; + + if ($table === 'category_regexes') { + $data['categories_id'] = 1; + } + + return (int) DB::table($table)->insertGetId($data); + } + private function resetGlobalComposerState(): void { $reflection = new ReflectionClass(GlobalDataComposer::class);