compscai
All topics
· · 0 responses

Categorized as Grayware: Why My Site Would Not Load on Campus Wifi

One of my sites would not load on the campus wifi. Another site of mine, on the exact same server, loaded fine from the exact same laptop on the exact same network. That is a fun kind of broken, because it rules out most of what you would normally blame first. Writing it up because the answer was somewhere I had never thought to look, and the debugging path is reusable.

What it looked like

The browser sat there for a while and then gave me this:

code
This site can't be reached
<site> took too long to respond

That specific wording matters more than it looks. A timeout is not the same as a DNS failure and it is not the same as a connection reset. If DNS were being filtered I would expect a name resolution error. If a firewall were actively refusing me I would expect a fast reset, or a block page telling me the category it did not like. What I got was silence, which means something in the middle dropped my packets on the floor and never said anything.

So the first useful thing I learned: read the error text carefully before you touch anything. It narrows the search a lot.

Ruling out my own server

My instinct was that I had broken something in my own config. I have a bot problem on that site and I had been tightening things up, so I assumed I had blocked too much.

The campus network hands out addresses in a known range, so I went looking for that range in my nginx logs. Not just on the broken site, on every site on that box:

code
cd /var/log/nginx
for f in *.log *.gz; do
  n=$(zgrep -hc '^<campus-range>\.' "$f" 2>/dev/null)
  [ "${n:-0}" -gt 0 ] && echo "$n  $f"
done

The result was the thing that cracked it open. Requests from the campus range had reached three other sites on that server across the retained logs. The broken one had exactly zero. Not a single connection, ever.

That is a very specific shape of evidence. Same physical machine, same IP path, same CDN in front, same protocols. If the campus were blocking my server or my address, all four sites would be dark. Only one was. So whatever was doing this was matching on the hostname, not on where the hostname pointed.

Also worth saying plainly: zero requests means the problem is not in my application. My code never got a chance to run. That saved me from reading a lot of PHP for no reason.

Ruling out DNS and IPv6

The next two suspects are cheap to check, so check them.

code
dig +short A    broken-site.com; dig +short A    working-site.com
dig +short AAAA broken-site.com; dig +short AAAA working-site.com

Both sites came back on the same CDN, both dual stack with real IPv6 records, both proxied the same way. This mattered because a very common cause of "one site hangs, another does not, same wifi" is a broken IPv6 path. The browser tries v6 first, the network silently eats it, and you sit there watching a spinner. If only one of my sites had an AAAA record I would have found my answer right there.

I also checked domain age and registration, on the theory that new domains get filtered. That went nowhere too. The site that works is newer than the site that does not.

At this point I had eliminated my server, my app, my DNS, and IPv6. What was left was the network in front of me making a decision about the name itself.

The test that separates name from address

If you want to prove it is the hostname and not the address, do not compare two different domains. Compare one domain two ways.

code
# 1. Bare TCP to the IP, no hostname sent at all
curl -sv --max-time 10 -o /dev/null http://<the-ip>/

# 2. Same IP, but now with the hostname attached over TLS
curl -sv --max-time 10 -o /dev/null \
  --resolve broken-site.com:443:<the-ip> https://broken-site.com/

If the first one connects and the second one hangs, the filter is reading the hostname out of the TLS handshake and dropping you on that basis. If the first one never connects at all, you are being blocked at the address level and the hostname is irrelevant.

One caveat I got wrong on my first pass: I tried proving this by pointing one domain at the other domain's IP. That proves nothing when both sites sit behind the same CDN, because those addresses are shared across huge numbers of sites and either one will serve either hostname. Compare one domain against itself instead.

The actual answer

Campus networks run commercial URL filtering. The firewall vendor keeps a database that assigns every domain a category, and the network admin picks which categories to allow. Every major vendor has a public lookup page where you can type in a domain and see what they think it is.

Mine came back as grayware.

Here is roughly how that category is defined: content that does not pose a direct security threat but displays intrusive behavior, tempts users into granting remote access or performing unauthorized actions. It covers adware, rogueware, embedded crypto miners, clickjacking, browser hijackers, and typosquatting domains.

My site is a small online store. It has none of that. It does not even ship JavaScript of its own.

That was the whole mystery. Not a misconfiguration, not a firewall rule someone wrote about me specifically. A classifier looked at my domain, could not place it, and put it in a bucket that campus networks block by default. The silent drop rather than a block page is just how that gets enforced for HTTPS, since serving a block page for an encrypted connection means breaking the certificate, so a lot of setups do not bother and simply discard the traffic.

Why a normal site gets read as grayware

This is the part I actually found useful, so I went looking for what the classifier could see.

First I checked whether it could reach me at all. If a crawler gets a challenge page or a 403, it cannot classify you and it may default to something unflattering.

code
for ua in "" "curl/8.5.0" "Mozilla/5.0 (compatible; Googlebot/2.1)"; do
  curl -sS -o /dev/null -w "%{http_code} %{size_download}\n" -A "$ua" https://broken-site.com/
done

Everyone got a normal 200 with a full page. So the crawler could see me fine. It saw me and still did not like me.

Then I looked at what it actually saw, and the picture got clearer:

  • My contact email was invisible. My CDN has an email obfuscation feature turned on by default. It rewrites any address on the page into a placeholder that a small script decodes in the browser. A human sees the address. Anything that does not run scripts sees the literal text "[email protected]" and a link to an internal decode URL. So as far as a crawler was concerned, my site published no way to contact anyone.
  • There was no contact page. I had a contact form, but it lived in a collapsible widget in the footer and its endpoint only accepted POST. There was no address you could visit and read. A form is not a page.
  • The business behind it was never named. No legal entity anywhere in the markup.
  • No structured data identifying the site as a business. I had product markup on item pages, which helps search engines show prices, but nothing at all saying who was selling them.

Put those together and you get a young domain that takes money and offers no evidence a real company is behind it. I would not have guessed those four things add up to a security category, but from the classifier's side it is not unreasonable. It is looking for the things a legitimate business bothers to publish, and I had not published any of them.

The fix

Four changes, all of them things the site should have had anyway:

  1. A real contact page at a real URL, with a form that posts to the same endpoint the widget already used. One code path, one table, just a second door onto it.
  2. Organization structured data in the footer. I wrote it as meta and link tags rather than a JSON block, because the email obfuscation rewrites visible text and mailto links but does not touch content attributes. That gets the real address in front of a crawler even with the feature still switched on.
  3. The registered company name in the footer.
  4. The contact page added to the sitemap and the footer nav so it is actually discoverable.

Then you submit a recategorization request. Every vendor has a form, they all want a CAPTCHA, and each one runs a separate database, so being fixed with one tells you nothing about the others.

Worth knowing before you submit: at least one vendor runs an automated crawler against your live site the moment you submit, and if it agrees with you it approves on the spot. If it does not agree, a human reviews it. So there is a real benefit to fixing the site first and submitting second. Same vendor also says it will not accept requests for domains it considers newly registered, which is a wall you cannot climb by editing your markup.

Mine is submitted and not resolved yet. I will update this when it lands.

A bug I found on the way

While building the contact page I copied the honeypot field from my existing form. It was named company, hidden off screen, and the handler treats any value in it as a bot.

Browser autofill fills fields named company. It reads the name attribute and helpfully drops in your employer.

So a real person with autofill turned on could fill out my contact form, hit send, get a success page, and have the message silently discarded as spam. On the old widget this was mostly hidden because the form sat inside a collapsed element. On a plain contact page it would have been live.

Give your honeypot a name that means nothing. company, phone, address, and url are all things autofill recognizes and will happily fill for you.

What I took from it

I spent the first stretch of this convinced I had broken something, because that is usually the right bet. The logs are what turned it around, and specifically the comparison across sites rather than looking at the broken one in isolation. One site with no traffic tells you nothing. One site with no traffic sitting next to three sites with traffic on the same box tells you exactly where to look.

The other thing I keep thinking about is that reputation is infrastructure. I treat DNS and certificates and server config as things I own and have to get right. It had not occurred to me that a handful of private companies also hold an opinion about my domain, that their opinion determines whether entire networks can reach me, and that I was not doing any of the basic things that would inform that opinion.

Go look up your own domain on a couple of the vendor lookup pages. It takes two minutes and you might not like the answer.

0 responses