I did not believe my own scanner

Six security headers were configured for a site I look after. Five of them arrived on every request. The sixth had never arrived, not once in the two months since it was written.

I did not find that by reading the configuration. I found it because my own scanner read the domain from the outside, reported a missing Content-Security-Policy header, and my first assumption was that the scanner was wrong.

The repository said otherwise. The header had been added in June, in a commit that also hardened something unrelated, and it sat in the same block as the five that were working. The policy itself was 416 characters on a single quoted line. The README documented it word for word. The architecture notes referred to it as a constraint on what the site was allowed to embed. A review of the repository confirmed every part of that, by line number.

So I started eliminating.

The CDN was serving a cached response, so I forced a miss. Same five headers, no policy. The CDN might have been stripping it in transit, so I asked the origin directly. Same five headers, no policy. The file might have been malformed, so I had it parsed. Nine entries, the policy among them, one clean line with no embedded newlines, correct list, correct service, correct path. There might have been a second configuration file taking precedence somewhere in the repository. There was not.

The value might simply have been too long, since every other entry in that file was between four and forty characters. I cut the policy down to eighteen characters and deployed it. Still nothing.

Then I added a header with a name I had invented, gave it the value "diagnostic", and deployed that.

It did not serve either.

That was the answer, and it was not the one I had been looking for. The five headers that were arriving had not come from the file at all. They were being served from settings the host had captured months earlier and had held ever since. The service was standalone rather than linked to the configuration file, and a standalone service does not read that file. It does not read it and fail. It does not read it and warn. It serves whatever it was last given, and the file stays in the repository, still correct, still read by nothing.

The host did nothing wrong here. A standalone service is a legitimate thing to have, and I still do not know whether this one was created that way or detached later. What the arrangement produces, though, is a document that goes on parsing cleanly, goes on passing review, goes on being cited in two other documents as the authority for a control, and is read by nothing.

I should be honest about my own part in this, because I made the same mistake in miniature while I was chasing it.

Before I found the cause, I decided the policy needed rewriting, and I inventoried what the live site actually loaded so I could write one against real evidence rather than a template. The policy I produced was worse than the one already in the repository. It was looser where it should have been strict, and it dropped the booking widget's host entirely, because that widget loads after the page does and my inventory only read the markup the server sent. I had said out loud, before running the scan, that the method would miss anything loaded at runtime. It missed it anyway. Had I shipped my version, the booking page would have quietly stopped working, and I would have been confident I had improved things.

I checked what I could see, and treated what I could see as the whole picture. That is the same failure I was investigating, one layer up.

Nobody involved in the original work was careless. The header was written by someone who understood what it was for. It was committed. It was reviewed. It was documented in two places, accurately, including the full policy string. Every one of those is advice I would give without hesitation, and not one of them would have caught this, because every one of them reads the same file.

Cyber insurance proposal forms ask whether a control is in place. Somebody has to answer, and they answer from what is available to them, which is the configuration and the documentation. In this case a careful person, reading everything they had, would have answered yes. They would have been wrong for two months and there would have been nothing in front of them to suggest it.

The gap that matters is not between honest answers and dishonest ones. It is between attested and observed. A configuration file describes an intention. A response header is a fact, and the two agree less often than anyone expects.

I still don't know when the service stopped reading its own configuration, or why. Nothing in the repository records it. That is the part I keep coming back to, because whatever happened, it happened once, quietly, and everything downstream went on agreeing with itself for two months afterwards.

Back to Writing