Back to Feed
article 3m read

Anti-framing rule put in a meta tag, where browsers won't honor it

The marketing site's framing protection lived in a meta tag the browser ignores for that rule, so it moved to real headers, and the API's new origin allowlist lets requests with no Origin through on purpose, for reasons written down next to the code.

Article DepthPractice

Flow

Checks for a change like mine

  1. 1Rules sent as headers

    Look at a real response from the live site, not the source files.

  2. 2Short source lists

    Name the hosts you load from instead of allowing any secure site.

  3. 3Three requests

    Your own site works, a stranger's site fails in the browser, and no Origin still hits the login check.

  4. 4One list of sites

    Keep the allowed sites in one place, or expect to edit two files each time.

Browsers won't obey a rule against being framed in a meta tag, and that's where the marketing site's rule went.

A security review of my telemetry product found several problems. Two of them: the API took requests from any site, and the web pages sent almost no safety headers. I have three parts: an API, an admin dashboard and a marketing site. I fixed all three in one go, and the first try at the marketing site was wrong.

A policy that showed up too late

The marketing site is plain files on a host. My code never touches its responses. So my first commit used a build step that wrote the safety rules into the page itself, as meta tags.

That looks like it stops other sites from framing my page. It doesn't. A browser reads only some rules from a meta tag, and "who may frame me" isn't one of them. The frame decision happens before the page is read, so the rule shows up too late. The old frame-blocking header doesn't work as a meta tag either. Probably the script rules were fine. The framing rule did nothing.

The build step even had a note saying production should set these on the server. It shipped anyway.

Moving it to the wire

The last commit deleted the build step and put the same rules in the host's header file. Now every response carries them as real headers. Images also narrowed from any secure site to the analytics hosts the page uses. A comment explains why the build step is gone.

The dashboard: widen, then narrow

The dashboard started strict. The middle commit loosened analytics to match the vendor's advice and kept risky eval scripts out of production. The last one cut images to a named list and dropped a loose allowance and a dead header. Inline scripts are still allowed, which is a weak spot.

The hole I left in the allowlist on purpose

The API used to accept every site, with cookies allowed. That's the bad combo. Now, in production, it only accepts my own sites, from a setting with defaults in code. Development stays open.

A request with no Origin header at all is let through. My command-line tool and my server jobs don't send one. They also have no browser cookies to steal. Browsers send Origin when a page calls another site. The real lock is the API key, not a cookie. And anything outside a browser can fake the header anyway.

If you build something like this, check three requests by hand:

  • one from your own site, which should work
  • one from a stranger's site, which should fail in the browser
  • one with no Origin, which should still hit your login check

That last branch is safe only while no important route logs you in with a cookie. The branch now has a long comment explaining why, for whoever adds the next route.

Two copies of one list

Adding my blog took one commit for the default in code and another for the deploy config. If production reads the config, the code list never runs. Next time I add a site, I'll be editing two files again.

Keep reading

Pick up a connected idea or branch into a nearby one.

5 paths forward