<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>X-Forwarded-For on Ambertu</title><link>https://ambertu.com/tags/x-forwarded-for/</link><description>Recent content in X-Forwarded-For on Ambertu</description><generator>Hugo</generator><language>zh-CN</language><copyright>2026 Ambertu · 粤ICP备18071259号</copyright><lastBuildDate>Thu, 03 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://ambertu.com/tags/x-forwarded-for/index.xml" rel="self" type="application/rss+xml"/><item><title>API 中转站的限流为什么不能信 X-Forwarded-For</title><link>https://ambertu.com/posts/api-gateway-real-ip-ratelimit/</link><pubDate>Thu, 03 Sep 2026 00:00:00 +0000</pubDate><guid>https://ambertu.com/posts/api-gateway-real-ip-ratelimit/</guid><description>X-Forwarded-For 是可追加的头，客户端能自己塞——按它做限流等于没限。本文讲清追加与覆写的本质区别、后端为什么必须只监听 127.0.0.1，以及多层反代下用 real_ip_recursive 从右往左取值的正确姿势。</description><content:encoded><![CDATA[<p>自建大模型 API 中转站几乎一定要做按 IP 限流：防注册刷号、防单账号打满上游配额。而这里有一个安静失效的陷阱——<strong>如果你的限流按 <code>X-Forwarded-For</code> 计数，那它等于没做</strong>。攻击者每个请求塞一个不同的伪造 IP，计数器永远打不满。</p>
<p>根本原因一句话：<strong><code>X-Forwarded-For</code> 是追加语义，<code>X-Real-IP</code> 是覆写语义。</strong> 前者保留客户端自带的值，后者丢弃。这个区别决定了哪个可信。</p>
<h2 id="追加与覆写的区别">追加与覆写的区别</h2>
<p>nginx 里这两行长得很像，行为完全不同：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="c1"># 追加：把 $remote_addr 拼在客户端已有的 XFF 后面
</span></span></span><span class="line"><span class="cl"><span class="k">proxy_set_header</span> <span class="s">X-Forwarded-For</span> <span class="nv">$proxy_add_x_forwarded_for</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 覆写：无条件用 $remote_addr 替换，客户端塞的值被丢掉
</span></span></span><span class="line"><span class="cl"><span class="k">proxy_set_header</span> <span class="s">X-Real-IP</span> <span class="nv">$remote_addr</span><span class="p">;</span>
</span></span></code></pre></div><p><code>$proxy_add_x_forwarded_for</code> 这个变量的定义就是&quot;客户端请求里的 XFF 值 + <code>, </code> + <code>$remote_addr</code>&quot;。所以攻击者只要在请求里带上 <code>X-Forwarded-For: 1.2.3.4</code>，上游收到的就是：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">X-Forwarded-For: 1.2.3.4, 203.0.113.77
</span></span><span class="line"><span class="cl">                 ↑伪造      ↑真实（nginx 追加的）
</span></span></code></pre></div><p>而<strong>绝大多数框架和教程都教你取 XFF 的第一个值</strong>当客户端 IP——理由听起来也很合理：&ldquo;最左边是最原始的客户端&rdquo;。这个说法在没有攻击者的世界里成立，在真实互联网上是漏洞。</p>
<figure>
<svg viewBox="0 0 800 300" role="img" aria-label="对比图：使用 X-Forwarded-For 时攻击者伪造的 IP 被追加保留，应用取第一个值导致限流计数落在伪造 IP 上；使用 X-Real-IP 时 nginx 覆写该头，应用取到真实 IP，限流生效" style="width:100%;height:auto;color:currentColor">
  <defs>
    <marker id="b1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M 0 1 L 10 5 L 0 9 z" fill="currentColor"/>
    </marker>
  </defs>
  <g font-family="monospace" font-size="10.5" fill="currentColor">
    <!-- top lane -->
    <text x="0" y="16" opacity="0.6">❌ 按 X-Forwarded-For 限流</text>
    <rect x="0" y="30" width="120" height="44" fill="none" stroke="currentColor" stroke-width="1.3"/>
    <text x="60" y="49" text-anchor="middle">攻击者</text>
    <text x="60" y="64" text-anchor="middle" font-size="9" opacity="0.65">自带 XFF: 1.2.3.4</text>
    <line x1="120" y1="52" x2="196" y2="52" stroke="currentColor" stroke-width="1.2" marker-end="url(#b1)"/>
    <text x="158" y="44" text-anchor="middle" font-size="9" opacity="0.65">追加</text>
    <rect x="198" y="30" width="150" height="44" fill="none" stroke="currentColor" stroke-width="1.3"/>
    <text x="273" y="49" text-anchor="middle">nginx</text>
    <text x="273" y="64" text-anchor="middle" font-size="8.5" opacity="0.65">$proxy_add_x_f_f</text>
    <line x1="348" y1="52" x2="424" y2="52" stroke="currentColor" stroke-width="1.2" marker-end="url(#b1)"/>
    <rect x="426" y="30" width="230" height="44" fill="none" stroke="currentColor" stroke-width="1.3"/>
    <text x="541" y="48" text-anchor="middle" font-size="9.5">XFF: 1.2.3.4, 203.0.113.77</text>
    <text x="541" y="64" text-anchor="middle" font-size="9" opacity="0.65">应用取 [0] → 1.2.3.4</text>
    <line x1="656" y1="52" x2="700" y2="52" stroke="currentColor" stroke-width="1.2" marker-end="url(#b1)"/>
    <text x="712" y="49" font-size="10" font-weight="bold">限流</text>
    <text x="712" y="63" font-size="10" font-weight="bold">失效</text>
    <text x="198" y="98" font-size="9.5" opacity="0.7">每个请求换一个伪造 IP → 计数器永远打不满</text>
    <line x1="0" y1="120" x2="780" y2="120" stroke="currentColor" stroke-width="1" opacity="0.2"/>
    <!-- bottom lane -->
    <text x="0" y="150" opacity="0.6">✓ 按 X-Real-IP 限流</text>
    <rect x="0" y="164" width="120" height="44" fill="none" stroke="currentColor" stroke-width="1.3"/>
    <text x="60" y="183" text-anchor="middle">攻击者</text>
    <text x="60" y="198" text-anchor="middle" font-size="9" opacity="0.65">自带 Real-IP: 1.2.3.4</text>
    <line x1="120" y1="186" x2="196" y2="186" stroke="currentColor" stroke-width="1.2" marker-end="url(#b1)"/>
    <text x="158" y="178" text-anchor="middle" font-size="9" font-weight="bold">覆写</text>
    <rect x="198" y="164" width="150" height="44" fill="currentColor" fill-opacity="0.1" stroke="currentColor" stroke-width="1.3"/>
    <text x="273" y="183" text-anchor="middle">nginx</text>
    <text x="273" y="198" text-anchor="middle" font-size="8.5" opacity="0.65">$remote_addr</text>
    <line x1="348" y1="186" x2="424" y2="186" stroke="currentColor" stroke-width="1.2" marker-end="url(#b1)"/>
    <rect x="426" y="164" width="230" height="44" fill="none" stroke="currentColor" stroke-width="1.3"/>
    <text x="541" y="182" text-anchor="middle" font-size="9.5">X-Real-IP: 203.0.113.77</text>
    <text x="541" y="198" text-anchor="middle" font-size="9" opacity="0.65">伪造值已被丢弃</text>
    <line x1="656" y1="186" x2="700" y2="186" stroke="currentColor" stroke-width="1.2" marker-end="url(#b1)"/>
    <text x="712" y="183" font-size="10" font-weight="bold">限流</text>
    <text x="712" y="197" font-size="10" font-weight="bold">生效</text>
    <text x="198" y="232" font-size="9.5" opacity="0.7">客户端塞什么都没用，值由反代单方面决定</text>
  </g>
  <line x1="0" y1="252" x2="780" y2="252" stroke="currentColor" stroke-width="1" opacity="0.2"/>
  <text x="0" y="274" font-size="10" font-family="monospace" fill="currentColor" opacity="0.7">关键差异：追加保留了客户端的输入，覆写没有。可信的是覆写。</text>
  <text x="0" y="292" font-size="10" font-family="monospace" fill="currentColor" opacity="0.7">前提：nginx 必须是唯一入口 —— 见下一节</text>
</svg>
<figcaption>两条路径的唯一区别是 nginx 那一格用了追加还是覆写。<strong>「取最左边是最原始客户端」这条常见说法，恰恰是最左边那个值完全由攻击者控制。</strong></figcaption>
</figure>
<h2 id="前提反代必须是唯一入口">前提：反代必须是唯一入口</h2>
<p><code>X-Real-IP</code> 可信有一个隐含前提——<strong>所有流量都必须经过反代</strong>。如果后端端口自己也在公网上听着，攻击者直连后端，想塞什么头就塞什么头，覆写逻辑根本不会执行。</p>
<p>这个疏漏比想象中普遍。写这篇文章的时候我顺手扫了自己的机器，发现两个问题：一个 Go 服务监听了 <code>0.0.0.0</code> 而不是 <code>127.0.0.1</code>，公网直接可达；更糟的是数据库端口也在安全组里开着，从外网能直接拿到 MariaDB 的协议握手包和版本号。两个都是历史遗留，跑了很久没人注意——<strong>因为从正常入口访问一切都是对的，问题只在你主动去扫端口时才会现形。</strong></p>
<figure>
<svg viewBox="0 0 760 250" role="img" aria-label="示意图：后端只监听 127.0.0.1 时攻击者无法绕过 nginx；后端监听 0.0.0.0 且安全组开放时攻击者可直连后端并自带任意请求头" style="width:100%;height:auto;color:currentColor">
  <defs>
    <marker id="b2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M 0 1 L 10 5 L 0 9 z" fill="currentColor"/>
    </marker>
  </defs>
  <g font-family="monospace" font-size="10.5" fill="currentColor">
    <text x="0" y="16" opacity="0.6">✓ 后端只监听 127.0.0.1</text>
    <rect x="0" y="30" width="110" height="38" fill="none" stroke="currentColor" stroke-width="1.3"/>
    <text x="55" y="53" text-anchor="middle">客户端</text>
    <line x1="110" y1="49" x2="186" y2="49" stroke="currentColor" stroke-width="1.2" marker-end="url(#b2)"/>
    <rect x="188" y="30" width="150" height="38" fill="currentColor" fill-opacity="0.1" stroke="currentColor" stroke-width="1.3"/>
    <text x="263" y="53" text-anchor="middle">nginx :443</text>
    <line x1="338" y1="49" x2="414" y2="49" stroke="currentColor" stroke-width="1.2" marker-end="url(#b2)"/>
    <text x="376" y="41" text-anchor="middle" font-size="9" opacity="0.65">覆写头</text>
    <rect x="416" y="30" width="180" height="38" fill="none" stroke="currentColor" stroke-width="1.3"/>
    <text x="506" y="53" text-anchor="middle" font-size="9.5">127.0.0.1:8081</text>
    <path d="M 55 68 L 55 92 L 506 92" fill="none" stroke="currentColor" stroke-width="1.2" stroke-dasharray="4 3" opacity="0.5"/>
    <text x="616" y="53" font-size="9.5" opacity="0.7">唯一入口</text>
    <text x="230" y="108" font-size="9.5" opacity="0.7">直连尝试 → 连接被拒，没有第二条路</text>
    <line x1="0" y1="128" x2="740" y2="128" stroke="currentColor" stroke-width="1" opacity="0.2"/>
    <text x="0" y="156" opacity="0.6">❌ 后端监听 0.0.0.0 且安全组开放</text>
    <rect x="0" y="170" width="110" height="38" fill="none" stroke="currentColor" stroke-width="1.3"/>
    <text x="55" y="193" text-anchor="middle">攻击者</text>
    <path d="M 110 189 L 200 189 L 200 216 L 414 216" fill="none" stroke="currentColor" stroke-width="1.4" marker-end="url(#b2)"/>
    <text x="238" y="234" font-size="9.5" font-weight="bold">绕过 nginx，自带任意 X-Real-IP</text>
    <rect x="188" y="170" width="150" height="38" fill="none" stroke="currentColor" stroke-width="1.3" stroke-dasharray="4 3" opacity="0.45"/>
    <text x="263" y="193" text-anchor="middle" opacity="0.45">nginx :443</text>
    <rect x="416" y="170" width="180" height="38" fill="currentColor" fill-opacity="0.1" stroke="currentColor" stroke-width="1.4"/>
    <text x="506" y="188" text-anchor="middle" font-size="9.5">0.0.0.0:8980</text>
    <text x="506" y="202" text-anchor="middle" font-size="8.5" opacity="0.7">公网可达</text>
    <text x="616" y="193" font-size="9.5" font-weight="bold">全部防护</text>
    <text x="616" y="206" font-size="9.5" font-weight="bold">被跳过</text>
  </g>
</svg>
<figcaption>下半图里被跳过的不只是 IP 覆写——TLS、限流、鉴权、路径前缀剥离，凡是在 nginx 层做的全部失效。<strong>后端监听地址是一道比任何头处理都更靠前的防线。</strong></figcaption>
</figure>
<p>自查两条命令，五秒钟的事：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 本机看监听地址：凡是 0.0.0.0 或 * 开头的都要问一句&#34;为什么&#34;</span>
</span></span><span class="line"><span class="cl">ss -tlnp
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 从外网验证（换成你的公网 IP 和端口），能连上就是有问题</span>
</span></span><span class="line"><span class="cl">curl -sv -m <span class="m">5</span> http://YOUR_PUBLIC_IP:8080/ 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> head -5
</span></span></code></pre></div><p>第二条必须<strong>从机器外面</strong>跑。在机器上自己 curl 自己永远是通的，测不出安全组的实际策略。</p>
<p>修法按优先级：</p>
<ol>
<li><strong>应用只监听本地</strong>——大多数框架都能配，Go 里就是 <code>http.ListenAndServe(&quot;127.0.0.1:8081&quot;, h)</code> 而不是 <code>&quot;:8081&quot;</code></li>
<li><strong>安全组只开 22 / 80 / 443</strong>——这是最后一道闸，也是最不容易被应用改动破坏的一道</li>
<li><strong>数据库一律不开公网</strong>——需要远程访问就走 SSH 隧道：<code>ssh -L 3306:127.0.0.1:3306 user@host</code></li>
</ol>
<h2 id="正确的限流写法">正确的限流写法</h2>
<h3 id="nginx-层限流天然安全">nginx 层限流：天然安全</h3>
<p>如果限流在 nginx 做，用 <code>$binary_remote_addr</code> 就好——它取的是 TCP 连接的对端地址，<strong>不是任何请求头</strong>，客户端无法伪造：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="c1"># http 块
</span></span></span><span class="line"><span class="cl"><span class="k">limit_req_zone</span> <span class="nv">$binary_remote_addr</span> <span class="s">zone=api:10m</span> <span class="s">rate=10r/s</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">location</span> <span class="s">/v1/</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kn">limit_req</span> <span class="s">zone=api</span> <span class="s">burst=20</span> <span class="s">nodelay</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">limit_req_status</span> <span class="mi">429</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">proxy_pass</span> <span class="s">http://127.0.0.1:3000/v1/</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>用 <code>$binary_remote_addr</code> 而不是 <code>$remote_addr</code> 是内存考虑：前者是 4 字节（IPv4），后者是字符串，在 10m 的 zone 里能存的条目数差好几倍。</p>
<h3 id="应用层限流只信覆写过的头">应用层限流：只信覆写过的头</h3>
<p>如果限流逻辑在应用里（比如要按用户 + IP 组合计数），那就只认 <code>X-Real-IP</code>：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="c1">// ✓ 只信反代覆写的头，且兜底用 TCP 对端地址</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="nf">clientIP</span><span class="p">(</span><span class="nx">r</span><span class="w"> </span><span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="nx">ip</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">r</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;X-Real-IP&#34;</span><span class="p">);</span><span class="w"> </span><span class="nx">ip</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="s">&#34;&#34;</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="nx">ip</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">host</span><span class="p">,</span><span class="w"> </span><span class="nx">_</span><span class="p">,</span><span class="w"> </span><span class="nx">_</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">net</span><span class="p">.</span><span class="nf">SplitHostPort</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">RemoteAddr</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="nx">host</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p><strong>绝对不要</strong>写成这样：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="c1">// ❌ 取 XFF 第一个值 —— 完全由客户端控制</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="nf">clientIP</span><span class="p">(</span><span class="nx">r</span><span class="w"> </span><span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="nx">xff</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">r</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;X-Forwarded-For&#34;</span><span class="p">);</span><span class="w"> </span><span class="nx">xff</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="s">&#34;&#34;</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="nx">strings</span><span class="p">.</span><span class="nf">Split</span><span class="p">(</span><span class="nx">xff</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;,&#34;</span><span class="p">)[</span><span class="mi">0</span><span class="p">]</span><span class="w">  </span><span class="c1">// 攻击者说什么就是什么</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="o">...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>我自己的 pet 服务在这一处踩过，最后在 nginx 配置里留了行注释提醒自己：<strong><code>X-Real-IP</code> 不能少，注册限流只认这个头，因为 XFF 客户端能自己塞。</strong> 这类结论写在配置文件旁边比写在文档里有用得多——改配置的人一定会看到它。</p>
<h2 id="前面还有-cdn-怎么办">前面还有 CDN 怎么办</h2>
<p>如果套了 CDN，<code>$remote_addr</code> 是 CDN 节点的 IP，直接覆写 <code>X-Real-IP</code> 会把全站流量都算成同几个 CDN 节点，限流立刻误伤所有人。这时候要用 nginx 的 <code>realip</code> 模块，而不是自己解析 XFF：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="c1"># 声明哪些来源是可信代理（填 CDN 的回源网段，各家文档都有公布）
</span></span></span><span class="line"><span class="cl"><span class="k">set_real_ip_from</span> <span class="n">203.0.113.0</span><span class="s">/24</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">set_real_ip_from</span> <span class="n">198.51.100.0</span><span class="s">/24</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">real_ip_header</span>    <span class="s">X-Forwarded-For</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">real_ip_recursive</span> <span class="no">on</span><span class="p">;</span>          <span class="c1"># 这一行是安全的关键
</span></span></span></code></pre></div><p>配好之后 <code>$remote_addr</code> 会被改写成真实客户端 IP，前面所有基于 <code>$remote_addr</code> 的限流、<code>X-Real-IP</code> 覆写都自动正确，不用改一行业务代码。</p>
<p><strong><code>real_ip_recursive on</code> 为什么是安全的关键</strong>，值得画一下：</p>
<figure>
<svg viewBox="0 0 760 230" role="img" aria-label="示意图：real_ip_recursive 开启时 nginx 从 X-Forwarded-For 最右侧开始向左扫描，跳过属于可信代理网段的地址，取第一个非可信地址作为真实客户端 IP，因此最左侧攻击者伪造的值永远不会被取到" style="width:100%;height:auto;color:currentColor">
  <defs>
    <marker id="b3" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M 0 1 L 10 5 L 0 9 z" fill="currentColor"/>
    </marker>
  </defs>
  <text x="0" y="16" font-size="10.5" font-family="monospace" fill="currentColor" opacity="0.6">X-Forwarded-For 的实际内容</text>
  <g font-family="monospace" font-size="10.5" fill="currentColor">
    <rect x="0" y="34" width="180" height="42" fill="none" stroke="currentColor" stroke-width="1.3" stroke-dasharray="4 3"/>
    <text x="90" y="52" text-anchor="middle" font-size="9.5">1.2.3.4</text>
    <text x="90" y="68" text-anchor="middle" font-size="9" opacity="0.7">攻击者伪造</text>
    <text x="190" y="59" opacity="0.5">,</text>
    <rect x="204" y="34" width="200" height="42" fill="currentColor" fill-opacity="0.12" stroke="currentColor" stroke-width="1.5"/>
    <text x="304" y="52" text-anchor="middle" font-size="9.5">203.0.113.77</text>
    <text x="304" y="68" text-anchor="middle" font-size="9" opacity="0.75">真实客户端</text>
    <text x="414" y="59" opacity="0.5">,</text>
    <rect x="428" y="34" width="200" height="42" fill="none" stroke="currentColor" stroke-width="1.3"/>
    <text x="528" y="52" text-anchor="middle" font-size="9.5">203.0.113.9</text>
    <text x="528" y="68" text-anchor="middle" font-size="9" opacity="0.7">CDN 节点（可信）</text>
  </g>
  <path d="M 620 100 L 540 100" fill="none" stroke="currentColor" stroke-width="1.4" marker-end="url(#b3)"/>
  <text x="628" y="104" font-size="9.5" font-family="monospace" fill="currentColor">从右开始扫</text>
  <g font-family="monospace" font-size="9.5" fill="currentColor">
    <text x="428" y="126">① 在 set_real_ip_from 里 → 跳过</text>
    <text x="204" y="146">② 不在可信列表里 → 取它，扫描停止 ✓</text>
    <text x="0" y="166" opacity="0.6">③ 永远扫不到这里</text>
  </g>
  <line x1="0" y1="184" x2="740" y2="184" stroke="currentColor" stroke-width="1" opacity="0.2"/>
  <text x="0" y="206" font-size="10" font-family="monospace" fill="currentColor" opacity="0.75">扫描一旦遇到非可信地址就停止，所以左侧的伪造值不可能被取到。</text>
  <text x="0" y="224" font-size="10" font-family="monospace" fill="currentColor" opacity="0.75">安全性完全依赖 set_real_ip_from 填得准 —— 填宽了等于自己把门打开。</text>
</svg>
<figcaption><strong>方向就是安全性本身。</strong>从右往左扫、遇到非可信地址即停，意味着攻击者在左边塞多少个伪造 IP 都到不了。反过来从左往右取第一个，就是本文开头那个漏洞。</figcaption>
</figure>
<p>两个配置陷阱：</p>
<ul>
<li><strong><code>real_ip_recursive off</code>（默认值）在有 CDN 时是错的。</strong> 关闭递归时 nginx 只取 XFF 的<strong>最后一个</strong>地址，那就是 CDN 节点自己，等于没解析。</li>
<li><strong><code>set_real_ip_from</code> 千万别填 <code>0.0.0.0/0</code>。</strong> 把所有来源都标为可信，递归就会一路扫到最左边——正好扫到攻击者伪造的那个值，比不配还糟。见过有人为了&quot;省事&quot;这么写。</li>
</ul>
<h2 id="一份自查清单">一份自查清单</h2>
<table>
	<thead>
			<tr>
					<th>检查项</th>
					<th>命令 / 位置</th>
					<th>期望</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>后端监听地址</td>
					<td><code>ss -tlnp</code></td>
					<td>只有反代和 SSH 在 <code>0.0.0.0</code>，业务端口都在 <code>127.0.0.1</code></td>
			</tr>
			<tr>
					<td>外网可达端口</td>
					<td>从<strong>外部机器</strong> <code>curl</code> 各端口</td>
					<td>只有 22 / 80 / 443 通</td>
			</tr>
			<tr>
					<td>数据库端口</td>
					<td><code>ss -tlnp | grep -E '3306|5432'</code></td>
					<td>绑 <code>127.0.0.1</code>，且安全组不开</td>
			</tr>
			<tr>
					<td>应用取 IP 的逻辑</td>
					<td>搜代码里的 <code>X-Forwarded-For</code></td>
					<td>不存在取 <code>[0]</code> 的写法</td>
			</tr>
			<tr>
					<td>nginx 是否覆写</td>
					<td>搜配置 <code>X-Real-IP</code></td>
					<td>每个 <code>proxy_pass</code> 的 location 都有</td>
			</tr>
			<tr>
					<td>CDN 场景</td>
					<td>搜配置 <code>real_ip_recursive</code></td>
					<td>为 <code>on</code>，且 <code>set_real_ip_from</code> 是具体网段</td>
			</tr>
	</tbody>
</table>
<h2 id="相关阅读">相关阅读</h2>
<ul>
<li><a href="/posts/llm-api-sse-proxy-buffering/">反代把大模型 API 的 SSE 流式响应缓冲了：五个缓冲点与三层排查法</a> —— 同一份 nginx 配置里的另一个坑</li>
<li><a href="/posts/new-api-binary-vs-docker/">new-api 部署选型：2G 小内存机器上裸二进制比 Docker 省多少</a> —— 后端监听 <code>127.0.0.1</code> 在两种部署形态下怎么配</li>
</ul>
]]></content:encoded></item></channel></rss>