把自家網域固定解析至 127.0.0.1,會令 SSRF 防護拒絕自家檔案
Pin your own domain to 127.0.0.1 and your SSRF guard will refuse your own files
把自家網域在 /etc/hosts 固定解析至 127.0.0.1,會令按 IP 封鎖私有及回環位址的 SSRF 防護拒絕自家儲存檔案,令內部下載失敗。文章建議在 DNS 解析後、私有 IP 檢查前,僅對從應用程式環境變數取得的自家主機名作精確匹配例外,同時把連線固定到已解析的 IP。測試應確認自家回環位址主機獲接受、外部回環位址主機仍被拒絕,並對自家主機遭拒發出警報。
Two reasonable changes, made months apart by different people, can quietly break every download your app makes from its own storage. One is an SSRF guard. The other is a line in /etc/hosts. Neither is wrong on its own. Together they turn your own files into "unsafe URLs".
The setup
Picture a service that fetches URLs. Some come from users: a link pasted into a chat, an avatar URL, a webhook target. Others come from your own system: a messaging gateway saves an incoming voice note or photo to object storage and hands the processing pipeline a link like https://storage.example.com/media/abc.ogg.
Because users can supply URLs, the fetcher has an SSRF guard. Before it connects, it resolves the hostname and refuses anything private, loopback or link-local. That is the standard defence against "fetch whatever the user sent" turning into "read the cloud metadata endpoint" or "hit the admin panel on localhost".
The second change
Later someone hardens the server. The app calls its own API and storage hundreds of times a minute, and those calls should not depend on public DNS. So they pin the names to the box:
127.0.0.1 storage.example.com app.example.com
The reverse proxy on the same machine routes by SNI, TLS still validates, latency drops. On its own, a sensible change.
What happens next
storage.example.com now resolves to 127.0.0.1 on that server. The SSRF guard sees a loopback address and refuses the download, exactly as written. Every file your own gateway stored becomes unreachable to your own pipeline.
Nothing crashes. A refused URL is usually logged as a warning, by design, because users paste bad links all the time. You see the symptom somewhere downstream: a bot that answers every voice message with "I couldn't hear that, could you type it?", thumbnails that never render, documents that never get parsed. That symptom sends you to the transcription code or the model first, and to name resolution last.
The guard is right
From the guard's point of view, a hostname that resolves to loopback is a textbook DNS rebinding pattern. It cannot know that this particular name belongs to you. Where a name resolves is not a property of the name. It changes when you:
- add an
/etc/hostsentry; - run in Docker with
extra_hostsor an internal network alias; - use split-horizon DNS in Kubernetes or a VPC, so your public name resolves to
10.xinside; - put a private endpoint in front of a managed storage service;
- move from managed hosting to your own box.
Any of these can move your own traffic into the "private address" bucket.
Four ways to fix it
- Remove the hosts entry. Simplest. It brings back the DNS dependency, and the next person hardening the box will add the line again.
-
Allowlist by suffix, anything ending in
.example.com. Subdomains do not inherit ownership: a wildcard record, a dangling CNAME or a customer-controlled subdomain walks straight through. - Skip the IP check for storage URLs only. Fixes one caller and leaves the same trap for the next internal caller that fetches from your own name.
- Teach the guard your own public names, by exact match, and ask that question after resolving and before the private-IP check.
The fourth holds up best. Take the hostnames from the same environment variables the app already uses for its base URL and storage URL, so you keep one source of truth and no second list to forget.
const OWN_HOSTS = new Set(
[process.env.APP_URL, process.env.STORAGE_URL]
.filter((u): u is string => Boolean(u))
.map((u) => new URL(u).hostname.toLowerCase()),
);
export function isOwnPublicHost(hostname: string): boolean {
return OWN_HOSTS.has(hostname.toLowerCase());
}
In the guard, after DNS resolution:
const ips = await resolveAll(hostname);
if (isOwnPublicHost(hostname)) return { safe: true, pinnedIp: ips[0] };
// private, loopback and link-local checks for everyone else
Three details matter:
-
Exact match, not suffix.
storage.example.com.evil.netandstorage-example.comare still refused. - Keep pinning the connection to the resolved IP. A request to your own name goes to loopback as intended, and you do not open a rebinding window between the check and the connect.
-
Everything else stays the same. A foreign hostname that resolves to
127.0.0.1is still refused.
Test it so the test can fail
Mock the resolver. Make your own host resolve to 127.0.0.1 and assert the URL is accepted. Make a foreign host resolve to 127.0.0.1 and assert it is refused. Add the look-alike names. Run the suite against the old code first: the "own host" case has to go red there, otherwise the test proves nothing.
Make the quiet failure loud
A check that fails closed is correct and silent at the same time. Count refused URLs per hostname. A refusal for one of your own hostnames should never happen, so alert on the first one. That one counter turns a silent outage into an alert within minutes.
Takeaways
- A guard that judges by IP address needs to know your public names, because their resolution changes with your infrastructure.
- Re-check the SSRF guard whenever you touch DNS, hosts files, container networks or hosting.
- When a feature breaks for everyone at once and the code did not change, look at the plumbing before the model.
I write about running AI assistants for WhatsApp, Instagram and Telegram at dosai.pro.
來源:DEV Community · dev.to