A deploy failed against a firewall rule that no public documentation describes — not the host's, not ours. The runbook we trusted had been right once; the wall had moved since.
On 2026-06-04, a section deploy to a client site — a welding and fabrication shop — was refused by the managed host's origin firewall. The runbook in hand modeled that firewall narrowly: it blocked REST POST bodies containing HTML literals, bypassable through a custom endpoint or by form-encoding. All of that was stale. The live rule set was wider in some places and narrower in others, and the host publishes none of it.
The transferable finding is not the workaround. It is the method: when a security layer you do not administer refuses your work, stop reasoning about what it probably does and measure what it actually does, one construct per request. We call that Probe the Firewall, Don't Model It. The artifact it produces — a block/pass matrix — outlives the deploy that prompted it, because the passes are the workaround.
Step 1 — Identify the blocking layer from response headers, not from assumptions
A managed host, a CDN, a security plugin and an upstream proxy produce failures that look identical from a terminal, and each has a different remedy.
The refused requests on 2026-06-04 returned a waf-block: request header, an empty body, and — the load-bearing part — no CDN mitigation header of any kind. A CDN block announces itself: a challenge, a ray identifier, a mitigation reason. None was present. The block belonged to the host's own origin firewall, one layer beneath the CDN dashboard where an operator would spend the next hour.
Worked example of the inverse. Later in the same session a scripted request from Python's urllib was refused with a different signature — that one was the CDN, applying a browser-signature ban to the default user agent. Re-issued through curl, it passed. Two layers, two rule sets, two remedies, and only the response headers tell them apart.
Step 2 — Build a matrix of minimal disposable probes, one construct each
A probe that tests three things at once tells you nothing when it fails. The session ran roughly sixteen disposable snippets on 2026-06-04, each carrying a single construct, each deployed and observed in isolation against the site's managed host.
They were left in place rather than removed, because the snippet manager's own REST DELETE returns a 500 here — a consequence of the very rule set being mapped. The instrument for cleaning up the probes was blocked by the thing the probes measured.
Every row below is an observed result from that session, not an inference from documentation.
| Construct | Result | Note |
|---|---|---|
Tag-start strings — <script, <style, <section, <svg, <img, <input | Blocked | Matched as XSS tag-starts on the raw payload; the string alone is enough |
Injection and obfuscation tokens — base64_decode, hex2bin, chr(, pack(, hexdec | Blocked | Token match against the source text |
Anonymous closures — function ( | Blocked | Treated as an obfuscation signature |
| Server-side-request-forgery fetch helpers | Blocked | Any helper containing a fetch function trips it |
require / include | Blocked | File-inclusion rule |
| Binary media upload | Blocked | Refused at the origin firewall, not the media library |
HTTP DELETE verb | Blocked | Why the probes could not be cleaned up over REST |
GET requests | Passed | Every read path stayed open throughout |
| Named functions | Passed | Semantically identical to the blocked closure form |
| Braces and control flow | Passed | No structural PHP rule in evidence |
| Route registration | Passed | Custom endpoints register normally |
| Heavy string concatenation | Passed | Roughly 150 concatenated segments in one expression |
| Arbitrary hex or base64 data | Passed | 80 KB and above passed cleanly |
| Small, clean PHP | Passed | The baseline control for the matrix |
Step 3 — Record the passes as carefully as the blocks
Most write-ups of a firewall encounter publish the blocks, because the blocks are the complaint. The passes are the deliverable, and two rows do all the work.
Anonymous closures were blocked; named functions passed. Those two constructs do the same thing. Nothing about a closure is more dangerous than a named function with the same body — the rule is not reasoning about behaviour, it is matching a shape that obfuscated payloads tend to have. The remedy is a rename, not a redesign.
The decoder tokens were blocked; the encoded data was not. base64_decode was refused while 80 KB of base64 payload passed without comment. That inverts the intuition everyone brings to a firewall, which is that the dangerous thing is the data. Here the data is inert and the short verb that would act on it is the trigger.
The workaround falls out of those two rows: carry content as data, assemble it with named functions and string concatenation, and never let a decoder token or a tag-start literal appear in the transmitted source. Not a clever bypass — the rule set, read back in the order it was written.
The rule with no workaround
One capability could not be rebuilt. A helper endpoint that performs an outbound fetch trips the request-forgery rule by construction — the fetch function is the match, and there is no version of "a helper that fetches things" without one. Write that down, because an hour hunting for a route around a rule that has none is an hour gone.
Step 4 — Find the off switch and time-box the window
The host does expose a control. On its dashboard: Apps → the site → Manage → Security tab → scroll past the malware scanner → a Web Application Firewall toggle.
That path appears in none of the host's knowledge-base articles. It was found by walking the dashboard rather than searching the documentation, and it is the most useful sentence in this post for anyone on the same platform.
The deploy ran with the firewall off, over curl, on-domain, in about two minutes, and the firewall went back on immediately. Time-boxing is not a formality — an off switch you forget to flip back is a worse defect than the one you were working around.
Verification, and what it proved beyond the deploy
The post-deploy check ran logged out, on the canonical URL, with no cache-buster, because a cache-busted URL verifies that your file exists rather than that a visitor will receive it.
The response returned a cache HIT dated after the deploy — a two-for-one result. The new section was live for anonymous traffic, and both the CDN and the page cache had purged and re-cached with no manual purge issued. A cache HIT older than the deploy would have meant the opposite, and the difference is one timestamp.
What we did not verify
The source record carries status: draft, and two claims inside it are weaker than the rest. Mobile rendering and the reduced-motion variant were not re-tested live at deploy. That both behave correctly rests on the CSS for those paths being unchanged from an earlier verified state — a reasonable inference, and not a measurement. A regression there unrelated to this deploy would have gone unnoticed.
What would prove this wrong
- The same probe set passing unchanged on a second site on the same host. The rule set would be per-site configuration rather than a platform default, and the matrix stops generalizing.
- A helper containing a fetch function deploying successfully. "No workaround exists" is the strongest claim here and the easiest to overturn.
- A
waf-blockheader arriving alongside a CDN mitigation header. Then Step 1's layer identification was wrong and everything built on it is suspect. urllibsucceeding from a different network. That sinks the browser-signature explanation.- The host publishing a rule set that contradicts any row above. Documentation beats our probes, and we replace the table.
What the matrix is for
A firewall you do not administer is not a specification to reason about — it is a black box to characterize. Sixteen small probes, one construct each, turn every future deploy on that platform into a table lookup. If a security layer has ever eaten a deploy and the answer was "we worked around it somehow," the four steps above will leave a matrix the next deploy inherits instead of a story nobody can reuse. We will map one alongside a team that wants the artifact.
Get the kit, not just the theory.
We'll send the build checklist behind this post — and the next pillar when it ships. One email, no drip sequence. Unsubscribe in one click.