运维与安全
在 Laravel 与 Filament 管理后台中安全地上传图片
阅读需 7 分钟
在 Laravel 与 Filament 管理后台中安全地上传图片
在管理后台上传一张图片,看起来是最无害的功能。但它也是最容易让不该存在的东西进入服务器的途径之一。在给这个网站的博客加上图片功能时(每篇文章一张封面图,以及 Markdown 正文中的图片),我首先把它当作安全功能来对待,其次才是内容功能。下面是我采用的几层防护:每一层都很小,单独任何一层都不够。
1. 只接受真正需要的格式
第一个决定是:什么才算图片。Filament 的 FileUpload::image() 接受任何 image/* 类型的文件,其中就包括 SVG。博客需要的是照片和截图,所以白名单要写明确:
public const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
FileUpload::make("{$column}_upload")
->image()
->acceptedFileTypes(self::ACCEPTED_TYPES)
->disk('public')
->directory($directory)
->visibility('public')
->maxSize($maxSizeKb);
为什么不允许 SVG? SVG 不是位图,而是 XML 文档,可以包含 <script> 元素和事件处理器。如果有人直接从你的域名打开一个上传的 SVG,其中的脚本就会在你的源(origin)下运行,能访问页面所能访问的一切。对 SVG 做清洗是可行的,但对作品集博客来说,最简单的安全做法就是干脆不接受。我自己绘制的插画随代码一起发布,不走上传表单。
同样的规则也适用于 Markdown 编辑器中上传的图片。Filament 允许单独配置编辑器附件,因此必须套用相同的白名单和大小限制,否则编辑器就成了一扇后门:
MarkdownEditor::make($name)
->fileAttachmentsDisk('public')
->fileAttachmentsDirectory('images/blog/content')
->fileAttachmentsAcceptedFileTypes(ImageUploadFields::ACCEPTED_TYPES)
->fileAttachmentsMaxSize(4096);
我把白名单放进了后台所有上传字段(项目、技能、联系方式链接和博客)共用的辅助类中,这样收紧一次,处处生效。
2. 限制文件大小
大小限制既防意外也防滥用:一张 40 MB 的未压缩照片会拖慢每个展示它的页面,不加限制的上传还会把磁盘塞满。封面图和正文图片最大 4 MB;技能图标这类较小的资源保持更低的上限。校验在服务端进行,而不仅仅依赖浏览器端的组件。
3. 永远不要信任原始文件名
Filament 使用 Laravel 的 hashName() 保存上传文件:它会生成随机文件名,并根据检测到的内容类型推断扩展名,而不是采用用户输入的名字。这一下子消除了两类问题:藏在文件名里的路径把戏,以及像 photo.png.php 这样指望服务器配置不当而被执行的文件。同时,上传文件的 URL 也不会透露原文件的任何信息。
随后,页面类会把完整的公开 URL 存入模型,视图只需输出 URL,永远不会根据用户输入拼接路径:
protected function storeUploadedImageUrl(array $data, string $column): array
{
$upload = Arr::pull($data, "{$column}_upload");
if (filled($upload)) {
$data[$column] = asset('storage/'.$upload);
}
return $data;
}
4. 让上传目录无法执行代码
即使有了上面所有检查,我仍然假设总有一天会有意料之外的东西进入上传目录。因此,目录本身必须拒绝执行任何内容。使用 Apache 时,在公共存储目录中放一个 .htaccess 即可:
<FilesMatch "\.(php\d?|phtml|phar|pht|phps|cgi|pl|py|sh|asp|aspx|jsp)$">
Require all denied
</FilesMatch>
<IfModule mod_mime.c>
RemoveHandler .php .php3 .php4 .php5 .php7 .php8 .phtml .phar
RemoveType .php .php3 .php4 .php5 .php7 .php8 .phtml .phar
</IfModule>
这就是纵深防御:校验决定什么可以被保存,Web 服务器则保证无论保存了什么,都只会作为静态文件提供。如果使用 Nginx,对应的做法是为存储路径配置一个永远不把请求转给 PHP 的 location 块。
5. 渲染用户内容时不放行 HTML
文章中的图片以 Markdown 形式出现,例如 。渲染 Markdown 时隐藏着第二类问题:Markdown 默认允许原始 HTML,链接也可能携带 javascript: 地址。渲染器的配置让这两者都无法存活:
$html = Str::markdown($markdown, [
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
把 html_input 设为 strip 后,在编辑器里输入的 <img src=x onerror=...> 会被直接去掉,而不会输出到页面。关闭 allow_unsafe_links 后,指向 javascript: 的图片或链接会被中和。Markdown 的图片语法仍然会生成普通的 <img> 元素,作者真正需要的功能一样不少。
6. 用测试来证明
如果将来的某次重构能悄无声息地拿掉其中某一层,那么这些防护就没多大意义。后台测试通过 Filament 表单上传真实文件,既验证正常流程,也验证拒绝情况:
public static function forbiddenCoverFiles(): array
{
return [
'svg with script' => ['evil.svg', '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'],
'php' => ['1.php', '<?php system($_GET["c"]);'],
];
}
#[DataProvider('forbiddenCoverFiles')]
public function test_blog_post_cover_rejects_svg_and_scripts(string $name, string $content): void
{
Livewire::test(CreateBlogPost::class)
->fillForm([/* ... */ 'cover_image_upload' => UploadedFile::fake()->createWithContent($name, $content)])
->call('create')
->assertHasFormErrors(['cover_image_upload']);
$this->assertEmpty(Storage::disk('public')->allFiles('images/blog/covers'));
}
正常用例会检查 PNG 是否以随机文件名保存(保存的 URL 中不得出现原始文件名),以及文件是否确实存在于公共磁盘上。另一个测试会渲染一篇文章,其中包含一张 Markdown 图片、一个原始的 <img onerror> 和一张 javascript: 图片,并断言只有第一张会进入 HTML。
检查清单
- 明确的 MIME 白名单;除非做了清洗,否则不接受 SVG。
- 所有上传途径使用同一套规则,包括编辑器附件。
- 在服务端限制文件大小。
- 使用随机文件名,扩展名由内容推断。
- 上传目录无法执行代码。
- 渲染 Markdown 时去除原始 HTML,并禁用不安全链接。
- 不仅测试正常流程,也要测试拒绝情况。
每一项都只有几行代码。合在一起,它们把"上传一张图片"从一个隐藏的风险,变成了我每次写文章时都无需再操心的功能。