Writing Engineering & games

What a script is allowed to reach

A platform that lets users write server-side scripts and make HTTP requests has handed strangers a machine inside its own network. Whatever else that machine can reach, they can now reach too.

This is server-side request forgery, and it is one of the harder problems in user-scriptable platforms because the feature and the vulnerability are the same feature. Polytoria’s answer is a single function, and it is worth reading because it gets the shape right.

Why the naive version fails

The obvious implementation is to take the URL and fetch it. Consider what a script could then request.

http://localhost:6379 reaches a Redis instance on the same host that almost certainly has no password, because it was only ever meant to listen locally. http://10.0.0.5/admin reaches a private-network service that trusts anything originating inside the perimeter. Cloud providers historically exposed credentials over plain HTTP on a link-local address, which is why that particular request appears in breach write-ups repeatedly.

In every case the attacker’s own machine cannot route to those addresses. The game server can. The script is a proxy that borrows the server’s network position, which is the whole point of the attack.

The gate

CheckURLPass runs before any request leaves. Its structure is a sequence of refusals:

if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? parsedUri))
    throw new InvalidOperationException("Invalid URL");

if (parsedUri.Scheme != Uri.UriSchemeHttps && parsedUri.Scheme != Uri.UriSchemeHttp)
    throw new InvalidOperationException("Invalid URL scheme");

Parse first, and reject anything that will not parse. This matters more than it looks: a huge share of URL-filter bypasses come from the filter and the HTTP client disagreeing about what a string means. Parsing once with the same library that will perform the request removes that gap.

The scheme check kills a whole family at once. Without it, file:// reads local files and gopher:// can be coerced into speaking to arbitrary TCP services. An allowlist of two schemes is the correct shape — enumerate what is permitted, never what is forbidden.

Then production tightens further:

if (parsedUri.Scheme != Uri.UriSchemeHttps)
    throw new InvalidOperationException("Only HTTPs is allowed in production");

string host = parsedUri.Host.ToLowerInvariant();
if (host == "localhost" || host == "loopback")
    throw new InvalidOperationException("Access to localhost is not allowed in production");

if (IPAddress.TryParse(host, out _))
    throw new InvalidOperationException("Access to raw IP addresses is not allowed in production");

var addresses = Dns.GetHostAddresses(host);
if (addresses.Any(ip => ip.IsPrivate()))
    throw new InvalidOperationException("Access to private IP addresses is not allowed in production");

Four decisions worth noticing

HTTPS only in production, HTTP allowed locally. The isLocalTest parameter splits the policy. Developers can hit a local mock server over plain HTTP; the shipped game cannot. Recognising that a security control has a legitimate development mode, and passing that as an explicit argument rather than reading a global, keeps the difference visible at every call site.

Raw IPs rejected outright. Not “check whether this IP is private” — rejected entirely. This closes off the encoding tricks that make IP-based filters miserable: decimal notation, octal, IPv6-mapped IPv4, and the rest. Requiring a hostname sidesteps the entire category.

Resolution happens before the decision. A hostname that resolves to a private address is the classic bypass, and checking the string alone would miss it. The gate resolves and inspects the answers.

Refusals are specific. Each rejection says which rule fired. That is a judgement call — it tells a prober something — but for a platform whose users are mostly hobbyist developers hitting a rule by accident, a message they can act on is worth more than the marginal information leak.

The part that is hard for everyone

Any check of this shape resolves a name, decides, and then hands the URL to a client that resolves it again. Between those two lookups the answer can change. This is a time-of-check to time-of-use problem, and in the DNS setting it is called rebinding — described in the literature since 2007 and still not fully solvable at the application layer.

There is no clean fix inside a validation function. The robust mitigations live lower down: resolve once and connect to the resolved address rather than the name, or route outbound traffic through an egress proxy that enforces policy at connection time. Both are infrastructure changes, not code changes.

I raise it because it is the interesting part of the design space, not because it is a Polytoria oversight — essentially every application-layer SSRF filter in production shares this property, and the standard references treat it as a known limit rather than a bug.

Practical note: if you are building anything that fetches a user-supplied URL — a webhook, an avatar importer, a link preview — you have this problem. Allowlist schemes, reject raw IPs, resolve before deciding, and put the egress policy in the network rather than only in the code.

What generalises

  • Parse with the same library that will perform the request.
  • Allowlist what is permitted; a blocklist of schemes is always incomplete.
  • Reject raw IP literals rather than trying to classify them.
  • Resolve names before deciding, and know that resolution can change afterwards.
  • Give development an explicit, visible relaxation rather than a hidden one.

References

  1. OWASP. “Server Side Request Forgery Prevention Cheat Sheet.” The standard practitioner reference for this class of control.
  2. C. Jackson, A. Barth, A. Bortz, W. Shao, D. Boneh. “Protecting Browsers from DNS Rebinding Attacks.” ACM CCS, 2007. The paper that named the time-of-check problem in the DNS setting.
  3. J. H. Saltzer, M. D. Schroeder. “The Protection of Information in Computer Systems.” Proceedings of the IEEE, 63(9), 1975. Least privilege and fail-safe defaults, both visible in this design.
  4. Y. Rekhter et al. RFC 1918: Address Allocation for Private Internets, 1996; and M. Cotton et al. RFC 6890: Special-Purpose IP Address Registries, 2013. What “private” means precisely.
  5. Polytoria engine source, github.com/Polytoria/polytoria-gamePolytoria/scripts/datamodel/services/HttpService.cs.
← All writing Get in touch →