Access Control
Published
Geographic access control looks like infrastructure and is usually configuration: a list of countries you serve, a list you must not, a rule about anonymising exits, a decision about addresses the database cannot place, and a reputation check of your own. Each is a property name on an options object read by a component that was installed rather than built, and the difference between a refusal and an admission is whether that name still resolves. We protected a file that configures one, renamed the names a group at a time, and read the decisions.
What the sample actually does
The file configures access the way a company with export obligations configures it. Two countries are served, so the serving allowlist names them. One country is sanctioned, so the denylist names it. Anonymising exits are refused. The trusted proxy depth is set to one, so the address believed is the one the load balancer wrote rather than the one the client did. Addresses the geo database cannot place are denied by default. And the application supplies its own reputation check against a threat feed.
Each guard is exercised against a request only it can refuse. The sanctioned address is otherwise unremarkable. The Brazilian address is not sanctioned anywhere - it is simply not a market. The VPN exit geolocates to a country that is served. The unknown address is a new allocation nothing has heard of. The stuffing source is in a served country, is not an anonymiser, and is only distinguishable by the feed. Every option was also deleted from the sample's own configuration before any protected arm was read, and every one moved the output except the control pinned equal to the library default.
The middleware is copied in unprotected, with the permissive defaults this family ships when handed an empty configuration: everywhere served, nowhere denied, anonymisers welcome, the leftmost forwarded entry believed, unknown addresses allowed, and no reputation opinion.
Protection alone was applied first, on five presets covering both output targets, the gate profile and the compressed profile. All five behaved identically to the unprotected file.
The sanctioned country was covered twice, and that is why the halves looked harmless
The sanctioned address is refused by the denylist. It is also, incidentally, not on the serving allowlist - the company does not sell there either. Two independent rules, both reaching the same request.
Renaming the denylist alone: still refused. The measured line moved from sanctioned-country=refused(country-denied(kp)) to refused(country-not-served(kp)). Same outcome, different reason, and the reason is the part a compliance report quotes.
Renaming the serving allowlist alone: also still refused, by the denylist, and the only visible change was in an unrelated request from a country that is merely unserved, which became permitted.
Renaming both produced sanctioned-country=ALLOWED(permitted). The request went through, and the compliance export that would evidence the refusal has nothing to record because there was no refusal.
Each half was a value diff with no change of outcome. That is the finding a reviewer closes as cosmetic, and it is the reliable signature of two controls that are only independent until one transformation reaches both. They are properties of the same object in the same file.
The mitigation is placement rather than count. If a sanctions rule genuinely must hold, it belongs at the edge - a WAF rule, a CDN country rule, a network ACL - where a change to your bundle cannot move it. A second rule in the same configuration literal is not a second layer.
Which address is being judged
The trusted proxy depth decides which entry in the forwarded chain is believed. Renaming it reverted the depth to the library default, which believes the leftmost entry - the one the client wrote.
The measured request has a genuine sanctioned client behind a load balancer, with a respectable London address prepended by the client themselves. Before: spoofed-forwarded-for=refused(country-denied(kp)) judged-from=203.0.113.20. After: ALLOWED(permitted) judged-from=198.51.100.7. The visitor chose their own country and the system agreed.
The legitimate request is unaffected either way, which is exactly why this survives testing: with no client-supplied header the leftmost entry and the trusted entry are the same address, so every ordinary request behaves identically and only a request that is trying to lie behaves differently.
The forwarded-header question has its own article in this series, linked below, covering the two opposite failures that one option produces. It is worth reading alongside this one, because here it is the input to every other rule on the page: a country check is only as good as the address it is checking.
The rest of the area
Renaming the anonymiser rule reverted it to off, and a VPN exit geolocating to a served country was permitted. Whether that matters depends entirely on why you geofence - it is close to irrelevant for latency routing and central for licensing or export control.
Renaming the default-action option moved unknown addresses from denied to allowed: unknown-address(198.51.100.211)->allow. This is the quiet one. New allocations, carrier NAT ranges and satellite blocks are a steady trickle of addresses no database can place, so the arm produces a slow leak rather than an event, and the requests it admits look exactly like ordinary traffic.
Renaming the reputation callback substituted the library's builtin, which has no opinion, and a known credential-stuffing source in a served country was permitted. Same downgrade shape as everywhere else in this series: the check is still configured, still reported as caller-supplied, and answering a weaker question.
Renaming the compliance export's field names left every decision correct and took the export from at,country,decision,rule to _0x1,_0x2,_0x3,rule, with the readback reading MISSING for all three. The refusals happened; the evidence that they happened stopped being legible. For a control that exists mainly to be evidenced, that is most of the value.
Renaming the request's own field names failed loudly, as this direction always does: every address resolved to undefined, every request was refused, and the legitimate customer was locked out along with everybody else.
What to do about it
Scope the renaming with MemberRegexp, excluding the middleware's option names, the request fields it reads, and the fields on the row your export writes.
Put anything with a legal consequence at the edge. Sanctions screening and export control should not depend on a property name in an application bundle, for the same reason they should not depend on a feature flag: the failure is silent, the evidence is the same artefact that failed, and the finding arrives from an auditor rather than a monitor.
Prefer deny-by-default for unplaceable addresses and set it explicitly. It is the option in this area whose library default is the permissive one, and its failure mode is a trickle rather than an event, so it will not announce itself.
Verify with requests. Send a request whose forwarded header claims a served country from a peer that is not one, and confirm the claim is ignored. Send one from an address your database cannot place and confirm the default you expect. Both take a minute against the deployed build.
Frequently asked questions
Does protecting my JavaScript break geo blocking on its own?
Not in this measurement. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result required member renaming pointed at property names.
The sanctioned country was on two lists. Why did that not save it?
It saved it against each half. Renaming either list alone left the request refused and changed only the reported reason. Both lists are properties of the same options object in the same file, so one pattern reached both, and then the sanctioned request was permitted.
Which arm is the quietest?
The default action for addresses the geo database cannot place. Renaming it moved unknown addresses from denied to allowed, and new allocations and carrier NAT ranges are a steady trickle, so it produces a slow leak rather than an event.
What happened to the forwarded header?
Renaming the trusted depth reverted it to believing the leftmost entry, which the client writes. A sanctioned client who prepended a London address was judged to be in London. Ordinary requests are unaffected, which is why it survives testing.
Was the threat feed check disabled?
It was downgraded to the library's builtin, which has no opinion, so a known credential-stuffing source was permitted. The reported check still read caller-supplied, because the option is still a function - it is simply not the one being consulted.
Would the compliance export show any of this?
One arm made it worse. Renaming the export's field names left every decision correct while the readback reported MISSING for country, decision and timestamp. For a control that exists mainly to be evidenced, losing the evidence is most of the loss.
What is the smallest change that prevents all of this?
Scope RenameMembers with a MemberRegexp excluding the middleware's option and request field names, and move anything with a legal consequence to the edge, where a change to your bundle cannot reach it.
Related reading