DEV Community

jaryn
jaryn

Posted on

Build a HAR Allowlist Fixture Before a Model Sees Your Session Cookie

A login loop will not die. You open DevTools, hit Export HAR, and you are one paste away from asking a coding model why Set-Cookie never sticks.

Count the headers first. Do you see Cookie? An SSO JWT? A WAF challenge cookie? A leftover Authorization: Bearer from an API tab you forgot to close?

That blob is not a stack trace. It is a session jar. The moment it crosses an inference boundary you do not operate, you have reproduced a trust-boundary violation without writing an exploit.

I treat everything below as a local regression fixture, not as evidence of a named production breach. You should too.

The invariant

No Cookie, Set-Cookie, or Authorization header may leave the workstation toward a model API.

Not in the prompt. Not in a tool result. Not in a "please explain this HAR" upload. Would you email that file to a vendor alias you created five minutes ago? If the answer is no, a free model session is not a safer alias.

Agents make the failure quieter. They assume the debug blob is context. They will resend it. Your gate has to sit in front of the tool, not inside the model's manners.

Trust boundaries, drawn once

[Browser]
   |  HTTPS + cookies (this hop is supposed to carry them)
[Origin / IdP / WAF]
   |
[Laptop DevTools] -- HAR export
   |
   +-- allowlist redactor --> [model API or a server you can grep]
   +-- raw paste -----------> [remote prompt logs, GPU operator, backups]
Enter fullscreen mode Exit fullscreen mode

Three boundaries matter.

  1. Browser to origin: cookies are supposed to travel here.
  2. Laptop to model: cookies are not supposed to travel here.
  3. Model operator to their storage: you cannot unsay a prompt.

A free remote model and a free self-hosted server sit on opposite sides of boundary 3. They do not change boundary 2. The redactor still belongs on the laptop.

What not to send

I keep a deny list next to the fixture. It is boring on purpose. Boring is the point.

Field Fixture example Send to a model?
Request Cookie session=FIXTURE_NOT_REAL; idp=eyJhbGciOiJub25lIn0.e30. Never
Response Set-Cookie session=...; HttpOnly; Secure; SameSite=Lax Never
Authorization Bearer FIXTURE_NOT_REAL Never
X-Api-Key / X-CSRF-Token rotation material Never
Query token / code / access_token OAuth leftovers Never
POST password / otp / client_secret login form Never
Internal Host idp.corp.internal Strip or rewrite
Location to a private IP https://10.0.0.8/callback Strip
Public path, status, timing POST /login302, 180ms Yes, after redaction

Positive fixture: a HAR that still contains Cookie must fail the gate. Negative fixture: the same HAR after redaction must pass, and the leftover public paths must still be readable.

If both fixtures do not exist, you do not have a control. You have a speech.

Reproduce the leak with a minimal HAR

Save this as fixtures/login-loop.har. Synthetic on purpose. Enough to fail.

{
  "log": {
    "version": "1.2",
    "creator": {"name": "fixture", "version": "0.0.1"},
    "entries": [
      {
        "startedDateTime": "2026-09-07T12:00:00.000Z",
        "time": 180,
        "request": {
          "method": "POST",
          "url": "https://app.example.com/login",
          "httpVersion": "HTTP/1.1",
          "headers": [
            {"name": "Host", "value": "app.example.com"},
            {"name": "Cookie", "value": "session=FIXTURE_NOT_REAL; waf_challenge=FIXTURE_NOT_REAL"},
            {"name": "Authorization", "value": "Bearer FIXTURE_NOT_REAL"}
          ],
          "queryString": [
            {"name": "next", "value": "/admin"},
            {"name": "code", "value": "oauth-code-FIXTURE"}
          ],
          "postData": {
            "mimeType": "application/x-www-form-urlencoded",
            "text": "username=alice&password=FIXTURE_NOT_REAL"
          },
          "headersSize": -1,
          "bodySize": 42
        },
        "response": {
          "status": 302,
          "statusText": "Found",
          "httpVersion": "HTTP/1.1",
          "headers": [
            {"name": "Set-Cookie", "value": "session=FIXTURE_NOT_REAL; HttpOnly; Secure"},
            {"name": "Location", "value": "https://10.0.0.8/callback"}
          ],
          "content": {"size": 0, "mimeType": "text/plain"},
          "headersSize": -1,
          "bodySize": 0
        }
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The leak is one command. No model required.

python3 -c "import json; h=json.load(open('fixtures/login-loop.har'));
print([x for e in h['log']['entries'] for x in e['request']['headers'] if x['name'].lower() in ('cookie','authorization')])"
Enter fullscreen mode Exit fullscreen mode

If that print still shows Cookie, you are holding production-adjacent material. Do not paste it. Do not zip it into a chat UI. Do not let an agent "attach the file."

Why is this worse than a pytest log leak? A HAR is a timeline. It includes the cookie the browser sent, the cookie the origin tried to set, the bearer token from another tab, and often the WAF challenge name. You are not leaking one secret. You are leaking the jar plus the topology.

A 70-line allowlist gate

Pinned for this fixture: Python 3.12, pytest 8.x, HAR 1.2. Label this unexecuted against any vendor. Run it on files you generated yourself.

# har_allowlist.py
from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Any

DENY_HEADERS = {
    "cookie",
    "set-cookie",
    "authorization",
    "proxy-authorization",
    "x-api-key",
    "x-csrf-token",
}
DENY_QUERY = {"token", "code", "access_token", "id_token", "refresh_token"}
SECRET_BODY = re.compile(
    r"(password|passwd|otp|client_secret|refresh_token)\s*=", re.I
)
PRIVATE_HOST = re.compile(
    r"(^10\.)|(^192\.168\.)|(^172\.(1[6-9]|2\d|3[0-1])\.)|\.internal$|\.corp$",
    re.I,
)

class HarAllowlistError(ValueError):
    pass


def _headers(obj: dict[str, Any]) -> list[dict[str, str]]:
    return obj.get("headers") or []


def findings(har: dict[str, Any]) -> list[str]:
    hits: list[str] = []
    for i, entry in enumerate(har.get("log", {}).get("entries", [])):
        req, resp = entry.get("request", {}), entry.get("response", {})
        for block_name, block in (("request", req), ("response", resp)):
            for header in _headers(block):
                if header.get("name", "").lower() in DENY_HEADERS:
                    hits.append(f"entry[{i}].{block_name}.headers.{header['name']}")
        for q in req.get("queryString") or []:
            if q.get("name", "").lower() in DENY_QUERY:
                hits.append(f"entry[{i}].request.query.{q['name']}")
        text = (req.get("postData") or {}).get("text") or ""
        if SECRET_BODY.search(text):
            hits.append(f"entry[{i}].request.postData")
        for header in _headers(req) + _headers(resp):
            if header.get("name", "").lower() == "host" and PRIVATE_HOST.search(header.get("value", "")):
                hits.append(f"entry[{i}].host")
            if header.get("name", "").lower() == "location" and PRIVATE_HOST.search(header.get("value", "")):
                hits.append(f"entry[{i}].location")
    return hits


def assert_safe(path: Path) -> None:
    har = json.loads(path.read_text())
    hits = findings(har)
    if hits:
        raise HarAllowlistError("unsafe HAR fields: " + ", ".join(hits))


def redact(har: dict[str, Any]) -> dict[str, Any]:
    clone = json.loads(json.dumps(har))
    for entry in clone.get("log", {}).get("entries", []):
        for block in (entry.get("request", {}), entry.get("response", {})):
            block["headers"] = [
                h for h in _headers(block)
                if h.get("name", "").lower() not in DENY_HEADERS
                and not (
                    h.get("name", "").lower() in {"host", "location"}
                    and PRIVATE_HOST.search(h.get("value", ""))
                )
            ]
        req = entry.get("request", {})
        req["queryString"] = [
            q for q in (req.get("queryString") or [])
            if q.get("name", "").lower() not in DENY_QUERY
        ]
        if SECRET_BODY.search((req.get("postData") or {}).get("text") or ""):
            req["postData"] = {"mimeType": "text/plain", "text": "[redacted]"}
    return clone
Enter fullscreen mode Exit fullscreen mode
# tests/test_har_allowlist.py
import json
from pathlib import Path

import pytest

from har_allowlist import HarAllowlistError, assert_safe, redact

RAW = Path("fixtures/login-loop.har")

def test_raw_fixture_must_fail():
    with pytest.raises(HarAllowlistError) as exc:
        assert_safe(RAW)
    msg = str(exc.value)
    assert "Cookie" in msg or "cookie" in msg.lower()
    assert "Authorization" in msg or "authorization" in msg.lower()

def test_redacted_fixture_must_pass(tmp_path: Path):
    redacted = redact(json.loads(RAW.read_text()))
    out = tmp_path / "login-loop.redacted.har"
    out.write_text(json.dumps(redacted))
    assert_safe(out)
    entry = redacted["log"]["entries"][0]
    assert entry["request"]["url"].endswith("/login")
    assert entry["response"]["status"] == 302
Enter fullscreen mode Exit fullscreen mode

Run it like this:

python3 -m venv .venv
source .venv/bin/activate
pip install 'pytest==8.3.3'
pytest -q tests/test_har_allowlist.py
Enter fullscreen mode Exit fullscreen mode

Expected failure evidence: the raw fixture fails. The redacted copy passes. If raw passes, your deny list is wrong. If redacted fails, you over-stripped and the model no longer has a public path to explain. That second failure is annoying. It is still safer than the first.

Need a tripwire you actually own? Inject a canary cookie into a throwaway browser profile, export a HAR, and confirm the gate catches it:

CANARY="mc_har_canary_$(openssl rand -hex 8)"
python3 - <<'PY'
import json, os, pathlib
p = pathlib.Path("fixtures/login-loop.har")
h = json.loads(p.read_text())
h["log"]["entries"][0]["request"]["headers"].append(
    {"name": "Cookie", "value": f"canary={os.environ['CANARY']}"}
)
pathlib.Path("fixtures/login-loop.canary.har").write_text(json.dumps(h))
PY
Enter fullscreen mode Exit fullscreen mode

If you operate the server that will see the prompt, grep for that canary later. If you do not operate the model, the canary is a tripwire you will never see fire. That is the tradeoff. Do not pretend otherwise.

Prevent / detect / recover

Layer Prevent Detect Recover
Laptop Allowlist gate before clipboard / file upload pytest on raw vs redacted fixtures Delete the chat if the vendor exposes a delete control; rotate the session anyway
Agent harness Tool schema rejects *.har unless assert_safe returns Log the sha256 of files the agent attached Revoke IdP sessions, rotate WAF challenge secrets if names leaked
Server you operate Drop prompts whose body matches Cookie: / Bearer ey Canary grep in prompt logs Wipe the log slice, rotate, re-run the fixture
Server you do not operate Do not send the raw HAR. Period. You cannot detect. Assume the jar is burned. Rotate.

Prevent is cheap. Detect on a third-party model is mostly fiction. Recover is cookie rotation plus IdP session revoke, not a polite follow-up prompt asking the model to forget.

Where a free model and a free server actually fit

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode is an open-source AI development platform. Operator-supplied, and relevant here: it offers free model access and a free server option. I do not need a catalog page to place them on the diagram. I need boundary 3.

If the HAR, after redaction, still needs a model to explain a redirect chain, a free-model path sends the allowlisted remainder to a remote inference API. A free-server path keeps that remainder on a machine you can grep. Neither path gets a cookie. If your workflow cannot guarantee that, do not use either path.

I will not invent quotas, model names, hardware, or how long a free tier lasts. The control is the fixture, not the pricing row. If you evaluate that platform, run the allowlist against a synthetic HAR before the first prompt. That is the only product-adjacent ask in this article.

Tradeoff, stated plainly: remote free-model access is fine for public paths and status codes you already decided were publishable. A free server is useful when you want prompt-log grep and a canary you can actually hunt. Self-hosting does not bless a raw HAR. It only changes who can read the mistake.

Limitations, and who should not use this

This redactor is a regression fixture. It is not DLP. It will miss a secret in a JSON field named note. It will miss a JWT stuffed into ?next=. It will miss multipart bodies. It will miss a screenshot of the Application panel. It will miss a source map that still embeds an internal absolute URL.

Do not use this approach if you handle regulated PHI, cardholder data, or government sessions that are unclassified-but-sensitive. Those workloads need a blocked clipboard path and a managed browser, not a Python script in a gist.

Do not use a free model or a free server as a dumping ground because "the HAR is too big to read." Size is not a security classification. Do not store raw HARs in the same repo as the fixture "for realism." The raw file is the positive test. Keep it synthetic.

And do not outsource the classification to the model. "Please ignore cookies in this file" is not a control. The model is the destination. Destinations do not enforce your invariant.

Boundary question

Which invariant belongs in CI, and which layer should enforce it?

The pytest gate belongs in CI for any repo that stores HAR fixtures, support bundles, or "repro steps" markdown. The enforcement layer is the laptop pre-prompt hook — or the agent file-tool wrapper — not the model provider's terms of service.

If you cannot point to the failing raw fixture and the passing redacted one, you do not have an invariant. You have a hope. Want to paste the HAR anyway? Run the gate first. Then decide whether the leftover public paths are even worth a model.

Top comments (0)