当前位置: 首页 > news >正文

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

问题:一扇没有门铃的锁着的门

站在安全研究人员的角度想想:你在某个站点上发现了一个暴露的端点,想做正确的事——私下报告。于是你开始找:

  • 有没有/security页面?通常没有。
  • 有没有security@邮箱?也许有,也许真有人在看。
  • 有没有联系表单?消息限 500 字符,附件也吞掉。
  • 去社交媒体上找那家公司?现在你是在公开场合讨论漏洞。

每撞上一次死胡同,要么报告者放弃,要么漏洞细节最终被公开。两种结果你都不想要。

security.txt 是什么(RFC 9116)

security.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能阅读的语言,比如ende
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两个路径返回如下内容,每次都带上最新的 Expires:

Contact: 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.org
  • CakePHP 用户:security.txt 中间件文档

让门好找,装上铃,铃响就接。

框架无关的中间件代码

这个 PSR-15 中间件很容易移植到任何兼容 PSR-15 的框架。需要的话复制粘贴进自己的生态就行(中间件 + DTO)。Response 类换成你实际用的实现,InstanceConfigTrait 也要调整。下面是一个尽量去框架化的版本:

<?php declare(strict_types=1); /** * 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 array<string, string|array<string>> */ 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 CakePHP's 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; charset=utf-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); } }
http://www.jsqmd.com/news/1091754/

相关文章:

  • python爬虫实战项目|第100篇:爬虫技术全景回顾与未来展望
  • 让经典游戏重获新生:dxwrapper全面解决Windows 10/11兼容性问题
  • 强制访问控制的数学基石:深度拆解BLP机密性模型的设计哲学与工程遗产
  • 终极指南:三步解锁Wand专业版完整功能,告别付费订阅
  • vi 删除指定范围的行,不用再反复按 dd
  • 编写高质量 Skill 系列 -- 如何设计需求分析与用例生成的 SKILL
  • 【2026最新】在 Win11 WSL2 (Ubuntu 24.04) 上搭建 Synopsys VCS/Verdi 2023数字 IC 设计 EDA 工具链
  • 如何在10分钟内解决离线音乐库的歌词同步难题?LRCGET批量歌词下载终极指南
  • Seedance 2.0 做短视频分镜,我最在意的不是“出片”,而是能不能交给团队复用
  • 洛谷 P1854 花店橱窗布置:从 OJ 题解到动态规划实战心法
  • 别再熬夜写论文了!6款AI写作辅助平台,一键生成逻辑连贯初稿!
  • 英雄联盟皮肤资源库技术深度解析:从文件结构到游戏资产管理的终极指南
  • 程序员别再乱堆书签!这个编程合集,让你写代码全程不被打断
  • UART串口环回测试中的校验位实战:从原理到FPGA实现
  • FMEA×控制计划×PPAP自动联动,这才是研发管理的天花板-全星研发项目管理APQP软件系统#APQP #PLM #汽车电子 #芯片研发 #新能源 #项目管理软件
  • 终极指南:如何用PIDtoolbox彻底解决无人机飞行稳定性问题
  • TLS 1.3如何防御中间人攻击:从加密原理到Nginx实战部署
  • Java国密算法实战:SM2/SM3/SM4完整代码示例与Bouncy Castle集成
  • 国内外网站建设选择-主流网站建设工具客观对比(2026年6月最新)含零代码、AI编程、源码定制交付
  • 一次函数图像工厂:用 SymPy 自动生成 y=kx+b 对比动画
  • 慕课助手:打破在线学习效率瓶颈的浏览器插件解决方案
  • CPUDoc深度指南:解锁CPU隐藏性能的5个关键技巧
  • 2026 年深圳远程IO模块选型指南:明达智控MR30系列
  • 循环码编码解码matlab仿真(P124302009 罗睿章, P124302167张国峰)
  • Mi-Create终极指南:零基础打造个性化小米手表表盘
  • 如何将手机摄像头变成OBS专业直播源:DroidCam OBS插件完整指南
  • Nacos Derby反序列化漏洞深度剖析与安全加固实战
  • 2026权威实测|企业级AI编程应用场景全梳理:SaaS后台工具链选型指南
  • 将Android手机打造成随身Linux工作站:Termux + VNC实战指南
  • 手机号逆向查询QQ号:从遗忘到找回的3分钟解决方案