Hiding a File Server Behind a Blog
Contents
Static hosting is wonderful right up until you have a two-gigabyte archive to hand someone. Uploading it to the host is either impossible or expensive, and the obvious workaround — pointing people at a machine you own — means telling the world where that machine is.
This site does neither. Large files live on a computer in my apartment, and the address of that computer appears nowhere: not in the HTML, not in a redirect, not in a response header, not in the network tab.
The shape of it
Three pieces, each doing one thing.
visitor → blog/dl/big.zip
│
Pages Function ← the real address lives here, in an env var
│ (server to server)
cloudflared tunnel ← outbound only; no open ports at home
│
my machine, 127.0.0.1
The key property is that the second hop is made by Cloudflare’s server, not by the visitor’s browser. A redirect would hand the address to the browser. A proxy never does.
Why a redirect is not enough
The tempting shortcut is to have the Function issue a 302 to the tunnel
hostname. It is one line of code and it works. It also completely defeats the
purpose: the browser has to know where it is going in order to go there, so
the address ends up in the Location header, in the address bar, and in
anyone’s devtools.
Even a short-lived signed URL only limits how long the address is useful. It does not stop the address from being learned.
Not following redirects into the open
Inside a Worker, fetch with redirect: 'follow' resolves redirects
server-side. The browser sees the final bytes and none of the hops. That is
the behaviour we want — but it also means the origin could, in principle,
bounce the proxy somewhere unexpected. So the response headers are rebuilt
from scratch rather than forwarded:
const PASS_THROUGH = [
'content-type',
'content-length',
'content-range',
'accept-ranges',
'etag',
'last-modified',
];
const headers = new Headers();
for (const name of PASS_THROUGH) {
const value = upstreamResponse.headers.get(name);
if (value) headers.set(name, value);
}
An allowlist, not a denylist. Anything the origin invents — Location,
Set-Cookie, Server, a stray X-Powered-By naming the machine — is
dropped because it was never on the list.
Range requests, or: the part people forget
A proxy that buffers the whole file before responding will fall over on
anything large, and it will make resuming a download impossible. Forwarding
the Range header costs four lines and turns the proxy into something you can
actually put a 4 GB file behind:
for (const name of ['range', 'if-range', 'if-none-match', 'if-modified-since']) {
const value = request.headers.get(name);
if (value) outHeaders.set(name, value);
}
Returning upstreamResponse.body directly streams it through. Memory usage
stays flat regardless of file size.
The manifest is a firewall
The proxy will not fetch an arbitrary path. Every downloadable file is listed in a manifest, and a request for anything else is refused before a packet leaves Cloudflare:
if (env.ALLOW_UNLISTED !== '1' && !ALLOWED.has(rel)) {
return fail(404, 'Not found.');
}
This is the difference between “a tunnel to my file server” and “a tunnel to
my computer”. Path traversal, drive letters, and encoded .. are all rejected
separately, but the allowlist is what makes those checks a second line of
defence rather than the only one.
Errors that say nothing
Every failure returns a fixed string. Not the upstream status text, not the exception message, not the hostname:
catch {
return fail(502, 'The file server is temporarily unavailable.');
}
A proxy that leaks getaddrinfo ENOTFOUND files-abc123.example.com in an
error page has undone all of the above.
The result
Hello (sample)1 KBA tiny text file used to verify that the private file proxy works end to end.That link points at my machine. You cannot tell where my machine is.
If my computer is switched off, the link returns a polite 502 and nothing else. When it comes back on, the same link works again — no republishing, no changed URLs, nothing on the blog to update.