Responses: Added extra sanitization for download names

From testing, don't think this could exploited directly, as the response
would error instead of allowing control characters, but this adds an
extra layer of sanitization, and switches to encoded disposition
filenames for better UTF8 support.
This commit is contained in:
Dan Brown
2026-03-16 18:28:44 +00:00
parent f4c9d2b049
commit a9ffd3e0c7
7 changed files with 47 additions and 29 deletions

View File

@@ -102,12 +102,15 @@ class DownloadResponseFactory
protected function getHeaders(string $fileName, int $fileSize, string $mime = 'application/octet-stream'): array
{
$disposition = ($mime === 'application/octet-stream') ? 'attachment' : 'inline';
$downloadName = str_replace('"', '', $fileName);
$downloadName = str_replace(['"', '/', '\\', '$'], '', $fileName);
$downloadName = preg_replace('/[\x00-\x1F\x7F]/', '', $downloadName);
$encodedDownloadName = rawurlencode($downloadName);
return [
'Content-Type' => $mime,
'Content-Length' => $fileSize,
'Content-Disposition' => "{$disposition}; filename=\"{$downloadName}\"",
'Content-Disposition' => "{$disposition}; filename*=UTF-8''{$encodedDownloadName}",
'X-Content-Type-Options' => 'nosniff',
];
}

View File

@@ -19,7 +19,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/books/{$book->id}/export/html");
$resp->assertStatus(200);
$resp->assertSee($book->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.html');
}
public function test_book_plain_text_endpoint()
@@ -30,7 +30,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/books/{$book->id}/export/plaintext");
$resp->assertStatus(200);
$resp->assertSee($book->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.txt');
}
public function test_book_pdf_endpoint()
@@ -40,7 +40,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/books/{$book->id}/export/pdf");
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.pdf');
}
public function test_book_markdown_endpoint()
@@ -50,7 +50,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/books/{$book->id}/export/markdown");
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.md"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.md');
$resp->assertSee('# ' . $book->name);
$resp->assertSee('# ' . $book->pages()->first()->name);
$resp->assertSee('# ' . $book->chapters()->first()->name);
@@ -63,7 +63,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/books/{$book->id}/export/zip");
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.zip"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.zip');
$zip = ZipTestHelper::extractFromZipResponse($resp);
$this->assertArrayHasKey('book', $zip->data);
@@ -77,7 +77,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/chapters/{$chapter->id}/export/html");
$resp->assertStatus(200);
$resp->assertSee($chapter->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.html');
}
public function test_chapter_plain_text_endpoint()
@@ -88,7 +88,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/chapters/{$chapter->id}/export/plaintext");
$resp->assertStatus(200);
$resp->assertSee($chapter->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.txt');
}
public function test_chapter_pdf_endpoint()
@@ -98,7 +98,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/chapters/{$chapter->id}/export/pdf");
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.pdf');
}
public function test_chapter_markdown_endpoint()
@@ -108,7 +108,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/chapters/{$chapter->id}/export/markdown");
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.md"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.md');
$resp->assertSee('# ' . $chapter->name);
$resp->assertSee('# ' . $chapter->pages()->first()->name);
}
@@ -120,7 +120,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/chapters/{$chapter->id}/export/zip");
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.zip"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.zip');
$zip = ZipTestHelper::extractFromZipResponse($resp);
$this->assertArrayHasKey('chapter', $zip->data);
@@ -134,7 +134,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/pages/{$page->id}/export/html");
$resp->assertStatus(200);
$resp->assertSee($page->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.html');
}
public function test_page_plain_text_endpoint()
@@ -145,7 +145,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/pages/{$page->id}/export/plaintext");
$resp->assertStatus(200);
$resp->assertSee($page->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.txt');
}
public function test_page_pdf_endpoint()
@@ -155,7 +155,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/pages/{$page->id}/export/pdf");
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.pdf');
}
public function test_page_markdown_endpoint()
@@ -166,7 +166,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/pages/{$page->id}/export/markdown");
$resp->assertStatus(200);
$resp->assertSee('# ' . $page->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.md');
}
public function test_page_zip_endpoint()
@@ -176,7 +176,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/pages/{$page->id}/export/zip");
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.zip"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.zip');
$zip = ZipTestHelper::extractFromZipResponse($resp);
$this->assertArrayHasKey('page', $zip->data);

View File

@@ -18,7 +18,7 @@ class HtmlExportTest extends TestCase
$resp = $this->get($page->getUrl('/export/html'));
$resp->assertStatus(200);
$resp->assertSee($page->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.html');
}
public function test_book_html_export()
@@ -31,7 +31,7 @@ class HtmlExportTest extends TestCase
$resp->assertStatus(200);
$resp->assertSee($book->name);
$resp->assertSee($page->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.html');
}
public function test_book_html_export_shows_html_descriptions()
@@ -58,7 +58,7 @@ class HtmlExportTest extends TestCase
$resp->assertStatus(200);
$resp->assertSee($chapter->name);
$resp->assertSee($page->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.html');
}
public function test_chapter_html_export_shows_html_descriptions()

View File

@@ -14,7 +14,7 @@ class MarkdownExportTest extends TestCase
$resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
$resp->assertStatus(200);
$resp->assertSee($page->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.md');
}
public function test_page_markdown_export_uses_existing_markdown_if_apparent()

View File

@@ -17,7 +17,7 @@ class PdfExportTest extends TestCase
$resp = $this->get($page->getUrl('/export/pdf'));
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.pdf');
}
public function test_book_pdf_export()
@@ -28,7 +28,7 @@ class PdfExportTest extends TestCase
$resp = $this->get($book->getUrl('/export/pdf'));
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.pdf');
}
public function test_chapter_pdf_export()
@@ -38,7 +38,7 @@ class PdfExportTest extends TestCase
$resp = $this->get($chapter->getUrl('/export/pdf'));
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.pdf');
}

View File

@@ -14,7 +14,7 @@ class TextExportTest extends TestCase
$resp = $this->get($page->getUrl('/export/plaintext'));
$resp->assertStatus(200);
$resp->assertSee($page->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.txt');
}
public function test_book_text_export()
@@ -35,7 +35,7 @@ class TextExportTest extends TestCase
$resp->assertSee($directPage->name);
$resp->assertSee('My awesome page');
$resp->assertSee('My little nested page');
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.txt');
}
public function test_book_text_export_format()
@@ -68,7 +68,7 @@ class TextExportTest extends TestCase
$resp->assertSee($chapter->name);
$resp->assertSee($page->name);
$resp->assertSee('This is content within the page!');
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.txt');
}
public function test_chapter_text_export_format()

View File

@@ -324,7 +324,7 @@ class AttachmentTest extends TestCase
$attachmentGet = $this->get($attachment->getUrl(true));
// http-foundation/Response does some 'fixing' of responses to add charsets to text responses.
$attachmentGet->assertHeader('Content-Type', 'text/plain; charset=utf-8');
$attachmentGet->assertHeader('Content-Disposition', 'inline; filename="upload_test_file.txt"');
$attachmentGet->assertHeader('Content-Disposition', 'inline; filename*=UTF-8\'\'upload_test_file.txt');
$attachmentGet->assertHeader('X-Content-Type-Options', 'nosniff');
$this->files->deleteAllAttachmentFiles();
@@ -340,7 +340,22 @@ class AttachmentTest extends TestCase
$attachmentGet = $this->get($attachment->getUrl(true));
// http-foundation/Response does some 'fixing' of responses to add charsets to text responses.
$attachmentGet->assertHeader('Content-Type', 'text/plain; charset=utf-8');
$attachmentGet->assertHeader('Content-Disposition', 'inline; filename="test_file.html"');
$attachmentGet->assertHeader('Content-Disposition', 'inline; filename*=UTF-8\'\'test_file.html');
$this->files->deleteAllAttachmentFiles();
}
public function test_file_access_name_in_content_disposition_header_is_sanitized()
{
$page = $this->entities->page();
$this->asAdmin();
$attachment = $this->files->uploadAttachmentDataToPage($this, $page, 'test_file.html', '<html></html><p>testing</p>', 'text/html');
$attachment->name = "my\\_/super\n_fu\$n_\tfile";
$attachment->save();
$attachmentGet = $this->get($attachment->getUrl(true));
$attachmentGet->assertHeader('Content-Disposition', 'inline; filename*=UTF-8\'\'my_super_fun_file.html');
$this->files->deleteAllAttachmentFiles();
}