diff --git a/.htaccess b/.htaccess index 08f562d..c27d6dc 100644 --- a/.htaccess +++ b/.htaccess @@ -1,7 +1,17 @@ -# 安全与整洁:禁用目录列表;阻止直接下载数据库 / 说明文件 +# FeedLite —— 安全与整洁(Apache 规则) +# +# 作用:禁用目录列表;阻止对数据库 / 配置文件 / 备份文件 / 文档的直接下载。 +# ⚠️ 这些规则只拦「静态直读」。.php 文件本身仍是「被 PHP 执行」而非下载, +# 所以拦截的重点是「非 .php 后缀但内容敏感」的文件 —— 例如手工或工具产生的 +# config.php.bak_20260928_193339(名字以数字结尾!旧写法的 \.bak$ 匹配不到它, +# 于是它被当作静态文件原样吐出,等于公开了管理员口令与接口令牌)。 +# 因此后缀匹配一律放宽为「后缀出现在文件名任意位置」,而不是只匹配结尾。 + Options -Indexes - +# —— 数据库 / 日志 / 文档 / 配置与备份(含任意后缀形式)—— +# 关键点:不写 $ 结尾锚点,避免 config.php.bak_20260928_193339 这类名字漏网。 + Require all denied @@ -10,3 +20,42 @@ Options -Indexes Deny from all + +# —— 兜底:任何「含 .php 且后面还跟着别的东西」的文件一律拒绝 —— +# 正常 PHP 入口都是 xxx.php 结尾,会被 PHP 执行; +# 而 config.php.bak_20260928、config.php.txt、admin.php.old 这类 +# 不会被执行、只会被当文本下载 —— 全部拒掉。 + + + Require all denied + + + Order allow,deny + Deny from all + + + +# —— 隐藏文件(.env / .git 之类)—— + + + Require all denied + + + Order allow,deny + Deny from all + + + +# —— 点名拒绝:只含真实凭据的配置文件(正常请求永远不会直接访问它们)—— +# 它们通常会被 PHP 执行(返回空 body)而不泄漏,但显式拒绝可以防「PHP 处理器被改 +# 成静态输出」「服务器换了不支持的执行配置」这类意外,属于纵深防御。 +# 注意:文件系统层面的 require 不受影响,程序读配置照常工作。 + + + Require all denied + + + Order allow,deny + Deny from all + + \ No newline at end of file diff --git a/README.md b/README.md index 0d0cf12..5f3a663 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ feedback/ ├─ admin.php 管理后台(口令登录:审核 / 管理全部问题 / 站点设置 / 操作日志) ├─ install.php 安装向导(数据库不存在时自动引导进来;装完建议删除) ├─ upload.php 附件上传接口(两步:领对象键 → 收文件本体,local 落盘 / OSS 代理 PUT) -├─ media.php 附件代理输出(私有桶 + 内网 Endpoint 下图片/视频的唯一对外读取通道,带鉴权与 Range) +├─ media.php 附件代理输出(图片/视频的**唯一对外读取通道**:无论存本机还是 OSS,前台地址都是 media.php?k=…,带鉴权与 Range) ├─ captcha.php "换一题"用的人机验证刷新接口(返回 JSON) ├─ api.php 只读数据接口(需令牌),供自动化 / AI 读取 ├─ cron.php 附件过期清理 + 通知邮件补发(配计划任务) @@ -36,7 +36,7 @@ feedback/ ├─ lib/ 内部库(bootstrap / db / util / oss / attach / captcha / mailer / layout) ├─ assets/ style.css / app.js / 图标(favicon、各尺寸 PNG、site.webmanifest) ├─ data/ SQLite 数据库目录(已用 .htaccess 拒绝访问) -├─ uploads/ 本机附件目录(仅"本机硬盘"模式下使用;PHP 按需自动创建) +├─ uploads/ 本机附件目录(仅"本机硬盘"模式下使用;PHP 按需自动创建;**目录禁止 Web 直连,附件只走 media.php**) ├─ .htaccess 禁止目录列表、禁止下载 .db/.md └─ README.md 本文件 ``` @@ -275,14 +275,29 @@ php /www/wwwroot/你的域名/feedback/cron.php > `local/…` 一律按本机文件读,`fb/…`(或你配的 `oss.prefix`)一律按 OSS 读。 > 所以中途从 OSS 换成硬盘(或反过来)时,**已经发过的帖子里的附件照常显示**。 +> ★ **前台地址与存储方式无关(统一代理)** +> 无论选「本机硬盘」还是「阿里云 OSS」,页面上的图片 / 视频地址**永远**是同一个形态: +> ``` +> media.php?k=<对象键> 例:media.php?k=local%2F2026%2F09%2Fabcd.jpg +> ``` +> 这样做有两个好处: +> 1. **路径统一**:存储方式对页面与前端 JS 完全透明,换存储不用改前端、也不会出现两套 URL 逻辑; +> 2. **不暴露真实地址**:本机模式下不会给出 `uploads/2026/09/xxxx.jpg` 这类可被猜目录、被批量抓取的直链; +> OSS 模式下更不会出现任何 bucket / endpoint / 签名痕迹。 +> 真实的物理位置只由服务器在 `media.php` 内部按对象键前缀解析。 +> 数据库里**只存对象键**,地址是渲染时现算的 —— 所以这项改动对**历史附件也立即生效,无需迁移数据**。 + **本机硬盘模式的两个注意点** 1. 目录由 PHP **按需自动创建**(`fb_local_writable()` 里的 `@mkdir`),属主就是 PHP 进程用户,天然可写。 ⚠️ **不要用 SFTP/部署账号手动建这个 `uploads/` 目录**——那样属主会变成部署账号, PHP 写不进去,上传会失败。后台「存储体检」会明确显示"可写 ✅ / 不可写 ❌"并给出处置办法; 真遇到了,**删掉整个 `uploads/`(含 `.htaccess`)再刷新页面**,PHP 会重建。 -2. 目录里自动带一个 `.htaccess`:禁止列目录、禁止执行 `.php`/`.pl`/`.py`/`.cgi`/`.sh` 等脚本, - 只允许当静态图片 / 视频读取。对象键由服务端生成并做正则白名单校验,客户端无法指定路径。 +2. 目录里自动带一个 `.htaccess`:**整个目录一律拒绝 Web 直接访问**(附件只走 `media.php` 代理, + 真实路径不外泄),并额外禁止执行 `.php`/`.pl`/`.py`/`.cgi`/`.sh` 等脚本。 + 该规则带**版本标记**(`# fb-local-guard: vN`),程序每次确保目录时比对版本, + 老站点上残留的旧版 `.htaccess` 会被**自动升级**,不需要手工替换。 + 对象键由服务端生成并做正则白名单校验,客户端无法指定路径。 --- diff --git a/admin.php b/admin.php index 178cf08..df4d036 100644 --- a/admin.php +++ b/admin.php @@ -577,7 +577,7 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']); - 本机:文件落在站点 uploads/ 目录;OSS:文件经服务器代理写入私有桶(走内网 Endpoint),公网无法直连桶。两种方式都需要服务器磁盘能放下单个上传文件。 + 本机:文件落在站点 uploads/ 目录;OSS:文件经服务器代理写入私有桶(走内网 Endpoint),公网无法直连桶。两种方式都需要服务器磁盘能放下单个上传文件。前台展示一律经 media.php 代理——无论存哪,页面上的图片/视频地址都是 media.php?k=…,不暴露 uploads/ 真实路径,也不出现任何 OSS 桶信息。
diff --git a/lib/attach.php b/lib/attach.php index a878661..c06cdf2 100644 --- a/lib/attach.php +++ b/lib/attach.php @@ -38,7 +38,28 @@ function fb_local_dir() { * 确保本机附件目录存在,并补齐安全防护文件(.htaccess / index.html)。 * ⚠️ 目录被删掉后由 PHP 重建时,.htaccess 不会自动回来 —— 必须在这里补写, * 否则「禁列目录 + 禁执行脚本」的防护就丢了。 + * + * ⚠️⚠️ 【版本化升级】只判「文件在不在」是不够的:老站点上早已存在的 + * 旧版 .htaccess(允许直读)会一直留着,新规则永远写不进去。 + * 所以规则串里埋一行 `# fb-local-guard: vN`,每次确保时比对版本号, + * 低于当前版本就整份重写。改规则时务必把 FB_LOCAL_GUARD_VER 一起 +1。 */ +define('FB_LOCAL_GUARD_VER', 2); + +function fb_local_guard_rule() { + return "# 本机附件目录(由程序自动生成)\n" + . "# fb-local-guard: v" . FB_LOCAL_GUARD_VER . "\n" + . "# ★ 一律禁止 Web 直接访问:附件只走 media.php 代理,真实路径不暴露\n" + . "Options -Indexes\n\n" + . "\n Require all denied\n\n" + . "\n Order allow,deny\n Deny from all\n\n\n" + . "\n" + . " \n Require all denied\n \n" + . " \n Order allow,deny\n Deny from all\n \n" + . "\n"; +} + +/** 确保目录存在并补齐防护文件(规则版本见上方 FB_LOCAL_GUARD_VER) */ function fb_local_ensure_dir() { $dir = fb_local_dir(); if (!is_dir($dir)) { @@ -48,15 +69,8 @@ function fb_local_ensure_dir() { } $ht = $dir . '/.htaccess'; - if (!is_file($ht)) { - $rule = "# 本机附件目录(由程序自动生成)\n" - . "# 允许直接读取图片 / 视频,但禁止列出目录、禁止执行任何脚本\n" - . "Options -Indexes\n\n" - . "\n" - . " \n Require all denied\n \n" - . " \n Order allow,deny\n Deny from all\n \n" - . "\n"; - @file_put_contents($ht, $rule); + if (!is_file($ht) || fb_local_guard_stale($ht)) { + @file_put_contents($ht, fb_local_guard_rule()); @chmod($ht, 0644); } @@ -68,6 +82,19 @@ function fb_local_ensure_dir() { return true; } +/** + * 现存的 .htaccess 是不是旧版(需要升级)。 + * 判据:拿不到 `fb-local-guard: vN` 标记,或标记里的 N < FB_LOCAL_GUARD_VER + * → 都算过期,整份重写(旧文件没有标记,正是最需要升级的那种)。 + * 只在目录可写时才重写,避免不可写环境下每次都白试一遍。 + */ +function fb_local_guard_stale($ht) { + $body = @file_get_contents($ht, false, null, 0, 4096); + if ($body === false) { return false; } + if (strpos($body, '# fb-local-guard: v' . FB_LOCAL_GUARD_VER) !== false) { return false; } + return true; +} + /** 本机附件是否可写 */ function fb_local_writable() { fb_local_ensure_dir(); @@ -130,17 +157,36 @@ function fb_local_path_of($key) { return fb_local_dir() . '/' . ltrim($rel, '/'); } -/** 附件的访问地址(自动按 key 前缀分流) */ -function fb_attach_url($key) { - if (fb_attach_is_local($key)) { - return fb_base_url() . '/uploads/' . substr(ltrim((string)$key, '/'), 6); - } - return fb_oss_url($key); +/** + * 附件的【唯一对外地址】—— 一律走本站 media.php 代理。 + * ------------------------------------------------------------------ + * ★ 无论附件存在本机 uploads/ 还是阿里云 OSS,前台给浏览器的都是这一个形态: + * media.php?k=<对象键> + * 两个目的: + * ① 路径统一 —— 存储方式(local / oss)对页面与前端 JS 完全透明, + * 换存储方式不会产生两套 URL 逻辑、旧的本地附件也不会失效; + * ② 不暴露真实地址 —— 本机模式下不再直接给出 uploads/2026/09/xxx.jpg + * (避免被猜目录、被批量抓取),OSS 模式下更不会出现任何 bucket / endpoint 痕迹。 + * 真实的物理位置只由服务器在 media.php 内部按 key 前缀解析。 + * ⚠️ 数据库里只存对象键(oss_key),地址是渲染时现算的 —— + * 所以本改动对【历史附件】同样立即生效,无需迁移数据。 + */ +function fb_attach_proxy_url($key) { + return fb_base_url() . '/media.php?k=' . rawurlencode(ltrim((string)$key, '/')); } -/** 附件的展示地址(缩略图;本机存储没有图片处理,直接给原图) */ +/** 附件的访问地址(原图;统一走 media.php 代理,与存储方式无关) */ +function fb_attach_url($key) { + return fb_attach_proxy_url($key); +} + +/** + * 附件的展示地址(缩略图;同样统一走 media.php 代理) + * · OSS:借 OSS 图片处理在服务端缩放后再转发,省下行流量; + * · 本机:没有图片处理能力,直接给原图(仍是 media.php 代理地址)。 + */ function fb_attach_thumb_url($key, $w = 640, $mime = '') { - if (fb_attach_is_local($key)) { return fb_attach_url($key); } + if (fb_attach_is_local($key)) { return fb_attach_proxy_url($key); } return fb_oss_thumb_url($key, $w, $mime); } diff --git a/lib/bootstrap.php b/lib/bootstrap.php index 7e62235..e098922 100644 --- a/lib/bootstrap.php +++ b/lib/bootstrap.php @@ -132,4 +132,11 @@ if (PHP_SAPI !== 'cli' && session_status() !== PHP_SESSION_ACTIVE) { // install.php 在真正提交安装时会自己显式调用 fb_db()。 if (fb_installed()) { fb_db(); + // 顺手确保 uploads/ 目录与「禁止直读」规则在位且为最新版(local / oss 都需要)。 + // ⚠️ 为什么放在这里:老站点上早已存在的旧版 .htaccess(曾允许直读)不会自己更新, + // 而部署账号通常对 uploads/ 无写权限 —— 只有 PHP(目录属主)能改。 + // 函数内部只在「文件缺失或版本标记过期」时才写盘,命中后每次请求几乎零开销。 + if (function_exists('fb_local_ensure_dir')) { + fb_local_ensure_dir(); + } } diff --git a/media.php b/media.php index 6cd9d3a..51c60c6 100644 --- a/media.php +++ b/media.php @@ -12,8 +12,9 @@ * ③ 只允许图片/视频白名单 MIME,且透传 Range 以支持视频拖动进度条。 * ④ 全程流式转发,不把文件读进内存(memory_limit 小也安全)。 * - * 本机存储(local/...)的附件由 Web 服务器直接吐 uploads/ 下的文件,不走这里; - * 但为兼容旧链接,这里也做一次本地回退。 + * 本机存储(local/...)与本文件的 OSS 分支【前台地址完全一致】: + * 两种存储方式对外都只暴露 media.php?k=... —— 路径统一、真实地址不泄漏。 + * uploads/ 目录自身也被 .htaccess 全部拒绝,直连真实路径取不到东西。 */ require __DIR__ . '/lib/bootstrap.php'; @@ -40,7 +41,8 @@ if ($process !== '') { } } -/* ---------------- 本机存储:直接读磁盘并流式输出 ---------------- */ +/* ---------------- 本机存储:直接读磁盘并流式输出 ---------------- + * 与前台的统一代理地址配套:local 附件也走这里,uploads/ 目录本身禁止直连。 */ if (fb_attach_is_local($key)) { // 先鉴权再探文件,避免用 404/403 差异被探测对象是否存在 if (!fb_media_authorized($key)) { fb_media_fail(403, '无权访问该附件'); } @@ -53,6 +55,7 @@ if (fb_attach_is_local($key)) { header('Content-Length: ' . $size); header('Accept-Ranges: bytes'); header('Cache-Control: public, max-age=86400'); + header('X-Content-Type-Options: nosniff'); // 与 OSS 分支保持一致 fb_media_send_local($path, $size); exit; } diff --git a/upload.php b/upload.php index 78b2d9f..8309398 100644 --- a/upload.php +++ b/upload.php @@ -15,6 +15,9 @@ * 浏览器既拿不到可直连的 URL、也连不上内网地址,所以「浏览器直传」在此架构下不成立。 * 读取同理,走 media.php 代理(见该文件)。 * + * ★ 前台地址与存储方式无关:local 与 oss 返回的 url 都是 media.php?k=..., + * 页面/前端 JS 看不到 uploads/ 真实路径,也看不到任何 OSS 痕迹。 + * * 两种方式共用同一套校验:扩展名 / MIME 白名单、单文件大小、每 IP 频率、全站总量上限。 */ require __DIR__ . '/lib/bootstrap.php'; diff --git a/uploads/.htaccess b/uploads/.htaccess index e7eff14..e4e5cc5 100644 --- a/uploads/.htaccess +++ b/uploads/.htaccess @@ -1,7 +1,16 @@ -# 本机附件目录 -# 允许直接读取图片 / 视频,但禁止列出目录、禁止执行任何脚本 +# 本机附件目录(由程序自动生成) +# fb-local-guard: v2 +# ★ 一律禁止 Web 直接访问:附件只走 media.php 代理,真实路径不暴露 Options -Indexes + + Require all denied + + + Order allow,deny + Deny from all + + Require all denied