Skip to content

security: fix 5 critical vulnerabilities (v0.6.0 release blockers)#9

Merged
stephenfeather merged 1 commit intodevelopfrom
security/fix-v0.6.0-blockers
Feb 2, 2026
Merged

security: fix 5 critical vulnerabilities (v0.6.0 release blockers)#9
stephenfeather merged 1 commit intodevelopfrom
security/fix-v0.6.0-blockers

Conversation

@stephenfeather
Copy link
Copy Markdown
Owner

@stephenfeather stephenfeather commented Feb 2, 2026

Summary

Fixes 5 critical security vulnerabilities identified during the v0.6.0-beta-1 release audit:

  • CSRF in LegacyAjaxHandler - Added operation-specific nonce verification for all AJAX endpoints
  • XSS in LegacyAjaxHandler - Escaped all output with esc_html(), esc_url(), esc_js()
  • XSS in FilesPage - Escaped JavaScript-injected values with esc_js()
  • Header Injection in FileDownloadHandler - Sanitized filenames with RFC 5987 encoding
  • CSRF in FileActionHandler - Added nonce verification to upload/import handlers + form fields

Files Changed

File Changes
src/Ajax/LegacyAjaxHandler.php CSRF protection + XSS escaping
src/Admin/Pages/FilesPage.php XSS escaping in JavaScript
src/Http/Handler/FileDownloadHandler.php Header injection prevention
src/Admin/Services/FileActionHandler.php CSRF protection
src/Admin/UploadHelper.php Added nonce fields to form

Test plan

  • Verify AJAX operations (preacher/series/service/file CRUD) still work with nonces
  • Verify file upload and URL import still work with nonce verification
  • Verify file downloads work with sanitized filenames (including non-ASCII names)
  • Test with malicious filename containing <script> tags - should be escaped
  • Test pagination on sermons and files pages

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Added CSRF protection to file upload and import operations
    • Enhanced input validation and output escaping across file management features
    • Improved file download security with proper header sanitization for broader client compatibility

- CSRF: Add nonce verification to LegacyAjaxHandler and FileActionHandler
- XSS: Escape all output in LegacyAjaxHandler and FilesPage with esc_html/esc_js/esc_url
- Header Injection: Sanitize filenames in FileDownloadHandler with RFC 5987 encoding
- Forms: Add wp_nonce_field() to UploadHelper upload/import form

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 2, 2026

📝 Walkthrough

Walkthrough

The PR adds security hardening across multiple entry points: CSRF nonce verification for file uploads and imports, output escaping for XSS prevention in admin pages and AJAX handlers, and filename sanitization in HTTP download headers to prevent header injection.

Changes

Cohort / File(s) Summary
CSRF Protection & Nonce Verification
src/Admin/Services/FileActionHandler.php, src/Admin/UploadHelper.php
Added WordPress nonce field generation and verification for file upload and import operations, validating CSRF tokens before processing requests.
Output Escaping & Data Sanitization
src/Admin/Pages/FilesPage.php, src/Ajax/LegacyAjaxHandler.php
Systematic HTML/attribute/URL escaping using esc_html, esc_attr, esc_url, and esc_js; type-casting numeric IDs to int; refactored operation routing with nonce verification in AJAX handler.
File Download Security
src/Http/Handler/FileDownloadHandler.php
Added sanitizeFilenameForHeader method to prevent header injection and support UTF-8 filenames in Content-Disposition headers.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant UploadForm as Upload Form
    participant FileActionHandler as FileActionHandler
    participant Validator as CSRF Validator
    
    Client->>UploadForm: Request form
    UploadForm->>UploadForm: Generate nonce fields
    UploadForm->>Client: Return form with nonces
    Client->>FileActionHandler: Submit file + nonce
    FileActionHandler->>Validator: Verify nonce
    alt Nonce Valid
        Validator-->>FileActionHandler: ✓ Valid
        FileActionHandler->>FileActionHandler: Process upload
        FileActionHandler-->>Client: Success
    else Nonce Invalid
        Validator-->>FileActionHandler: ✗ Invalid
        FileActionHandler-->>Client: 403 Security Error
    end
Loading
sequenceDiagram
    participant Client
    participant LegacyAjaxHandler as AJAX Handler
    participant OpDetector as Op. Type Detector
    participant Validator as Nonce Validator
    participant CrudHandler as CRUD Handler
    
    Client->>LegacyAjaxHandler: AJAX request with operation
    LegacyAjaxHandler->>OpDetector: Determine operation type
    OpDetector-->>LegacyAjaxHandler: Operation type
    LegacyAjaxHandler->>Validator: Verify nonce for operation
    alt Nonce Valid
        Validator-->>LegacyAjaxHandler: ✓ Valid
        LegacyAjaxHandler->>CrudHandler: Route to handler
        CrudHandler-->>LegacyAjaxHandler: Escaped response
        LegacyAjaxHandler-->>Client: Success
    else Nonce Invalid
        Validator-->>LegacyAjaxHandler: ✗ Invalid
        LegacyAjaxHandler-->>Client: 403 Security Error
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 With nonces checked and outputs blessed,
Our files now rest in HTML nest,
No injection tricks shall pass our gates,
Security through escaping states! 🔒

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the primary change: fixing five critical security vulnerabilities as v0.6.0 release blockers. It clearly reflects the main objective of the PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch security/fix-v0.6.0-blockers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud bot commented Feb 2, 2026

Quality Gate Failed Quality Gate failed

Failed conditions
0.0% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@stephenfeather stephenfeather merged commit 4aaa8c0 into develop Feb 2, 2026
1 of 5 checks passed
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/Admin/UploadHelper.php (1)

159-166: ⚠️ Potential issue | 🟠 Major

XSS vulnerability: Unescaped file data in select options.

The $file->id and $file->name values from the database are echoed without escaping, which could allow stored XSS if a malicious filename exists in the database.

🔒 Proposed fix
                     <select name="getid3">
                         <?php
                             $files = File::findUnlinked();
                             echo count($files) == 0 ? '<option value="0">No files found</option>' : '<option value="0"></option>';
                         foreach ($files as $file) {
                             ?>
-                                <option value="<?php echo $file->id; ?>"><?php echo $file->name; ?></option>
+                                <option value="<?php echo (int) $file->id; ?>"><?php echo esc_html($file->name); ?></option>
                         <?php } ?>
                     </select>
🤖 Fix all issues with AI agents
In `@src/Admin/Pages/FilesPage.php`:
- Line 312: The URL string in the anchor uses invalid interpolation "{(int)
$file->id}" and double-encodes the ampersand; fix by casting $file->id to int
before building the query and concatenating it into admin_url, e.g. build the
path using string concatenation with (int) $file->id and a plain '&getid3=' (not
'&amp;'), then pass that full URL to esc_url within the href; update the anchor
generation in FilesPage (the line calling admin_url and esc_url) accordingly.

In `@src/Ajax/LegacyAjaxHandler.php`:
- Around line 106-107: The code reads $_POST['fname'] and calls
validate_file(sb_get_option('upload_dir') . $_POST['fname']) before the request
nonce is verified; move the file-access logic behind the nonce check by
returning the operation type without reading fname, then perform
validate_file(sb_get_option('upload_dir') . $_POST['fname']) inside handleFile()
after verifying the nonce (use the same nonce check function your codebase uses,
e.g., check_ajax_referer or wp_verify_nonce), and if validate_file() fails echo
'invalid' and exit; also sanitize the filename with
sanitize_file_name($_POST['fname']) before using it in handleFile() to avoid
unsafe input.

In `@src/Http/Handler/FileDownloadHandler.php`:
- Line 81: The preg_replace call that assigns $sanitized may return null on PCRE
errors; update the FileDownloadHandler code where $sanitized =
preg_replace('/[\r\n\t]/', '', $fileName); to defensively handle a null result
by checking the return value and falling back to a safe alternative (e.g., use
the original $fileName or an empty string), and surface/log an error if
preg_replace fails so downstream operations that use $sanitized (filename
handling/headers) do not receive null.
🧹 Nitpick comments (3)
src/Admin/Services/FileActionHandler.php (1)

35-36: Consider using wp_unslash() before nonce verification for WordPress coding standards.

While wp_verify_nonce() handles slashed input correctly, WordPress coding standards recommend unslashing superglobal data before use. This is a minor standards compliance issue, not a security concern.

♻️ Optional refactor for coding standards
         if (
             !isset($_POST['sb_file_import_nonce']) ||
-            !wp_verify_nonce($_POST['sb_file_import_nonce'], 'sb_file_import')
+            !wp_verify_nonce(wp_unslash($_POST['sb_file_import_nonce']), 'sb_file_import')
         ) {

Apply the same pattern to line 147 for sb_file_upload_nonce.

src/Admin/Pages/FilesPage.php (1)

376-383: Consider moving inline function definitions outside the loop.

Defining deletelinked_N functions inline for each row creates many small script blocks. While functional, this could be refactored to use a single function with parameters, similar to the rename() and kill() functions already defined in renderPageScripts().

src/Ajax/LegacyAjaxHandler.php (1)

439-442: Handle potentially null $file->title for unlinked files.

When rendering unlinked files, $file->title may be null since unlinked files aren't associated with sermons. Passing null to esc_js() will work but could be cleaner.

♻️ Defensive null handling
         // Escape values for safe use in JavaScript strings.
         $safeName = esc_js($file->name);
-        $safeTitle = esc_js($file->title);
+        $safeTitle = esc_js($file->title ?? '');
         $fileId = (int) $file->id;

<td style="text-align:center">
<a href="<?php echo admin_url("admin.php?page=sermon-browser/new_sermon.php&amp;getid3={$file->id}"); ?>"><?php _e('Create sermon', 'sermon-browser') ?></a> |
<button type="button" id="u-link-<?php echo $file->id; ?>" class="button-link" onclick="rename(<?php echo $file->id; ?>, '<?php echo $file->name; ?>')"><?php _e('Rename', 'sermon-browser'); ?></button> | <button type="button" class="button-link" onclick="if(confirm('Do you really want to delete <?php echo str_replace("'", '', $file->name); ?>?')) kill(<?php echo $file->id; ?>, '<?php echo $file->name; ?>')"><?php _e('Delete', 'sermon-browser'); ?></button>
<a href="<?php echo esc_url(admin_url("admin.php?page=sermon-browser/new_sermon.php&amp;getid3={(int) $file->id}")); ?>"><?php esc_html_e('Create sermon', 'sermon-browser') ?></a> |
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Syntax error in URL string interpolation.

The {(int) $file->id} syntax inside a double-quoted string won't cast to int as intended. The cast operation is not supported within curly brace interpolation.

🐛 Proposed fix
-                    <a href="<?php echo esc_url(admin_url("admin.php?page=sermon-browser/new_sermon.php&amp;getid3={(int) $file->id}")); ?>"><?php esc_html_e('Create sermon', 'sermon-browser') ?></a> |
+                    <a href="<?php echo esc_url(admin_url("admin.php?page=sermon-browser/new_sermon.php&getid3=" . (int) $file->id)); ?>"><?php esc_html_e('Create sermon', 'sermon-browser') ?></a> |

Note: Also removed &amp; since esc_url handles encoding, and using &amp; before esc_url would double-encode it.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<a href="<?php echo esc_url(admin_url("admin.php?page=sermon-browser/new_sermon.php&amp;getid3={(int) $file->id}")); ?>"><?php esc_html_e('Create sermon', 'sermon-browser') ?></a> |
<a href="<?php echo esc_url(admin_url("admin.php?page=sermon-browser/new_sermon.php&getid3=" . (int) $file->id)); ?>"><?php esc_html_e('Create sermon', 'sermon-browser') ?></a> |
🧰 Tools
🪛 GitHub Check: CodeFactor

[notice] 312-312: src/Admin/Pages/FilesPage.php#L312
Line exceeds 120 characters; contains 201 characters. (Generic.Files.LineLength)

🤖 Prompt for AI Agents
In `@src/Admin/Pages/FilesPage.php` at line 312, The URL string in the anchor uses
invalid interpolation "{(int) $file->id}" and double-encodes the ampersand; fix
by casting $file->id to int before building the query and concatenating it into
admin_url, e.g. build the path using string concatenation with (int) $file->id
and a plain '&getid3=' (not '&amp;'), then pass that full URL to esc_url within
the href; update the anchor generation in FilesPage (the line calling admin_url
and esc_url) accordingly.

Comment on lines +106 to +107
if (isset($_POST['fname']) && validate_file(sb_get_option('upload_dir') . $_POST['fname']) === 0) {
return 'file';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Form data accessed before nonce verification.

$_POST['fname'] is passed to validate_file() before the nonce is verified. While validate_file() is a safe function (only checks path validity), this pattern could mask timing-based information disclosure about file paths.

Consider restructuring to verify the nonce first, then determine the specific operation type from validated input.

♻️ Suggested restructure
-        if (isset($_POST['fname']) && validate_file(sb_get_option('upload_dir') . $_POST['fname']) === 0) {
+        if (isset($_POST['fname'])) {
             return 'file';
         }

Then move the validate_file() check into handleFile() after nonce verification:

private static function handleFile(): void
{
    // Validate file path after nonce verification
    if (validate_file(sb_get_option('upload_dir') . $_POST['fname']) !== 0) {
        echo 'invalid';
        die();
    }
    
    $fname = sanitize_file_name($_POST['fname']);
    // ... rest of handler
}
🧰 Tools
🪛 GitHub Check: Codacy Static Code Analysis

[failure] 106-106: src/Ajax/LegacyAjaxHandler.php#L106
$_POST['fname'] not unslashed before sanitization. Use wp_unslash() or similar


[warning] 106-106: src/Ajax/LegacyAjaxHandler.php#L106
Direct use of $_POST Superglobal detected.


[warning] 106-106: src/Ajax/LegacyAjaxHandler.php#L106
Implicit true comparisons prohibited; use === TRUE instead


[failure] 106-106: src/Ajax/LegacyAjaxHandler.php#L106
Processing form data without nonce verification.

🤖 Prompt for AI Agents
In `@src/Ajax/LegacyAjaxHandler.php` around lines 106 - 107, The code reads
$_POST['fname'] and calls validate_file(sb_get_option('upload_dir') .
$_POST['fname']) before the request nonce is verified; move the file-access
logic behind the nonce check by returning the operation type without reading
fname, then perform validate_file(sb_get_option('upload_dir') . $_POST['fname'])
inside handleFile() after verifying the nonce (use the same nonce check function
your codebase uses, e.g., check_ajax_referer or wp_verify_nonce), and if
validate_file() fails echo 'invalid' and exit; also sanitize the filename with
sanitize_file_name($_POST['fname']) before using it in handleFile() to avoid
unsafe input.

private static function sanitizeFilenameForHeader(string $fileName): string
{
// Remove any characters that could be used for header injection.
$sanitized = preg_replace('/[\r\n\t]/', '', $fileName);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle potential preg_replace failure.

preg_replace() can return null on PCRE errors (e.g., backtrack limit exceeded with malformed input). This null would propagate through subsequent operations.

🛡️ Proposed defensive fix
         // Remove any characters that could be used for header injection.
-        $sanitized = preg_replace('/[\r\n\t]/', '', $fileName);
+        $sanitized = preg_replace('/[\r\n\t]/', '', $fileName) ?? $fileName;
🤖 Prompt for AI Agents
In `@src/Http/Handler/FileDownloadHandler.php` at line 81, The preg_replace call
that assigns $sanitized may return null on PCRE errors; update the
FileDownloadHandler code where $sanitized = preg_replace('/[\r\n\t]/', '',
$fileName); to defensively handle a null result by checking the return value and
falling back to a safe alternative (e.g., use the original $fileName or an empty
string), and surface/log an error if preg_replace fails so downstream operations
that use $sanitized (filename handling/headers) do not receive null.

@stephenfeather stephenfeather deleted the security/fix-v0.6.0-blockers branch February 2, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant