Skip to content

Commit ae155d6

Browse files
committed
Added safe mime sniffing to prevent serving HTML
(Amoung other content types) For #3027
1 parent 5c834f2 commit ae155d6

File tree

3 files changed

+106
-6
lines changed

3 files changed

+106
-6
lines changed

app/Http/Controllers/Controller.php

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use BookStack\Facades\Activity;
66
use BookStack\Interfaces\Loggable;
77
use BookStack\Model;
8+
use BookStack\Util\WebSafeMimeSniffer;
89
use finfo;
910
use Illuminate\Foundation\Bus\DispatchesJobs;
1011
use Illuminate\Foundation\Validation\ValidatesRequests;
@@ -117,8 +118,9 @@ protected function jsonError(string $messageText = '', int $statusCode = 500): J
117118
protected function downloadResponse(string $content, string $fileName): Response
118119
{
119120
return response()->make($content, 200, [
120-
'Content-Type' => 'application/octet-stream',
121-
'Content-Disposition' => 'attachment; filename="' . $fileName . '"',
121+
'Content-Type' => 'application/octet-stream',
122+
'Content-Disposition' => 'attachment; filename="' . $fileName . '"',
123+
'X-Content-Type-Options' => 'nosniff',
122124
]);
123125
}
124126

@@ -128,12 +130,13 @@ protected function downloadResponse(string $content, string $fileName): Response
128130
*/
129131
protected function inlineDownloadResponse(string $content, string $fileName): Response
130132
{
131-
$finfo = new finfo(FILEINFO_MIME_TYPE);
132-
$mime = $finfo->buffer($content) ?: 'application/octet-stream';
133+
134+
$mime = (new WebSafeMimeSniffer)->sniff($content);
133135

134136
return response()->make($content, 200, [
135-
'Content-Type' => $mime,
136-
'Content-Disposition' => 'inline; filename="' . $fileName . '"',
137+
'Content-Type' => $mime,
138+
'Content-Disposition' => 'inline; filename="' . $fileName . '"',
139+
'X-Content-Type-Options' => 'nosniff',
137140
]);
138141
}
139142

app/Util/WebSafeMimeSniffer.php

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
namespace BookStack\Util;
4+
5+
use finfo;
6+
7+
/**
8+
* Helper class to sniff out the mime-type of content resulting in
9+
* a mime-type that's relatively safe to serve to a browser.
10+
*/
11+
class WebSafeMimeSniffer
12+
{
13+
14+
/**
15+
* @var string[]
16+
*/
17+
protected $safeMimes = [
18+
'application/json',
19+
'application/octet-stream',
20+
'application/pdf',
21+
'image/bmp',
22+
'image/jpeg',
23+
'image/png',
24+
'image/gif',
25+
'image/webp',
26+
'image/avif',
27+
'image/heic',
28+
'text/css',
29+
'text/csv',
30+
'text/javascript',
31+
'text/json',
32+
'text/plain',
33+
'video/x-msvideo',
34+
'video/mp4',
35+
'video/mpeg',
36+
'video/ogg',
37+
'video/webm',
38+
'video/vp9',
39+
'video/h264',
40+
'video/av1',
41+
];
42+
43+
/**
44+
* Sniff the mime-type from the given file content while running the result
45+
* through an allow-list to ensure a web-safe result.
46+
* Takes the content as a reference since the value may be quite large.
47+
*/
48+
public function sniff(string &$content): string
49+
{
50+
$fInfo = new finfo(FILEINFO_MIME_TYPE);
51+
$mime = $fInfo->buffer($content) ?: 'application/octet-stream';
52+
53+
if (in_array($mime, $this->safeMimes)) {
54+
return $mime;
55+
}
56+
57+
[$category] = explode('/', $mime, 2);
58+
if ($category === 'text') {
59+
return 'text/plain';
60+
}
61+
62+
return 'application/octet-stream';
63+
}
64+
65+
}

tests/Uploads/AttachmentTest.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use BookStack\Uploads\AttachmentService;
1010
use Illuminate\Http\UploadedFile;
1111
use Tests\TestCase;
12+
use Tests\TestResponse;
1213

1314
class AttachmentTest extends TestCase
1415
{
@@ -44,6 +45,20 @@ protected function createAttachment(Page $page): Attachment
4445
return Attachment::query()->latest()->first();
4546
}
4647

48+
/**
49+
* Create a new upload attachment from the given data.
50+
*/
51+
protected function createUploadAttachment(Page $page, string $filename, string $content, string $mimeType): Attachment
52+
{
53+
$file = tmpfile();
54+
$filePath = stream_get_meta_data($file)['uri'];
55+
file_put_contents($filePath, $content);
56+
$upload = new UploadedFile($filePath, $filename, $mimeType, null, true);
57+
58+
$this->call('POST', '/attachments/upload', ['uploaded_to' => $page->id], [], ['file' => $upload], []);
59+
return $page->attachments()->latest()->firstOrFail();
60+
}
61+
4762
/**
4863
* Delete all uploaded files.
4964
* To assist with cleanup.
@@ -305,7 +320,24 @@ public function test_file_access_with_open_query_param_provides_inline_response_
305320
// http-foundation/Response does some 'fixing' of responses to add charsets to text responses.
306321
$attachmentGet->assertHeader('Content-Type', 'text/plain; charset=UTF-8');
307322
$attachmentGet->assertHeader('Content-Disposition', 'inline; filename="upload_test_file.txt"');
323+
$attachmentGet->assertHeader('X-Content-Type-Options', 'nosniff');
308324

309325
$this->deleteUploads();
310326
}
327+
328+
public function test_html_file_access_with_open_forces_plain_content_type()
329+
{
330+
$page = Page::query()->first();
331+
$this->asAdmin();
332+
333+
$attachment = $this->createUploadAttachment($page, 'test_file.html', '<html></html><p>testing</p>', 'text/html');
334+
335+
$attachmentGet = $this->get($attachment->getUrl(true));
336+
// http-foundation/Response does some 'fixing' of responses to add charsets to text responses.
337+
$attachmentGet->assertHeader('Content-Type', 'text/plain; charset=UTF-8');
338+
$attachmentGet->assertHeader('Content-Disposition', 'inline; filename="test_file.html"');
339+
340+
$this->deleteUploads();
341+
}
342+
311343
}

0 commit comments

Comments
 (0)