The classic-ASP bug that printed the literal word “undefined” instead of your IP
A small diagnostic page began returning the literal word undefined instead of the client value it was meant to display. There was no server error and no blank page-just a normal response with plausible-looking nonsense in the body.
That kind of failure is easy to miss because basic uptime checks still pass. The page renders and the status is successful, but the content is wrong. It was also conditional: some traffic paths supplied a value in the first lookup, while others reached the faulty fallback logic. That made the bug look intermittent even though the underlying code path was deterministic.
The root cause is a JScript-on-ASP quirk I had been carrying for years without knowing it, and the reason it only just surfaced is the part worth your time: I had a canonical helper that was accidentally papering over the bug, and three pages that hand-inlined the same logic without it. The helper’s defensive coercion hid a landmine that the copies stepped on.
The code
This was happening during a Cloudflare migration, so the IP page had to read the real client IP out of one of a few possible server variables, in priority order: Cloudflare’s pseudo-IPv4 header first, then CF-Connecting-IP, then fall back to REMOTE_ADDR. The inlined version on the broken pages looked like this:
var IP = String( Request.ServerVariables("HTTP_CF_PSEUDO_IPV4") || Request.ServerVariables("HTTP_CF_CONNECTING_IP") || Request.ServerVariables("REMOTE_ADDR"));If you write JavaScript, this reads as obviously correct. Take the first truthy value in the chain, coerce it to a string, done. For a visitor who is not behind Cloudflare’s pseudo-IPv4 path, you would expect the first lookup to be empty/falsy, the || to fall through to CF-Connecting-IP or REMOTE_ADDR, and the real IP to come out.
It does not. For an IPv4 visitor it returns undefined. Every time.
The trap
The mistake is assuming Request.ServerVariables("X") returns a string. It does not. In JScript ASP, Request.ServerVariables("X") returns a collection/Variant object, not the value inside it. You have to index or stringify it to get the actual value out. That distinction is invisible most of the time because ASP will happily coerce that object to its string value when you write it to the response.
But here it bites twice:
-
The object is always truthy. A
Request.ServerVariables("...")lookup for a header that is not present still hands you back a live collection object, and an object is truthy. So the||chain never falls through. The very first lookup,HTTP_CF_PSEUDO_IPV4, is truthy even when that header does not exist, so the second and third operands are never evaluated. The fallback logic I carefully wrote is dead code. -
Stringifying the empty-but-present first variable yields
"undefined". When that first object has no underlying value and you wrap the whole expression inString(...), what comes back is the literal textundefined. So the page that was supposed to print a real IP printsundefined, and it prints it confidently with a 200.
So both failure modes line up into the same nine-character output. The truthiness quirk guarantees you always evaluate the first operand and only the first operand, and the coercion quirk guarantees that when the first operand is empty, you get the word undefined rather than a blank.
Why I never saw it before
The site has a canonical helper for exactly this, ResolveClientIP(), with the same precedence chain: CF_PSEUDO_IPV4 then CF_CONNECTING_IP then REMOTE_ADDR. That helper has been running in production fine. The IP page was the thing throwing undefined, not the helper.
The difference is one function call. The canonical helper runs each server variable through a Clean() step before it does anything else. Clean() string-coerces its argument, and then, crucially, it lowercases the result and maps the literal strings "undefined" and "null" back to "". Its own comment says so: “simplify undefined and null to blank and ensure code is a string.” So the moment you Clean() each variable individually, an absent header collapses to an empty string "", which is falsy, which means the || chain behaves exactly the way a JavaScript developer expects: it falls through to the next real value. That "undefined" remap was never written as a fix for this bug. It is just defensive hygiene that predates the Cloudflare work. But that hygiene is the only reason the truthiness landmine never went off inside the helper.
The three pages that broke did not call the helper. They inlined the String(... || ... || ...) chain directly, wrapping the whole expression in String() once at the end instead of coercing each operand. One outer String() does not save you, because by the time it runs, the || has already picked the always-truthy first object. The coercion has to happen before the ||, per variable, or the fallback never engages.
The affected pages spanned a diagnostic view and administrative flows. All were copies of the same logic rather than calls to the canonical helper. The environment change did not create the defect; it changed which inputs were empty and made the existing divergence visible.
The fix, and the grep that matters more
The verification was direct: a controlled request through each affected path returned a valid value rather than undefined, and the shared helper still produced the expected result. That checks the behavior, not merely the code change.
But fixing the known pages is only the symptom fix. The deeper problem is that the same incorrect shape can exist anywhere a developer has copied the logic instead of using the helper. The remediation was therefore a scoped search for equivalent fallback chains, followed by review of each result. The bug was not “one page is wrong.” The bug was “the canonical logic and its copies no longer have the same safeguards.”
The takeaway
Two things, and the second is the one I keep relearning.
First, the runtime-specific one: in this legacy JScript/ASP environment, a server-variable lookup did not behave like an ordinary absent string. A fallback chain must normalize and validate each value before relying on truthiness. Similar APIs vary by runtime, so verify the actual return type and missing-value behavior in the environment you operate rather than transferring assumptions from browser JavaScript.
Second, the one that generalizes past ASP: when you keep a canonical helper and hand-inlined copies of the same logic, the helper’s defensive coercion will hide the exact bugs the copies expose. The helper working in production is not evidence the logic is safe. It is evidence the helper is doing one extra defensive step the copies forgot. Every inlined copy of shared logic is a place where the next person, or the next environment change, gets to rediscover the landmine the helper quietly defused.
So when you find a bug like this, do not just fix the three pages. Find the divergence. The pages that diverged from the canonical path are the bug. The pages that printed undefined were just the ones unlucky enough to get read.
Related
- Number(“LEGACYORG-EAST”) = NaN: How One Type Assumption Crashed Every Bracket Save for a Whole Class of Accounts: another JScript-on-ASP type coercion surprise that produced silent wrong behavior instead of an error
- Why schedules broke around March 4, 1973: a getTime() digit-count sort bug: a different JS coercion assumption (numeric sort of date strings) that went unnoticed for years
- Why Request.Form Is Empty: FormData vs URLSearchParams in Classic ASP AJAX: another classic-ASP API surface that behaves differently than the JS equivalent implies
- Edit a label, get " back: the HTML-entity round-trip bug: a different class of “helper silently transforms the value” bug at the ASP output boundary