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

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

大多数安全事件的起点不是天才攻击者,而是一个发现了问题、却不知道该如何报告的人。如果上报缺陷比发条推文还麻烦,客观上你就已经替他们选了公开披露。

与此同时,AI 让自动化安全测试达到了前所未有的规模。每一天,都有漏洞在某个角落被发现、被上报。

security.txt 解决的正是这个问题里最乏味的那部分:直接告诉发现者报告该发到哪。本文会介绍这个标准是什么、为什么降低报告门槛很重要、以及如何在 CakePHP 应用中一行代码接入一个永不过期的中间件。

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

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

  • 有没有 /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 对外提供:

arduino 复制代码
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 要求有它,漏了就直接抛异常。配错在启动时就暴露,不用等到运行时悄悄坏掉。

在应用中间件栈里,一行代码接入:

php 复制代码
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:

ruby 复制代码
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 也要调整。下面是一个尽量去框架化的版本:

swift 复制代码
<?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);
    }
}

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

相关推荐
两个人的幸福2 天前
Windows 桌面应用自研 PHP 队列(下):完整代码与六大工程化优化
php
BingoGo4 天前
PHP 泛型之殇 泛型 RFC 提案被拒绝
后端·php
JaguarJack4 天前
PHP 泛型之殇 泛型 RFC 提案被拒绝
后端·php
用户3074596982075 天前
PHP 扩展——从入门到理解
php
鹏仔先生5 天前
拷贝漫画APP下载页PHP程序,后台带免费AI写作
php
云水一下5 天前
从零开始学 PHP 系列(一):PHP 的前世今生与开发环境搭建
开发语言·php
xingpanvip5 天前
星盘接口开发文档:本命盘接口指南
android·开发语言·css·php·lua
酉鬼女又兒6 天前
零基础入门计算机网络运输层:端到端通信核心作用、端口号分类规则、复用分用工作机制及UDP与TCP协议全方位对比详解
网络·网络协议·tcp/ip·计算机网络·考研·udp·php
dog2506 天前
不要再继续优化 TCP
网络协议·tcp/ip·php
Channing Lewis6 天前
PHP 解析 Excel 的那些坑:一次“行号错位”引发的数据丢失
开发语言·php·excel