DEV Community

jaryn
jaryn

Posted on

Audit a Browser HAR Before a Model Sees the Session You Captured

The login form spun. 401, then 302, then 401 again. I opened DevTools, reproduced it once, and hit Export HAR. The file landed on my desktop as login.har. Forty-seven requests. One of them was the IdP token endpoint. I was one paste away from asking a model why the cookie refused to stick.

Would you have opened the file first?

I did not, not the first time I wired this fixture. The HAR held Set-Cookie, an Authorization: Bearer header, and a refresh token in a JSON body. That is not a stack trace. That is a credential export sitting on the wrong side of a trust boundary.

The failing sequence

Here is the event chain I keep reproducing in a lab folder. No production tenant. No real IdP. Just a file that looks like the one you almost pasted.

  1. Browser records a login redirect.
  2. DevTools writes HTTP Archive 1.2 to disk.
  3. You copy the HAR, or a “sanitized” excerpt, into a model prompt.
  4. The model provider now holds the session cookie, the bearer, and often the refresh token.
  5. You rotate nothing, because it “was only debugging.”

Sound familiar? The failure is not that the model is clever. The failure is that a HAR is a full-fidelity wire dump, and dump formats do not care about your intent.

Trust boundaries, not vibes

A HAR crosses four boundaries I actually care about.

Layer What it holds Who can read it after a paste
Browser process Live cookies, in-flight POSTs You, extensions, the OS
Workstation disk The .har file, editor swap, shell history Backup agents, sync clients, the next intern
Clipboard / chat UI Whatever you selected Screenshot tools, paste logs
Remote model API Prompt + attachments + provider logs The vendor, operators, whoever they retain

The invariant I want is boring. No Cookie, Set-Cookie, Authorization, OAuth token, or password field may leave the workstation toward a model. If that sentence makes you flinch, good. That is the point.

What must not go to a model? Session cookies. Bearer tokens. Refresh tokens. client_secret. Authorization codes in query strings. Password fields in postData. CSRF tokens tied to a live session. Anything that lets the recipient become you.

What can go? Status codes. Hostnames you already publish. Redirect hop counts. A redacted method/path list. Timing. A synthetic reproduction script. That is usually enough to debug a 401.

Minimal fixtures (pinned, synthetic)

Pinned for this write-up: HTTP Archive 1.2, Python 3.12 stdlib only, jq 1.7 if you want a one-liner. The hosts are .test. The secrets are canaries. This is a regression fixture, not a claim that I found a live leak.

Negative fixture — should pass the gate. Static asset, no credentials:

{
  "log": {
    "version": "1.2",
    "entries": [
      {
        "request": {
          "method": "GET",
          "url": "https://app.example.test/assets/app.css",
          "headers": [{"name": "Accept", "value": "text/css"}],
          "queryString": [],
          "postData": {"text": ""}
        },
        "response": {
          "status": 200,
          "headers": [{"name": "Content-Type", "value": "text/css"}],
          "content": {"text": "body{margin:0}"}
        }
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Positive fixture — must fail the gate. One login hop, three credential classes:

{
  "log": {
    "version": "1.2",
    "entries": [
      {
        "request": {
          "method": "POST",
          "url": "https://idp.example.test/oauth/token?code=AUTHCODECANARY",
          "headers": [
            {"name": "Authorization", "value": "Bearer eyJhbGciOiJub25lIn0.CANARY"},
            {"name": "Cookie", "value": "session=SESSCANARY"}
          ],
          "queryString": [{"name": "code", "value": "AUTHCODECANARY"}],
          "postData": {"text": "grant_type=authorization_code&client_secret=SECRETCANARY"}
        },
        "response": {
          "status": 200,
          "headers": [
            {"name": "Set-Cookie", "value": "session=SESSCANARY; HttpOnly; Secure"}
          ],
          "content": {"text": "{\"refresh_token\":\"REFRESHCANARY\"}"}
        }
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Save them as fixtures/har_clean.har and fixtures/har_session.har. If your gate cannot tell them apart, you do not have a gate.

A workstation audit you can run

Label: this is a local fixture script, not a scanner I ran against anyone’s production HAR.

#!/usr/bin/env python3
"""Fail closed if a HAR still carries session material. Python 3.12."""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path
from urllib.parse import urlparse, parse_qs

SENSITIVE_HEADERS = {
    "authorization",
    "proxy-authorization",
    "cookie",
    "set-cookie",
    "x-api-key",
    "x-auth-token",
}
SENSITIVE_PARAMS = {
    "access_token",
    "id_token",
    "refresh_token",
    "token",
    "password",
    "passwd",
    "code",
    "client_secret",
    "client_id",
    "api_key",
    "session",
}
BODY_RE = re.compile(
    r"(password|passwd|refresh_token|access_token|client_secret|id_token|session)=",
    re.I,
)

def _headers(obj: dict) -> list[tuple[str, str]]:
    out = []
    for h in obj.get("headers") or []:
        out.append((str(h.get("name", "")), str(h.get("value", ""))))
    return out

def audit(path: Path) -> list[str]:
    har = json.loads(path.read_text(encoding="utf-8"))
    hits: list[str] = []
    for i, entry in enumerate(har.get("log", {}).get("entries", []), start=1):
        req, resp = entry.get("request") or {}, entry.get("response") or {}
        url = req.get("url") or ""
        parsed = urlparse(url)
        qs = parse_qs(parsed.query, keep_blank_values=True)
        for name, values in qs.items():
            if name.lower() in SENSITIVE_PARAMS:
                hits.append(f"entry {i}: query {name} on {parsed.hostname}{parsed.path}")
        for collection, label in ((req, "req"), (resp, "resp")):
            for name, value in _headers(collection):
                if name.lower() in SENSITIVE_HEADERS and value.strip():
                    hits.append(f"entry {i}: {label} header {name}")
        for blob in (
            (req.get("postData") or {}).get("text") or "",
            (resp.get("content") or {}).get("text") or "",
        ):
            if BODY_RE.search(blob):
                hits.append(f"entry {i}: body matched credential pattern")
            try:
                data = json.loads(blob) if blob.strip().startswith("{") else {}
            except json.JSONDecodeError:
                data = {}
            if isinstance(data, dict):
                for key in data:
                    if str(key).lower() in SENSITIVE_PARAMS:
                        hits.append(f"entry {i}: json key {key}")
    return hits

def main() -> int:
    if len(sys.argv) != 2:
        print("usage: har_audit.py <file.har>", file=sys.stderr)
        return 2
    path = Path(sys.argv[1])
    hits = audit(path)
    if hits:
        print("FAIL: HAR still carries session material")
        for h in hits:
            print(f"  - {h}")
        return 1
    print("PASS: no session headers, token params, or password fields")
    return 0

if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Expected fixture output, local and synthetic:

$ python3 har_audit.py fixtures/har_clean.har
PASS: no session headers, token params, or password fields

$ python3 har_audit.py fixtures/har_session.har; echo exit:$?
FAIL: HAR still carries session material
  - entry 1: query code on idp.example.test/oauth/token
  - entry 1: req header Authorization
  - entry 1: req header Cookie
  - entry 1: resp header Set-Cookie
  - entry 1: body matched credential pattern
  - entry 1: json key refresh_token
exit:1
Enter fullscreen mode Exit fullscreen mode

If you only have jq, this is the header half. It is not complete. It is a tripwire.

jq -r '
  .log.entries[]
  | . as $e
  | (
      ($e.request.headers[]? | select(.name|ascii_downcase|test("cookie|authorization|x-api-key"))),
      ($e.response.headers[]? | select(.name|ascii_downcase|test("set-cookie")))
    )
  | "HIT \(.name)"
' fixtures/har_session.har
Enter fullscreen mode Exit fullscreen mode

A hit means stop. Do not “summarize the HAR for the model.” Summaries still leak the first 20 characters of a cookie, and those 20 characters are often enough.

Redact, then ask — never the reverse

I keep a drop folder the model is allowed to read. The HAR never goes there raw.

mkdir -p /tmp/model-inbox
python3 har_audit.py ./login.har && echo "refusing: gate must fail on a real login HAR"
Enter fullscreen mode Exit fullscreen mode

What I actually send after a fail is a skeleton I type by hand:

IdP: idp.example.test
App: app.example.test
Hop 1: GET /login -> 302 to /oauth/authorize
Hop 2: POST /oauth/token -> 200
Hop 3: GET /app -> 401 (cookie not sent on API origin)
Question: is this a Secure+Domain mismatch or a SameSite=Lax POST?
No cookies, no bearers, no codes.
Enter fullscreen mode Exit fullscreen mode

That is enough. If the model cannot debug a 401 without the cookie value, the model is not the bottleneck. Your reproduction is.

Prevent / detect / recover

Control Layer What it buys you What it does not
Do not export production HARs Process No credential file exists Useless if staging shares the IdP
har_audit.py on a pre-commit or drop folder Workstation / CI Fail closed before a model sees the file Will miss novel header names
Allowlist prompt inbox Agent harness Model tools cannot open() *.har Does not stop a human paste
Rotate session + refresh + client secret IdP Caps blast radius after a paste Too late if you already pasted
Provider retention review Legal / vendor You know the retention story Does not unsay a prompt

Prevent first. Detect in CI if .har files ever land in the repo. Recover by rotating, not by asking the model to “forget.”

I treat a self-hosted coding workspace as one more enforcement layer, not as magic. MonkeyCode is an open-source AI development platform I evaluate for that reason — keeping the model on my side of the HAR boundary, instead of shipping the capture to a public chat. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Operator-supplied for this piece: free model access and a free server option, which is relevant only after the gate is green. I still refuse to let the model vote on whether Set-Cookie is in scope.

Limitations, and who should not do this

This script is a deny-list. Deny-lists rot. A header named X-Session-Blob sails through. Multipart bodies, base64 cookies, and WebSocket frames inside a HAR are out of scope here. Redaction is not anonymization. Paths still identify tenants. Timestamps still identify people.

A local or self-hosted model does not erase the file. Prompts hit disk. Swap hits disk. Backups hit disk. If your threat is “a coworker’s laptop sync,” you need disk policy, not a smarter chatbot.

Who should not use this approach? Anyone whose plan is to HAR a production user and then “be careful.” Anyone debugging an incident that already needs legal hold — you do not run a hobby redactor over evidence. Anyone who thinks a passing gate means the HAR is safe to email. It is not. The gate only says the patterns I listed were absent.

Also skip this if you do not own the app. Exporting a session HAR from a site you do not control is how you accidentally steal someone else’s cookie. Including your own, in a shared browser profile.

What I put in CI versus what I enforce by hand

Numbered, because this is the only part that survives a week later:

  1. Pre-commit: reject *.har unless it lives under fixtures/ and passes har_audit.py.
  2. Agent allowlist: tools may read fixtures/ and /tmp/model-inbox, never ~/Downloads.
  3. Human rule: if DevTools can see Set-Cookie, the model cannot see the file.
  4. Recovery drill: rotate the canary session in the fixture repo on a schedule, so the rotate path is not theoretical.

The trend right now is to paste richer artifacts because models debug faster than we type. Faster is not a trust boundary. A HAR is a credential format that happens to end in .har.

So which invariant belongs in CI, and which layer should enforce it? I put “no session HAR in git or in the model inbox” in CI. I put “do not export production sessions” in the human layer, because no regex will save you from capturing the wrong browser profile. If you only pick one, pick the human rule. The script is there for the night you are tired and the 401 will not die.

Top comments (0)