PHP 应用 security.txt 漏洞披露实践

📅 2026/6/29 21:01:21
PHP 应用 security.txt 漏洞披露实践
问题一扇没有门铃的锁着的门站在安全研究人员的角度想想你在某个站点上发现了一个暴露的端点想做正确的事——私下报告。于是你开始找有没有/security页面通常没有。有没有security邮箱也许有也许真有人在看。有没有联系表单消息限 500 字符附件也吞掉。去社交媒体上找那家公司现在你是在公开场合讨论漏洞。每撞上一次死胡同要么报告者放弃要么漏洞细节最终被公开。两种结果你都不想要。security.txt 是什么RFC 9116security.txt 是 RFC 9116 A File Format to Aid in Security Vulnerability Disclosure信息性标准2022 年Edwin Foudil 与 Yakov Shafranovich 编写定义的一种纯文本小文件在已知路径以text/plain格式通过 HTTPS 对外提供https://example.com/.well-known/security.txt文件内部只有字段: 值这样的行。两个字段是必填的其余选填字段是否必需用途Contact是联系方式https:URL、mailto:或tel:。可按优先级重复出现。Expires是文件数据不再受信任的时间点ISO 8601 格式。Encryption否公钥获取地址方便对报告进行加密。Policy否披露策略链接。Acknowledgments否过往报告者的荣誉榜。Preferred-Languages否能阅读的语言比如en、de。Canonical否本文件的规范 URL。Hiring否安全相关岗位招聘。实践中有两个点值得注意不同字段之间顺序不重要。只有一处例外多条 Contact 行从上到下按优先级读取。Expires 是真正的坑。它必须是一个未来日期。两年前写好就丢在那儿的 security.txt按规范已经过期——一份过期文件给人的信号是这个团队对安全不上心。为什么要有一个标准安全研究人员不用再去猜你的组织架构。一条确定的路径机器能读人和扫描器都知道去那里查。为什么重要降低门槛把人引到对的渠道添加 security.txt 成本极低收益至少有两条。第一降低摩擦。发现者越快抵达正确的收件箱就越有可能完成报告——漏洞在他们翻找联系方式期间泄露的概率也就越低。你在堵上公开披露的借口。第二把人引到正确的渠道。这是很多团队不当回事的地方。正确渠道意味着私密、有人监控、预期明确一个私密的入口专用邮箱或 GitHub 私密漏洞报告通道不要用公开 Issue 区。一份写清楚的 Policy让报告者知道该期待什么会不会给安全港披露时间线会不会致谢另一端有个真人职责就是接收这些报告。让这条路显而易见你就能把一个 Twitter 零日危机变成一次安静的协同修复。核心就一句话让审计和报告变简单让简单的路成为安全的路。CakePHP 中间件一个永不过期的实现在 CakePHP 应用中同样想集成 security.txt但放一个静态文件有 Expires 过期问题——得有人一直记得去更新那个日期。于是把它做成了 cakephp-setup 插件3.21.0里一个轻量的 PSR-15 中间件。在线示例sandbox.dereuromark.de/.well-known/security.txt下面几个设计选择让它用起来很顺手做成中间件而不是路由。文件公开且格式固定在路由和认证执行之前就能直接返回不需要控制器、不需要路由配置、不需要认证例外。Expires 每次请求时重新计算。默认值是一年之后所以它永远有效。维护的问题就这么没了。配置用类型化的值对象。通过 SecurityTxt 对象来描述文档——具名参数、IDE 自动补全、类型检查——而不是丢一个松散的魔术字符串数组。Contact 强制必填。RFC 9116 要求有它漏了就直接抛异常。配错在启动时就暴露不用等到运行时悄悄坏掉。在应用中间件栈里一行代码接入use Setup\Middleware\SecurityTxt; use Setup\Middleware\SecurityTxtMiddleware; public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue { $middlewareQueue-add(new SecurityTxtMiddleware(new SecurityTxt( contact: https://github.com/owner/repo/security/advisories/new, canonical: https://example.com/.well-known/security.txt, policy: https://github.com/owner/repo/security/policy, preferredLanguages: en, de, ))); // ... the rest of your stack return $middlewareQueue; }它同时在/.well-known/security.txt和老的/security.txt两个路径返回如下内容每次都带上最新的 ExpiresContact: https://github.com/owner/repo/security/advisories/new Policy: https://github.com/owner/repo/security/policy Canonical: https://example.com/.well-known/security.txt Preferred-Languages: en, de Expires: 2027-05-23T00:00:00.000Z其他细节HEAD 请求只返回头部不带响应体路径匹配感知基础路径子目录挂载的应用也能用另外给值对象还没覆盖的字段留了原始数组兜底。配合 SECURITY.md 使用如果项目有公开的 Git 仓库SECURITY.md 可以锦上添花。security.txt 说的是报告发到哪SECURITY.md 说的是怎么报、该期待什么。在 GitHub 上SECURITY.md放在仓库根目录、.github/或docs/会在/security/policy渲染并出现在仓库的 Security 选项卡里——正好是前面 Policy 字段指的那个地址。两者互为补充Contact 指向 GitHub 的私密漏洞报告通道不是公开 Issue。Policy 指向 SECURITY.md里面写着流程和时间线。这样扫描器和人类都会抵达同一个私密的、该去的地方。今天就开始用给公开应用加 security.txt投入产出比极高。寥寥几行就能告诉外界你愿意听问题。读标准RFC 9116生成或校验文件securitytxt.orgCakePHP 用户security.txt 中间件文档让门好找装上铃铃响就接。框架无关的中间件代码这个 PSR-15 中间件很容易移植到任何兼容 PSR-15 的框架。需要的话复制粘贴进自己的生态就行中间件 DTO。Response 类换成你实际用的实现InstanceConfigTrait 也要调整。下面是一个尽量去框架化的版本?php declare(strict_types1); /** * Serves an RFC 9116 security.txt. Pure PSR-15 PSR-17: it depends only on the * injected factories, so it runs in any compliant stack. * * The required Expires field is computed on every request, so it never goes * stale. Contact is required; constructing without one throws. */ class SecurityTxtMiddleware implements MiddlewareInterface { /** var arraystring, string|arraystring */ private array $fields; private string $expiresInterval; public function __construct( SecurityTxt $document, private readonly ResponseFactoryInterface $responseFactory, private readonly StreamFactoryInterface $streamFactory, private readonly string $path /.well-known/security.txt, private readonly bool $serveRootFallback true, private readonly int $cacheMaxAge 86400, // 1 day; was CakePHPs DAY constant ) { $this-fields $document-toFields(); $this-expiresInterval $document-expiresInterval; if (SecurityTxt::normalize($this-fields[Contact] ?? null) []) { throw new InvalidArgumentException( SecurityTxtMiddleware requires at least one non-empty Contact field (RFC 9116)., ); } } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $method $request-getMethod(); if ($method ! GET $method ! HEAD) { return $handler-handle($request); } if (!$this-matches($this-relativePath($request))) { return $handler-handle($request); } $rendered $this-render(); $response $this-responseFactory-createResponse() -withHeader(Content-Type, text/plain; charsetutf-8) -withHeader(Content-Length, (string)strlen($rendered)); // correct even for HEAD if ($this-cacheMaxAge 0) { $response $response-withHeader(Cache-Control, max-age . $this-cacheMaxAge); } // HEAD: same headers as GET, but no body. $body $method HEAD ? : $rendered; return $response-withBody($this-streamFactory-createStream($body)); } private function matches(string $path): bool { return $path $this-path || ($this-serveRootFallback $path /security.txt); } /** * Resolve the request path relative to the application base path. The base * attribute is set by some frameworks (e.g. CakePHP) for subdirectory installs; * elsewhere getAttribute() returns and this is a pure-PSR-7 no-op. */ private function relativePath(ServerRequestInterface $request): string { $path $request-getUri()-getPath(); $base (string)$request-getAttribute(base, ); if ($base ! str_starts_with($path, $base)) { $path substr($path, strlen($base)); } return $path ! ? $path : /; } private function render(): string { $lines []; foreach (SecurityTxt::normalize($this-fields[Contact] ?? null) as $contact) { $lines[] Contact: . $contact; } foreach ($this-fields as $name $value) { if ($name Contact || $name Expires) { continue; } foreach (SecurityTxt::normalize($value) as $item) { $lines[] $name . : . $item; } } $lines[] Expires: . $this-expires(); return implode(\n, $lines) . \n; } private function expires(): string { $timestamp strtotime($this-expiresInterval) ?: strtotime(1 year); return gmdate(Y-m-d\TH:i:s.000\Z, (int)$timestamp); } }