`(见 uploads/.htaccess)—— * 所有附件一律经 `media.php` 出去,而 `media.php` **要求登录态**(未登录 401)。 * 线上实测:直接取 `uploads/nav-icons/xxx.png` 得 **403**。 * ② 但**导航展示页 `/nav/?<短语>` 是全站唯一免登录页**(用户明确要求), * 而它的图标请求也来自浏览器、同样不带登录态。 * ⇒ 于是"要登录才给图"和"公开页面要显示图"直接冲突:图标一个都显示不出来。 * 这正是用户报的「图标也没有」在服务端的那一半原因。 * * 做法:仿 media.php 开一个**窄得多的**公开出口 —— * 只服务 `Nav::ICON_DIR` 这一个目录、且文件名必须严格符合 * `md5串.<扩展名>`。别的一律 404(连目录都不列)。 * * ⚠️ 安全性说明(为什么这里敢免登录): * · 图标是**公开网站自己的 favicon**,不含任何用户隐私; * · 文件名是 `md5(网址)`,且这些网址本来就列在**同样是公开的**展示页上 —— * 这里的暴露面不比展示页本身更大; * · 只能取到这一层目录里的"名字长得像图标"的文件,路径穿越 / 目录遍历都不可能; * · 目录写权限在部署账号手里(见部署脚本),Web 侧只读。 * * 用法:`/nav_icon.php?f=<文件名>&v=` * ⚠️ `v` 只用于缓存击穿,不参与鉴权(值随便传也不会取到别的文件)。 */ declare(strict_types=1); require __DIR__ . '/app/bootstrap.php'; use App\Nav; /** 统一的失败出口(与 media.php 同一套做法:不吐任何多余信息) */ function nav_icon_fail(int $code, string $message): void { http_response_code($code); header('Content-Type: text/plain; charset=utf-8'); header('Cache-Control: no-store'); echo $message; exit; } $file = (string)($_GET['f'] ?? ''); // ---- ① 只认文件名(杜绝任何路径成分)---- // // ⚠️ 用 basename 而不是"检查有没有 `..`":前者把 `a/../../x.png` 直接截成 `x.png`, // 连"要判断什么"都不需要判断;后者总有没想到的编码绕过(%2e%2e、双重编码…)。 $file = basename(str_replace('\\', '/', $file)); // ---- ② 严格白名单:必须是「32 位十六进制 + 图片扩展名」---- // // ⚠️ 这一条是**真正的门**:没有它,`f=.htaccess` 之类就能被读出去 // (哪怕 basename 已经挡住了路径,文件名本身仍然可以是敏感文件)。 if (!preg_match('/^[a-f0-9]{32}\.(png|jpe?g|gif|webp|svg|ico)$/i', $file)) { nav_icon_fail(404, '没有这个图标'); } $abs = FB_ROOT . '/' . Nav::ICON_DIR . '/' . $file; if (!is_file($abs)) { nav_icon_fail(404, '没有这个图标'); } // ---- ③ MIME:由**内容**嗅探,绝不信扩展名 ---- // // (与 media.php 同一口径。SVG 会被 finfo 认成 text/plain,按扩展名补回。) $body = (string)@file_get_contents($abs); $mime = (new \finfo(FILEINFO_MIME_TYPE))->buffer($body) ?: ''; if ($mime === '' || $mime === 'application/octet-stream') { $mime = 'image/png'; } $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); if ($ext === 'svg' || $mime === 'image/svg+xml') { $mime = 'image/svg+xml'; } // 能被浏览器当页面/脚本执行的一律降级(与 media.php 同一条防线) if (strpos($mime, 'text/html') !== false || strpos($mime, 'javascript') !== false || strpos($mime, 'xhtml') !== false) { $mime = 'application/octet-stream'; } $etag = '"' . md5($file . '|' . strlen($body)) . '"'; $inm = trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')); if ($inm !== '' && $inm === $etag) { http_response_code(304); header('ETag: ' . $etag); header('Cache-Control: public, max-age=31536000, immutable'); exit; } http_response_code(200); header('Content-Type: ' . $mime); header('Content-Length: ' . strlen($body)); // ⚠️ 长缓存 + immutable 是安全的:文件名里含 `md5(网址)`,换图必换名; // 调用方还会再挂一个 `?v=`(见 Nav::iconUrl),双保险。 header('Cache-Control: public, max-age=31536000, immutable'); header('ETag: ' . $etag); header('X-Content-Type-Options: nosniff'); if ($mime === 'image/svg+xml') { // SVG 能带脚本 → 即便要显示,也禁止执行脚本、禁止加载外部资源 header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox"); } echo $body; exit;