SSI服务端包含实战:指令、类型匹配与缓存陷阱

SSI(Server Side Includes)是 Nginx 自带的一个模块,很多人第一次碰到它不是在教程里,而是在一次误配置之后:页面上原本该显示统计数据的地方,出现了一行裸露的文本 <!--# include virtual="/stat.html" -->,然后才发现原来这个功能一直开着。另一个方向的故事则相反,有人为了让几个纯静态页面共用页头和页脚,跑了整套 PHP,每个请求都要过一遍 FPM,而其实 SSI 一行配置就够了。

这篇写的是个人站长场景下的 SSI:什么时候值得用、怎么配才不出安全纰漏、以及开了之后最容易踩的几个坑。

一、SSI 到底解决什么问题

假设站点是纯静态的:一系列 HTML 文件,外加一个每天由脚本生成的列表页。页头、侧栏、页脚在 200 个文件里各复制了一份。要改导航里加一个链接,就得跑 sed 全量替换一遍——能跑通,但很容易把正文里恰好相同的字符串也改掉。

SSI 的做法是把这些公共片段拆成独立文件,在主页面里用指令引入:

<!--# include file="part/header.html" -->
<main>这里是正文</main>
<!--# include file="part/footer.html" -->

这样改导航只要改一个文件。它比 PHP 轻的地方在于:合成发生在 Nginx 内部,由一个 C 模块完成,没有额外进程、不用加载解释器。对于「静态为主、只有那么一点点动态需求」的站点,这个取舍往往很划算——用一套 PHP 进程池去拼页头,代价是常驻内存加请求延迟,而用 SSI,代价只是 Nginx 在一次响应里多读两个文件。

它的边界也很清楚:SSI 没有数据库、没有会话、没有逻辑判断(虽然有 if 但只支持极有限的比较),本质上是文本拼接。凡是需要「根据访客身份显示不同内容」的场景,都不该往这个方向上勉强靠。

二、开启 SSI 的最小配置

Debian/Ubuntu 上的 nginx 包默认编译了 http_ssi_module,不用额外装。关键是要按 location 精细开启,而不是全局打开:

server {
    listen 80;
    server_name example.com;
    root /var/www/example;

    # 默认全部不处理
    ssi off;

    # 只有 shtml 后缀和明确指定的目录才处理
    location ~ \.shtml$ {
        ssi on;
        ssi_silent_errors on;
        ssi_types text/html;
    }

    location /stat/ {
        ssi on;
        ssi_silent_errors on;
        ssi_types text/html;
    }
}

几个参数需要解释:

ssi on 只对 text/html 类型生效,这是默认行为。所以如果有人把片段文件命名成 .txt,include 进来后里面的 SSI 指令不会被二次解析——这个特性有时是坑,有时反而是救命的设计(下面会讲)。

ssi_silent_errors on 建议打开。默认情况下 include 失败会往页面里写一行 [an error occurred while processing the directive],访客能直接看到,很不专业。打开后失败就静默输出空,服务端日志里仍然有记录。

如果你确实想对整个站点开启(比如纯静态站全是 shtml 已经很久了),也务必确认没有用户可控的内容进入 text/html 响应,理由见下一节。

三、SSI 的两条安全红线

开启 SSI 最大的风险不是「读文件」,而是「执行命令」。Nginx 的 SSI 支持 exec 指令:

<!--# exec cmd="ls" -->

这一行如果出现在某个被 SSI 解析的页面里,就会真的去执行 shell 命令。所以第一条红线是:不要开启 ssi 的同时允许访客控制页面内容。评论、留言、用户昵称、上传的 HTML 文件,只要有一个能落到被 SSI 解析的位置,就等于把命令执行交出去了一部分。

更麻烦的是,Nginx 的 SSI 解析不区分「这行指令是站长写的」还是「这行指令是访客提交的」,它只按文本匹配。因此配置上一定要做到「解析范围」和「写入范围」不重叠:解析只放在自己的静态片段目录和 shtml 页面,用户上传目录(/uploads/)显式 ssi off,并且限制上传目录只能出静态文件。

第二类风险是路径穿越。file 和 virtual 的区别很关键:

include file="..." 是相对当前请求文件所在目录解析,而且不允许出现 ..;include virtual="..." 走的是 Nginx 的内部子请求,会重新匹配 location,所以能引用到其他目录的资源。

看起来 virtual 更强、更方便,但它会触发完整的 location 匹配链——包括 rewrite、proxy_pass 和 fastcgi_pass。这就引出两个真实的坑:一是如果 virtual 指向的路径被某个 location 代理到内网,SSI 就成了一个「内网请求跳板」;二是如果指向的处理程序返回 500,SSI 会把错误静默吞掉,页面看起来只是少了一块,很难联想到原因。

实践中的建议是:跨目录引用优先用 virtual,但指向的路径必须在配置里明确存在且只匹配静态文件;同目录引用用 file,不要写 ../ 去越界——Nginx 会直接返回错误,而很多人会误以为是语法问题。

四、四个最常见的配置坑

坑 1:include 的文件里还有 SSI,但没有被解析

SSI 默认只处理一层。主页面 include 了 header.html,如果 header.html 里又 include 了 nav.html,那 nav.html 的指令不会被执行,页面上会直接出现一行 <!--# include ... -->。要支持多层就得加:

ssi on;
ssi_types text/html;
# Nginx 没有内置「无限嵌套」开关,需要在被 include 的文件里
# 也确保内容类型是 text/html 且同属一个 location

这块的描述容易让人困惑,实测更清楚的做法是:include 进来的文件同样要经 Nginx 处理,并且它的 Content-Type 必须是 text/html。如果片段文件名是 .inc,mime.types 里没有对应项,就是 application/octet-stream,SSI 不会继续解析。所以要么片段用 .html 后缀,要么显式加 types { } 块。这也是为什么大量教程建议片段统一用 .html。

坑 2:缓存把 SSI 的结果和源文件的更新割裂开

SSI 的组合发生在 Nginx 每次响应时(动态拼装),所以如果前面挂了 CDN 或者开了 proxy_cache,缓存存的是**拼好的成品**。改一次 footer.html,源站立即生效,但 CDN 上的旧页面还在,于是访客看到的页脚是旧的,刷新几次又变成新的。排查时会以为是 SSI 没生效。

解决办法不是关缓存,而是给「拼接结果」一个正确的 ETag 或 Cache-Control。这里有个很实用的技巧:SSI 响应会带 Last-Modified,取的是主文件的时间,而不是所有被包含文件里最新的那个。也就是说 footer 变了但主页没变,If-Modified-Since 会拿到 304,访客永远看不到新页脚。

因此要么在改公共片段时统一 touch 所有引用它的页面(脚本可以做到,用 grep 反查引用关系),要么干脆对 SSI 页面不给强缓存:

location ~ \.shtml$ {
    ssi on;
    add_header Cache-Control "no-cache" always;
}

没有强缓存,每次仍然会带 ETag 或 Last-Modified 发条件请求,代价是一个 304 往返,但对静态站来说可以接受。

坑 3:ssi_types 写错导致「配置没问题但就是不生效」

很多人以为 ssi on 就万事大吉,结果用 curl 看到原始指令原样输出。原因通常是两件事之一:响应的 Content-Type 不是 text/html(比如默认类型被设成了 text/plain),或者 ssi_types 被写成只含 text/plain 而实际响应是 text/html; charset=utf-8。注意 SSI 的类型匹配是按基础类型比较的,带参数不影响,但如果 location 里手写了 default_type text/plain,就会全部失效。

验证方式最简单的一条命令:

curl -sI https://example.com/page.shtml

看 Content-Type 是不是 text/html,如果不是,先修这个再说别的。

坑 4:echo 指令里的变量与转义

SSI 支持 <!--# echo var="DATE_LOCAL" --> 输出日期、文件名、远程地址一类的变量,也支持 config errmsg="..." timefmt="..." 自定义。这类用法在动态时间戳场景挺方便,但要注意两点:一是默认输出的时间格式是英文长格式,需要 timefmt 指定;二是 echo 输出的内容不转义,如果参考的变量是访客可控的(比如 QUERY_STRING),就等于打开了 XSS 口子。结论很简单:不要 echo 任何来自请求的变量。

五、和「用 PHP 拼页头」的取舍

值得对比一下:一个只有 10 个页面、每页都有一个导航的静态站,用 PHP include 的做法是每个请求都进 FPM,PHP-FPM 的常驻内存至少几十 MB,而且每个请求都有一次进程调度开销。用 SSI 的做法是 Nginx 读两个小文件,内存增量接近零。

但如果站点有评论、有登录、有搜索,那本来就有 PHP,页头用 PHP include 更自然,没必要为了统一再引入 SSI——多一种拼装机制,就多一处需要理解的缓存与调试行为。

还有一个不太常见但实际存在的用法:把 SSI 当成「静态页面的最后一块拼图」。例如全站静态化后,唯一的动态元素是页脚的「今天更新了几篇」。让 cron 每十分钟生成一个 stat.html,页面用 SSI 引入它,刷新即可看到变化,既不破坏缓存的整体性,也不需要任何后台程序。这类需求用 PHP 是杀鸡用牛刀,用 SSI 则刚好。

六、上线前值得跑一遍的检查单

第一,确认 ssi off 是默认值,只在你需要的 location 里打开;第二,用户上传目录显式关闭 SSI,并确认该目录不允许解析 PHP;第三,所有片段文件后缀统一为 .html(或配好 types),避免多层 include 静默失效;第四,SSI 页面不给长强缓存,或者改片段时批量 touch 引用页;第五,用 curl 检查一次 Content-Type,再检查一次响应体里是否残留裸露的 <!--# 指令——两者加起来能覆盖九成的配置错误。

最后一句经验:SSI 属于那种「只在你已经明确知道为什么不用 PHP 的时候,才该用它」的技术。它能省下的资源是真实的,但它带来的隐性成本(缓存行为、类型匹配、嵌套解析)也真实存在。一个纯静态的技术文档站、一个只有几页的落地页、一个由脚本生成的静态归档,是它最合适的场景;其他情况下,老老实实用现成的模板引擎更省心。

Last modification:October 23rd, 2026 at 07:25 pm

Leave a Comment