本文中的项目名称、域名、主机名、账号、目录、Cookie 名称、时间参数、容量限制和命令均为虚构示例,仅用于说明技术方案,不对应任何真实部署环境。

一、项目目标

在家庭服务器或小型内部网络中,经常需要一种比网盘更轻量的文件交换工具:

  • 不希望部署庞大的协作平台;
  • 不希望为少量使用者维护账号密码;
  • 电脑端只需要临时登录;
  • 上传文件应自动到期;
  • 不同使用者之间必须完全隔离;
  • 管理页面不能随着历史记录增加而无限膨胀;
  • 用户文件、数据库和程序配置不能被公网直接访问。

基于这些需求,设计了一个名为 LanternBox 的示例系统。

它采用 PHP、SQLite 和现有 Web 服务器运行,不新增独立公网端口,不依赖常驻应用服务,也不把用户文件映射成静态下载地址。

整体结构如下:

Internet
   │
   ▼
Reverse Proxy
   │
   ▼
Web Server + PHP-FPM
   │
   ├── Application Code
   ├── SQLite Database
   ├── Private User Storage
   └── Cleanup Worker

二维码只负责临时授权。真正的用户身份由已经登记的手机浏览器持有,电脑获得的则是短期会话。


二、示例部署结构

示例环境使用以下虚构信息:

Public URL:
https://vault.example.net/dropbox/

Application host:
app-node.internal

Reverse proxy:
edge-node.internal

Project directory:
/srv/www/lanternbox/

Public directory:
/srv/www/lanternbox/public/

Private storage:
/srv/www/lanternbox/var/users/

SQLite database:
/srv/www/lanternbox/var/database/app.sqlite

项目目录大致划分为:

/srv/www/lanternbox/
├── app/          # 核心业务逻辑
├── config/       # 配置
├── public/       # 唯一允许公网访问的目录
├── tools/        # 管理与清理工具
├── tests/        # 测试文件
└── var/
    ├── database/
    ├── users/
    ├── tmp/
    └── logs/

这里最重要的设计不是“禁止访问几个敏感目录”,而是:

默认拒绝整个项目,只显式开放 public/

示例 Apache 配置如下:

Alias /dropbox/ "/srv/www/lanternbox/public/"

<Directory "/srv/www/lanternbox">
    Require all denied
</Directory>

<Directory "/srv/www/lanternbox/public">
    Require all granted
    Options -Indexes
    AllowOverride None
</Directory>

<FilesMatch "\.php$">
    SetHandler "proxy:unix:/run/php/example-fpm.sock|fcgi://localhost/"
</FilesMatch>

这种配置的安全意义是:

  • app/ 无法被浏览器读取;
  • config/ 无法被浏览器读取;
  • SQLite 数据库无法被直接下载;
  • 用户上传目录没有静态 URL;
  • 管理脚本和测试文件不会被公开;
  • 即使应用目录中新增了文件,也不会自动暴露到公网。

安全审计中,公开入口可以正常访问,而核心代码、配置、数据库和工具路径均未发现可用的公网绕过方式;普通用户访问管理入口也会被拒绝。


三、认证设计:手机浏览器作为可信设备

3.1 为什么不使用密码

对于少量家庭成员或内部使用者,账号密码会引入额外负担:

  • 密码忘记与重置;
  • 弱密码与重复使用;
  • 密码通过聊天工具传递;
  • 管理员长期保存临时密码;
  • 公共电脑输入密码时可能被记录。

因此,系统采用手机设备登记与二维码批准模式。

3.2 首次登记流程

管理员创建用户后,为该用户生成一次性登记链接,例如:

https://vault.example.net/dropbox/enroll/temporary-token

手机浏览器打开链接后,服务器执行以下操作:

  1. 验证登记令牌是否存在;
  2. 检查令牌是否过期;
  3. 检查令牌是否已经使用;
  4. 生成高强度随机设备令牌;
  5. 在浏览器中写入设备 Cookie;
  6. 数据库只保存设备令牌哈希;
  7. 清除已使用的登记令牌材料。

示例 Cookie:

Name: LANTERNBOX_DEVICE
Secure: true
HttpOnly: true
SameSite: Strict
Path: /dropbox/

登记令牌必须具备一次性和短时效特征。审计验证表明,一次性登记、令牌到期、使用后清除以及安全 Cookie 属性都能够正常工作。

3.3 电脑端扫码登录

电脑打开登录页后,服务器生成一条短期登录请求。

页面显示二维码,二维码中不直接包含永久设备凭据,而是一个短时、一次性的批准入口。

流程如下:

Computer creates login request
        │
        ▼
QR code is displayed
        │
        ▼
Trusted phone opens approval page
        │
        ▼
Server checks phone device identity
        │
        ▼
Phone approves the request
        │
        ▼
Original browser receives a short-lived session

为了防止登录劫持,系统必须保证:

  • 批准结果只能由最初发起请求的浏览器领取;
  • 二维码使用后立即失效;
  • 过期二维码不能批准;
  • 其他浏览器即使获得请求编号,也不能领取会话;
  • 手机只能为自己所属用户批准登录;
  • 普通用户不能通过修改参数变成管理员。

四、同一浏览器只能绑定一个账号的问题

在实际测试中发现,一个浏览器配置文件先登记管理员,再登记普通用户后,管理员身份会被普通用户覆盖。

原因并不是浏览器只能保存一个 Cookie,而是相同的:

  • Cookie 名称;
  • 域名;
  • 路径;

只能对应一个当前值。

例如:

LANTERNBOX_DEVICE
vault.example.net
/dropbox/

同一个浏览器配置文件再次写入这一 Cookie 时,旧值会被替换。

因此,当前设计实际上是:

一个浏览器配置文件对应一个可信用户身份。

最终采用的管理方式是:

Browser Profile A → administrator
Browser Profile B → regular user

这类限制应在登记页面明确提示,避免使用者误以为同一浏览器可以同时保存多个文件柜账号。

更复杂的多账号设计需要额外增加浏览器容器标识、服务器端多设备映射和账号选择界面。对于小型内部工具,分离浏览器配置通常更简单,也更不容易误操作。


五、用户文件不能直接公开下载

用户文件保存在私有目录,例如:

/srv/www/lanternbox/var/users/
└── 7f3d.../
    ├── report.pdf
    └── images/

该目录完全不映射到公网。

文件下载必须经过控制器:

GET /dropbox/download?id=1234

控制器依次检查:

  1. 当前浏览器会话是否有效;
  2. 文件记录是否存在;
  3. 文件是否属于当前用户;
  4. 文件是否已删除;
  5. 文件是否已过期;
  6. 最终磁盘路径是否仍位于用户根目录;
  7. 实际文件是否存在。

随后由 PHP 输出文件内容:

header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="example.bin"');
header('X-Content-Type-Options: nosniff');

跨用户下载测试会被拒绝,路径穿越和绝对路径输入也无法跳出当前用户目录。

上传文件名即使包含类似 HTML 标签的字符串,也只会作为文本显示,不会被浏览器解释成脚本。审计中的存储型 XSS 测试未观察到脚本执行。


六、上传流程与临时文件

上传过程采用临时目录和原子移动:

HTTP upload
   │
   ▼
Private temporary directory
   │
   ├── validate size
   ├── check quota
   ├── normalize filename
   └── verify destination
   │
   ▼
Atomic rename into user directory

示例配置可以写成:

return [
    'upload_limit_bytes' => 640 * 1024 * 1024,
    'default_retention_days' => 10,
    'trash_retention_hours' => 36,
];

这些数值只是示例,不应直接复制到真实环境。

安全设计应满足:

  • 上传文件不整体读入 PHP 内存;
  • 临时文件位于私有目录;
  • 上传失败后能够清理;
  • 用户配额在移动文件前检查;
  • 文件扩展名不决定服务器执行方式;
  • 用户上传的 .php.html 或脚本文件不能通过静态 URL 执行;
  • 最终落盘路径始终位于用户 UUID 根目录内。

审计确认,上传使用私有临时目录,之后通过原子移动进入用户目录,下载响应也包含附件和类型嗅探保护。


七、文件生命周期设计

该系统定位为临时文件交换工具,而不是永久云盘。

因此,所有数据都应具有明确生命周期。

活跃文件

上传成功后保存到期时间:

created_at
expires_at

页面持续展示:

Expires in 8 days

或:

Expires at 2030-04-18 21:30

回收站

删除文件后,不立即从磁盘移除,而是进入短期回收站:

status = trash
trash_expires_at = ...

临时认证数据

下列数据也必须自动清理:

  • 过期二维码登录请求;
  • 已拒绝登录请求;
  • 过期电脑会话;
  • 已撤销会话;
  • 已使用或过期的登记令牌;
  • 长时间残留的上传临时文件;
  • 已撤销且不再被有效会话依赖的设备。

八、故障一:网页清理按钮无法打开锁文件

系统提供了手动清理按钮:

Run cleanup

最初点击后返回:

Cannot open cleanup lock.

调查发现,清理逻辑并没有开始执行。问题发生在锁文件打开阶段。

示例锁文件路径:

/srv/www/lanternbox/var/tmp/cleanup.lock

此前有一次维护命令由高权限用户执行,导致锁文件变成:

root:root
0644

网页请求由 PHP-FPM 受限用户执行,因此无法用可写方式打开该文件。

修复方式不是扩大整个项目权限,而是只调整必要路径:

var/tmp/
    owner: webapp:webapp
    mode: 0750

cleanup.lock
    owner: webapp:webapp
    mode: 0640

不应使用:

chmod -R 777 /srv/www/lanternbox

这会让 Web 进程获得远超实际需要的权限。

修复后,应分别验证:

sudo -u webapp php tools/cleanup.php

以及管理页面中的手动清理按钮。

这个问题体现了一个常见陷阱:

高权限终端测试可能留下 Web 运行用户无法继续访问的文件。


九、故障二:清理成功,但页面仍不断增长

锁文件问题解决后,清理命令可以正常返回统计,但管理员页面中的过期会话仍然存在。

根本原因是两个逻辑同时存在问题:

  1. 清理函数只把记录标记为过期;
  2. 管理页面查询了全部历史记录。

短期看只有几条记录,长期运行后则会出现:

thousands of expired sessions
hundreds of revoked devices
large admin page
slow page rendering
poor operational visibility

修复后的会话规则

管理页面只显示:

pending AND not expired
approved AND not expired

清理程序物理删除:

expired pending sessions
expired approved sessions
denied sessions
revoked sessions
rows already marked expired

修复后的设备规则

管理页面只显示:

active and enrolled devices

被撤销的设备立即从主列表隐藏。

为了避免破坏关联关系,撤销设备不会马上物理删除,而是在满足以下条件后清除:

revoked for more than a grace period
AND
no active session depends on the device

登记令牌清理

登记成功后立即清空:

enrollment_token_hash
enrollment_expires_at

清理任务还会擦除旧版本遗留的已使用令牌材料。

经过调整后,管理员页面只反映当前有效状态,不再承担永久历史日志功能。审计也确认,过期请求和会话会被物理删除,同时有效文件和有效会话不会被误删。


十、安全审计范围

功能稳定后,对系统进行了完整的只读安全审计。

审计覆盖:

Application code
SQL usage
HTML output encoding
CSRF protection
XSS handling
Apache access control
Cookie security
Enrollment flow
QR login flow
Session lifecycle
Admin authorization
Multi-user isolation
Path traversal
Upload and download
Cleanup behavior
SQLite configuration
Public HTTP exposure

测试过程中创建了独立的临时管理员、普通用户、设备、会话和文件。

测试结束后,这些数据全部删除。真实用户、真实设备和真实文件没有被修改。审计期间也没有修改应用代码、Web 服务器、反向代理和系统服务。


十一、审计通过的安全边界

SQL 与代码

静态代码审查确认:

  • SQL 查询采用参数绑定;
  • 页面输出经过 HTML 转义;
  • 未发现动态命令执行;
  • 未发现不安全反序列化;
  • 未发现动态包含用户输入文件;
  • 二维码库采用本地静态资源并带有许可证说明。

用户隔离

两个临时用户之间进行了交叉测试:

User A cannot list User B files
User A cannot download User B files
User B cannot access User A device records
Regular users cannot open admin pages

修改文件编号、目录参数或下载 URL 也不能实现跨用户访问。

CSRF 与 XSS

写操作需要有效 CSRF Token。

缺少 Token 的普通用户操作和管理员操作均被拒绝。

恶意特征文件名会被 HTML 转义,不会直接进入页面脚本上下文。

SQLite

数据库连接启用了类似以下设置:

PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 4000;

清理任务与正常请求并行测试时,没有观察到数据库损坏。

不过文件系统操作与数据库写入无法真正组成同一个跨系统事务,因此仍存在很小的理论竞态窗口。


十二、审计发现的三个问题

12.1 数据库中保存明文 bearer token

最严重的问题是:

devices.device_token
computer_sessions.session_token

保存的是原始令牌,而不是哈希。

Bearer token 的特点是:

谁拿到令牌,谁就可以直接使用该身份。

因此,即使数据库无法通过公网下载,只要数据库文件通过备份、磁盘复制、错误权限或其他漏洞泄露,攻击者就可以接管设备或浏览器会话。

这与密码存储原则相同:

Browser keeps the original secret
Database stores only a verifier

12.2 空闲超时没有真正执行

配置中存在:

'idle_timeout_seconds' => 360,

但会话验证只检查绝对过期时间,没有检查最后活动时间。

于是会出现:

Session absolute TTL: enforced
Idle TTL: configured but ignored

这会造成实现与设计文档不一致。

12.3 撤销设备不会终止已批准会话

设备被撤销后,手机 Cookie 会立即失效。

但此前由该设备批准的电脑会话仍然有效,直到自己的绝对期限结束。

这不是认证绕过,但不符合管理员对“撤销设备”的合理预期。

审计最终没有发现管理员权限绕过、跨用户文件读取或私有目录公开等更严重问题。


十三、修复一:令牌只保存哈希

13.1 新令牌存储模型

浏览器继续保存原始随机令牌:

LANTERNBOX_DEVICE=<random-secret>
LANTERNBOX_SESSION=<random-secret>

数据库只保存:

SHA-256(random-secret)

验证过程:

$rawToken = $_COOKIE['LANTERNBOX_SESSION'] ?? '';
$tokenHash = hash('sha256', $rawToken);

$stmt = $db->prepare(
    'SELECT * FROM computer_sessions
     WHERE session_token = :token_hash'
);

$stmt->execute([
    ':token_hash' => $tokenHash,
]);

SHA-256 在这里不是用于保护低强度密码,而是用于高熵随机 token 的查找,因此适合直接作为 token verifier。

13.2 旧数据原地迁移

为了保留现有手机 Cookie,可以对旧明文值执行一次原地迁移。

示例数据库字段:

ALTER TABLE devices
ADD COLUMN device_token_hashed_at TEXT;

ALTER TABLE computer_sessions
ADD COLUMN session_token_hashed_at TEXT;

迁移逻辑必须具备幂等性:

hashed_at is NULL
    → treat current value as plaintext
    → hash it
    → write hashed_at

hashed_at is not NULL
    → already migrated
    → do not hash again

如果迁移脚本重复执行并再次哈希,浏览器中的原始 Cookie 将永远无法匹配数据库。

13.3 首次领取会话令牌

电脑会话批准后,原始 session token 只允许最初发起登录的浏览器领取一次。

数据库从创建时开始就只保存哈希。

这避免了原始 session token 在数据库中短暂落地。

修复后检查应确认:

raw cookie value does not appear in SQLite
hash(raw cookie value) matches exactly one row
legacy plaintext token count = 0

十四、修复二:真正执行空闲超时

会话有效性应同时受到两类限制:

absolute expiration
idle expiration

示例逻辑:

$now = time();

if ($now >= $sessionExpiresAt) {
    invalidateSession();
}

if ($now - $lastActivityAt >= $idleTimeoutSeconds) {
    invalidateSession();
}

每次经过认证的有效业务请求都会更新:

last_activity_at

但登录前的二维码轮询不能被视为已登录用户活动,否则可能意外延长尚未建立或已经失效的会话。

测试时无需等待真实时长,可以使用临时测试会话:

move last_activity_at into the past
request protected page
expect redirect to login

随后再测试绝对到期:

move expires_at into the past
request protected page
expect redirect to login

两类超时必须分别验证。


十五、修复三:撤销设备时联动撤销会话

电脑会话需要记录由哪台设备批准,例如:

approved_by_device_id

撤销设备时执行:

UPDATE devices
SET revoked_at = CURRENT_TIMESTAMP
WHERE id = :device_id;

UPDATE computer_sessions
SET status = 'revoked'
WHERE approved_by_device_id = :device_id
  AND status = 'approved';

必须注意:

  • 只撤销目标设备批准的会话;
  • 同一用户其他设备批准的会话不应受影响;
  • 禁用整个用户时,才终止该用户的全部设备和会话。

回归测试可以使用一个临时用户和两台临时设备:

Device A approves Session A
Device B approves Session B

Revoke Device A

Expected:
Session A invalid
Session B still valid

十六、修复后的回归测试

安全修复不能只检查语法,还必须验证真实行为。

完整回归至少包括:

PHP syntax checks
database integrity check
existing device cookie compatibility
new enrollment flow
new QR login flow
token hash verification
idle expiration
absolute expiration
device revocation cascade
user disable cascade
admin access control
multi-user file isolation
cleanup preservation
temporary data removal

示例语法检查:

php -l /srv/www/lanternbox/app/bootstrap.php
php -l /srv/www/lanternbox/app/security.php
php -l /srv/www/lanternbox/public/api/poll.php
php -l /srv/www/lanternbox/public/admin/index.php

数据库完整性:

sudo -u webapp php -r '
$db = new PDO("sqlite:/srv/www/lanternbox/var/database/app.sqlite");
echo $db->query("PRAGMA integrity_check")->fetchColumn(), PHP_EOL;
'

预期结果:

ok

十七、公开目录中的备份文件风险

安全修复完成后,还发现了一个非常容易忽略的问题。

某些修改文件的备份被放在公开目录中,例如:

public/api/poll.php.20300101123000
public/admin/index.php.old
public/config.php.bak

正式文件以 .php 结尾,会交给 PHP-FPM 执行。

但备份文件的最后扩展名可能变成:

.20300101123000
.old
.bak

Web 服务器可能把它们当成静态文本直接返回。

结果就是:

official PHP source protected
backup PHP source publicly downloadable

这类问题甚至不需要攻击者绕过目录限制,只要猜到备份文件名即可。

因此,生产项目应遵循:

Web 项目目录内不保留源码备份。

备份应放到 Web 根目录之外,例如:

/root/secure-backups/lanternbox/

或者在功能确认正常后直接删除。

删除前应验证:

PHP syntax: pass
SQLite integrity: ok
public site: reachable
authentication: working
file access: working

随后搜索项目中的常见残留:

find /srv/www/lanternbox -type f \
\( -name '*.bak' \
-o -name '*.old' \
-o -name '*.orig' \
-o -name '*~' \
-o -name '*.save' \) \
-print

还应从公网确认原备份 URL 已返回不存在,而不是仅检查本地文件是否删除。


十八、最终安全模型

经过审计和修复后,示例系统形成了较完整的安全边界。

Web 暴露边界

Only public/ is accessible
Private code is denied
Database is denied
User storage is denied
Directory indexes are disabled
Uploaded files are never executed directly

身份认证边界

One-time enrollment
Trusted phone device
Short-lived QR request
Short-lived browser session
Absolute timeout
Idle timeout
One-time session delivery

令牌边界

Browser stores raw token
Database stores token hash
Used enrollment token is erased
Revoked device cannot authenticate
Revoked device sessions are terminated

授权边界

Admin privilege comes from database
Normal users cannot access admin routes
Every file operation checks ownership
Final file path remains inside user root
Cross-user download is denied

数据生命周期

Files expire
Trash expires
Requests expire
Sessions expire
Temporary files expire
Revoked devices are eventually purged
Admin lists show only active state

运行权限

Application code: root-owned, read-only to PHP
Private storage: writable by PHP
Database: writable only by application user
Public directory: no backups
No world-writable project directories

十九、实践中最值得保留的经验

这个项目中最重要的并不是某一个框架或某一段 PHP 代码,而是以下几个工程经验。

“已失效”不等于“已删除”

会话被标记为过期后,如果管理页面仍查询全部记录,数据依然会无限增长。

失效状态、物理清理和页面查询必须同时设计。

配置存在不等于功能生效

配置文件中写有空闲超时,并不能证明代码实际执行了它。

所有安全参数都必须通过行为测试验证。

撤销设备不一定撤销会话

设备认证和已经签发的浏览器会话是两个不同对象。

撤销逻辑必须显式定义两者之间的联动关系。

数据库不公开,不代表明文令牌可以接受

数据库泄露的来源不仅是公网下载,还可能是:

  • 错误备份;
  • 主机权限问题;
  • 磁盘复制;
  • 维护脚本;
  • 文件同步;
  • 其他应用漏洞。

高熵 bearer token 仍应只保存哈希。

正式文件安全,不代表备份文件安全

index.php 可能由 PHP 执行,但 index.php.old 很可能被当作文本下载。

公开目录中不应出现任何源码备份。

高权限测试可能制造权限故障

由高权限账号创建的锁文件、缓存或临时文件,可能导致 Web 用户无法继续运行。

生产路径中的文件所有者应始终符合实际运行模型。

管理页面应展示当前状态,而不是无限历史

设备列表和会话列表的作用是管理当前有效对象,不是替代审计日志。

长期历史应进入独立审计表或日志系统。


结语

轻量工具并不意味着安全要求可以降低。

一个只有少量用户的文件柜,同样涉及:

  • 身份登记;
  • 设备信任;
  • 会话签发;
  • 用户隔离;
  • 私有文件访问;
  • 令牌保护;
  • 数据清理;
  • 权限模型;
  • 备份管理;
  • 安全回归测试。

真正适合长期运行的系统,不只是“能够上传和下载”,而是同时满足:

Private data cannot be directly exposed
Expired credentials really stop working
Revoked devices lose their authority
Tokens remain unusable after database leakage
Historical records do not grow without limit
Backups do not create a second public attack surface

对于自建小型服务,最可靠的原则仍然是:

Default deny
Least privilege
Short-lived authorization
Hashed bearer tokens
Strict user isolation
Explicit data lifecycle
No backups in public directories
Audit first, repair second, verify last

只有当认证、授权、存储、清理和部署边界共同成立时,一个看似简单的文件传输工具才真正具备长期运行的基础。

Leave a Reply

Your email address will not be published. Required fields are marked *