How a CORS proxy works — and how it can be abused
CORS proxies are useful, and an unprotected one is a gift to anyone trying to scan internal networks or launder credentials. This post covers the threats, what corsproxy.dev does about each one, and where those protections stop.
What a CORS proxy does, mechanically
A CORS proxy is a small HTTP server that does two things:
- Accepts a request from a browser at
/proxy?url=https://target.example/path. - Makes that request server-to-server, forwards the response back, and adds the
Access-Control-Allow-Originheader the browser was missing.
The proxy is still a cross-origin server. The browser lets your JavaScript read the response because the proxy says any origin is welcome.
The threats inherent to running one
The reason "open" CORS proxies on the public internet keep getting taken down: any code that can hit them can use them. Concretely:
Server-Side Request Forgery (SSRF)
If the proxy will fetch any URL, attackers send ?url=http://169.254.169.254/latest/meta-data/ (AWS metadata), ?url=http://10.0.0.5:9200/_search (an internal Elasticsearch), or ?url=http://localhost:6379/ (a Redis with no auth). The proxy makes the request from its network and returns the response.
Credential laundering
If the proxy forwards the Authorization header it received, an attacker can use the proxy to call paid APIs with someone else's bearer token. The target API logs the proxy's IP, not the attacker's.
Bandwidth abuse
Without rate limits, anybody who finds the proxy can pull terabytes through it. The bill lands on whoever's hosting it.
Reflected attacks
If responses are passed through untouched, an attacker can use the proxy to serve arbitrary content from a domain that the target trusts. Set-Cookie headers, redirect chains, and JavaScript can all be weaponised this way.
What corsproxy.dev does about each
Private-network targets are blocked
Only http:// and https:// targets are accepted, so no file://, gopher:// or data:. Before the upstream fetch, the target hostname is checked in two steps.
First, literal addresses and local names are refused with 403 BLOCKED_HOST:
localhostand any*.localhostname- RFC 1918 private ranges:
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 - Loopback and "this network":
127.0.0.0/8,0.0.0.0/8,::1,:: - Link-local:
169.254.0.0/16(which includes the169.254.169.254cloud metadata address) and IPv6fe80::/10 - Carrier-grade NAT:
100.64.0.0/10 - IPv6 unique local
fc00::/7, and IPv4-mapped IPv6 forms of all the IPv4 ranges above
Second, hostnames are resolved through Cloudflare's security DNS resolver (1.1.1.2, over DNS-over-HTTPS). A name that resolves to one of the private addresses above is refused with BLOCKED_HOST, which catches tricks like localtest.me. Domains the resolver flags for malware or phishing are refused with UNSAFE_TARGET.
The limits: that DNS check is a separate lookup made before the fetch, not a check on the connection the fetch actually opens. If the resolver errors, the check fails open and the request proceeds. Because resolve-then-fetch isn't atomic, DNS rebinding could in principle race it. The backstop is that the proxy runs on Cloudflare's edge, which can't reach private networks anyway. Upstream redirects are not followed: a 3xx is returned to the caller as is, so a redirect can't steer the proxy somewhere else.
Browser credentials are stripped before the upstream call
The proxy removes Authorization, Cookie, Origin, Referer, X-API-Key and Host from the browser's request before calling the upstream. Whatever the browser attached is never replayed to a third party.
To authenticate to the upstream, there are two explicit options:
- Managed upstream headers (recommended). You store a secret header on your API key for a specific target host, optionally a path prefix. The proxy adds it server-side, after everything else, so the secret never ships to the browser and the caller can't override it.
reqHeaders. Set a header for a single request withreqHeaders=Authorization:Bearer%20.... This works forAuthorizationtoo, but anything the browser sends is visible to the person using it.
Avoid putting tokens in the query string: URLs end up in logs, including our request log described below.
Every request is tied to an account and metered
The managed API requires an API key. Each account has a daily request quota, shared by all its keys and enforced atomically in a Cloudflare Durable Object. Hit the cap and you get a 429 until 00:00 UTC. Free is 500 requests/day and Pro is 20,000; Enterprise plans have no daily cap. There is also a daily bandwidth cap (250 MB on Free, 10 GB on Pro), and each response is limited to 10 MB.
Response headers are sanitised
We strip Set-Cookie, Transfer-Encoding, Connection, Keep-Alive and Upgrade from the upstream response, and set Access-Control-Allow-Origin: * ourselves. Access-Control-Allow-Credentials is never sent. Body content is passed through as-is, except that executable downloads are refused.
Audit logs
Every proxy request is logged for 30 days, then deleted by a daily job. Each entry holds the timestamp, user and key IDs, method, path, target URL, client IP, user agent, status, response time, request and response size, error code, country, Cloudflare data centre, referer, Cloudflare ray ID and bot score. Users can see their own logs in the dashboard; we use them for incident response.
Caching, and what we deliberately don't do
Nothing is cached unless a Pro request asks for it with ttl=<seconds> (1 to 86,400). With ttl, a 200 response to a GET is stored in the Cloudflare data centre that served it. The cache key is your API key plus the full target URL plus the headers sent upstream (minus per-visitor ones like User-Agent), so one customer's cached response is never served to another. The trade-off: within your own key, anyone sending the same URL and headers gets the cached copy until it expires. Don't set ttl on per-user data unless something user-specific is part of the URL or headers. Cache hits still count against your quota.
- We don't run a generic open proxy. There is no anonymous use; every request maps to an account.
- We only accept GET and POST (plus
OPTIONSfor preflight). The proxy is not a reverse proxy or an API gateway; it's narrow on purpose.
The self-hosted angle
If your security posture doesn't allow sending requests through a third party, we publish a separate, simpler open-source CORS relay in Go on GitHub (MIT). It is not the code that runs corsproxy.dev, and its defaults are different:
- It does block private, loopback, link-local and carrier-grade NAT targets by default. It resolves the hostname first, refuses the request if the lookup fails, and re-checks every redirect hop (it follows up to 10).
- It forwards every request header except
Host, includingCookieandAuthorization, and passes every response header back, includingSet-Cookie. - It has no API keys or quotas. A per-IP per-minute rate limit is available but off by default, and
ALLOWED_ORIGINSdefaults to*.
Self-hosting moves the security responsibility to you. If you expose it publicly, set ALLOWED_ORIGINS, ALLOWED_HOSTS and a rate limit.
Reporting a security issue
Found something? Email contact@corsproxy.dev. Our contact details are also in /.well-known/security.txt.