"""session-tokens-stolen-cookie-flags-rotation.py — two offline checks for session cookies you own. 1. --cookies FILE audits Set-Cookie header lines you captured from your OWN site (for example with `curl -sSI https://your-site/` saved to a file) against the requirements in NIST SP 800-63B-4 Sec. 5.1, the OWASP Session Management Cheat Sheet, and the cookie-prefix rules in draft-ietf-httpbis-rfc6265bis-22. It reports, per cookie, which requirements the header does not meet, with the source of each rule. 2. --model computes an upper bound on how long a stolen session cookie stays usable under a given policy: an overall reauthentication (absolute) timeout, session-ID rotation with replay detection, an out-of-band detection-and-revocation time, or a device-bound short-lived cookie. An inactivity (idle) timeout is recorded but never treated as a bound, because an attacker holding the cookie generates activity and keeps the session warm; that is the OWASP cheat sheet's own caveat. The script makes no network connections, sends nothing anywhere, and only reads the files you name. It parses headers and does arithmetic; it does not test, contact, or authenticate to any service. Inputs: --cookies FILE a text file of Set-Cookie lines (the "Set-Cookie:" prefix is optional) --model run the exposure model over the built-in scenarios --scenarios CSV optional: your own scenarios, columns scenario,absolute_minutes,idle_minutes,rotation_minutes, victim_return_minutes,detection_minutes,binding,cookie_ttl_minutes --out CSV write the model rows to CSV --chart PNG draw the exposure chart (needs matplotlib; tested with 3.10.9) Run: python code/session-tokens-stolen-cookie-flags-rotation.py --cookies my-headers.txt python code/session-tokens-stolen-cookie-flags-rotation.py --model \ --out datasets/session-tokens-stolen-cookie-flags-rotation.csv \ --chart content/images/session-tokens-stolen-cookie-flags-rotation/exposure-window.png Standard library only (Python 3.9+); matplotlib is needed for --chart alone. """ from __future__ import annotations import argparse import csv import datetime import math import re import sys DAY = 1440 # ---------------------------------------------------------------- rule set --- # Each rule: (id, level, requirement text, source tag). "SHALL" and "SHOULD" are quoted from the # documents named in SOURCES below; nothing here is invented. SOURCES = { "63B-5.1": "NIST SP 800-63B-4 Sec. 5.1 (Session Bindings), browser cookie requirements", "63B-5.2": "NIST SP 800-63B-4 Sec. 5.2 (Reauthentication)", "OWASP-SM": "OWASP Session Management Cheat Sheet", "6265bis-4.1.3.2": "draft-ietf-httpbis-rfc6265bis-22 Sec. 4.1.3.2 (__Host- prefix)", "6265bis-4.1.2.3": "draft-ietf-httpbis-rfc6265bis-22 Sec. 4.1.2.3 (Domain attribute)", } # A cookie value shorter than this cannot carry 64 bits even at 6 bits per character (base64url). MIN_VALUE_CHARS = math.ceil(64 / 6) # 11 MIN_HEX_CHARS = 16 # OWASP: 16 hex characters for 64 bits def parse_set_cookie(line: str) -> dict: """Parse one Set-Cookie header line into {name, value, attrs}. attrs keys are lowercased.""" line = line.strip() if not line: return {} if line.lower().startswith("set-cookie:"): line = line.split(":", 1)[1].strip() parts = [p.strip() for p in line.split(";")] if not parts or "=" not in parts[0]: return {} name, value = parts[0].split("=", 1) attrs = {} for p in parts[1:]: if not p: continue if "=" in p: k, v = p.split("=", 1) attrs[k.strip().lower()] = v.strip() else: attrs[p.strip().lower()] = True return {"name": name.strip(), "value": value.strip(), "attrs": attrs} def max_age_minutes(attrs: dict): """Minutes of persistence, or None for a non-persistent (session) cookie.""" if "max-age" in attrs and attrs["max-age"] is not True: try: return max(0, int(attrs["max-age"])) / 60.0 except ValueError: return None if "expires" in attrs and attrs["expires"] is not True: for fmt in ("%a, %d %b %Y %H:%M:%S GMT", "%a, %d-%b-%Y %H:%M:%S GMT"): try: exp = datetime.datetime.strptime(attrs["expires"], fmt).replace( tzinfo=datetime.timezone.utc) except ValueError: continue now = datetime.datetime.now(datetime.timezone.utc) return max(0.0, (exp - now).total_seconds() / 60.0) return None def audit_cookie(c: dict) -> list: """Return a list of (level, rule, detail, source_tag) findings for one parsed cookie.""" name, value, a = c["name"], c["value"], c["attrs"] out = [] secure = a.get("secure") is True httponly = a.get("httponly") is True samesite = str(a.get("samesite", "")).lower() if a.get("samesite") is not True else "" domain = a.get("domain") path = a.get("path") if not secure: out.append(("SHALL", "Secure missing", "cookies for session maintenance SHALL be accessible only on HTTPS sessions", "63B-5.1")) if not httponly: out.append(("SHOULD", "HttpOnly missing", "SHOULD be tagged as inaccessible via JavaScript, which removes the easiest " "script-side read of the session ID", "63B-5.1")) if not samesite: out.append(("SHOULD", "SameSite not set", "SHOULD set SameSite=Lax or SameSite=Strict rather than relying on the " "browser default", "63B-5.1")) elif samesite == "none" and not secure: out.append(("SHALL", "SameSite=None without Secure", "SameSite=None is only meaningful with Secure, and browsers reject the pair " "without it", "OWASP-SM")) if name.startswith("__Host-"): if not secure or domain is not None or path != "/": out.append(("SHALL", "__Host- prefix contract not met", "a __Host- cookie must be set with Secure, no Domain attribute, and Path=/", "6265bis-4.1.3.2")) else: out.append(("SHOULD", "no __Host- prefix", "session cookies SHOULD have the '__Host-' prefix and set Path=/ so the browser " "itself refuses a subdomain-scoped or non-Secure replacement", "63B-5.1")) if domain is not None: out.append(("SHOULD", f"Domain={domain} widens the scope", "a Domain attribute sends the cookie to that host and all of its subdomains; " "omitting it keeps the cookie on the origin server", "6265bis-4.1.2.3")) if path is not None and path != "/" and name.startswith("__Host-"): out.append(("SHALL", f"Path={path} with a __Host- name", "the __Host- prefix requires Path=/", "6265bis-4.1.3.2")) persist = max_age_minutes(a) if persist is not None: detail = f"persistent for {persist:,.0f} minutes ({persist / 60:,.1f} h)" level = "SHOULD" if persist > DAY: detail += "; longer than the 24 h overall reauthentication timeout SP 800-63B-4 " \ "SHOULD apply at AAL2" level = "REVIEW" out.append((level, "persistent cookie", detail + "; OWASP recommends non-persistent " "cookies for session management", "63B-5.2")) hexish = re.fullmatch(r"[0-9a-fA-F]+", value or "") if hexish and len(value) < MIN_HEX_CHARS: out.append(("REVIEW", "value too short for 64 bits", f"{len(value)} hex characters; 16 are needed for 64 bits of entropy", "OWASP-SM")) elif not hexish and len(value) < MIN_VALUE_CHARS: out.append(("REVIEW", "value too short for 64 bits", f"{len(value)} characters cannot encode 64 bits even at 6 bits per character", "OWASP-SM")) if "@" in value or re.search(r"(?i)(user|email|name)=", value): out.append(("SHALL", "value may carry cleartext personal information", "a session cookie SHOULD contain only an opaque string and SHALL NOT contain " "cleartext personal information", "63B-5.1")) return out def run_audit(path: str) -> int: with open(path, encoding="utf-8") as fh: lines = [ln for ln in fh if ln.strip() and "=" in ln] cookies = [c for c in (parse_set_cookie(ln) for ln in lines) if c] if not cookies: print(f"no Set-Cookie lines found in {path}", file=sys.stderr) return 1 worst = 0 for c in cookies: findings = audit_cookie(c) head = f"{c['name']} ({len(c['value'])}-char value)" print(f"\n{head}\n{'-' * len(head)}") if not findings: print(" meets every rule in this script") for level, rule, detail, tag in findings: worst = max(worst, {"REVIEW": 1, "SHOULD": 2, "SHALL": 3}[level]) print(f" [{level:<6}] {rule}\n {detail}\n source: {SOURCES[tag]}") print(f"\n{len(cookies)} cookie(s) audited; highest finding level: " f"{['none', 'REVIEW', 'SHOULD', 'SHALL'][worst]}") return 0 # ----------------------------------------------------------------- model --- FIELDS = ["scenario", "absolute_minutes", "idle_minutes", "rotation_minutes", "victim_return_minutes", "detection_minutes", "binding", "cookie_ttl_minutes", "bounding_control", "usable_minutes", "usable_hours", "source_tag", "generated_at"] # persistent-cookie ceiling used when nothing else bounds the session: a 30-day Max-Age. NO_BOUND_MINUTES = 30 * DAY SCENARIOS = [ # name, absolute, idle, rotation, victim_return, detection, binding, cookie_ttl, source tag ("Idle timeout only, 30-day persistent cookie", None, 30, None, None, None, "none", None, "OWASP-SM"), ("AAL1 overall timeout (30 days)", 30 * DAY, None, None, None, None, "none", None, "63B-5.2"), ("AAL2 overall timeout (24 h), 1 h inactivity", DAY, 60, None, None, None, "none", None, "63B-5.2"), ("AAL3 overall timeout (12 h), 15 min inactivity", 720, 15, None, None, None, "none", None, "63B-5.2"), ("Office-day absolute timeout (8 h)", 480, 30, None, None, None, "none", None, "OWASP-SM"), ("AAL2 plus rotation with replay detection, user returns within 1 h", DAY, 60, 10, 60, None, "none", None, "OWASP-SM"), ("AAL2 plus an alert acted on within 30 min", DAY, 60, None, None, 30, "none", None, "63B-5.1"), ("Device-bound session, 10-minute refresh cookie", DAY, 60, None, None, None, "device", 10, "63B-5.1"), ] def model_row(name, absolute, idle, rotation, victim_return, detection, binding, cookie_ttl, tag): bounds = {} if binding == "device" and cookie_ttl: bounds["device-bound cookie lifetime"] = float(cookie_ttl) if absolute: bounds["overall reauthentication timeout"] = float(absolute) if rotation and victim_return: bounds["rotation with replay detection"] = float(victim_return) if detection: bounds["out-of-band detection and revocation"] = float(detection) if not bounds: bounds["cookie Max-Age (nothing else bounds it)"] = float(NO_BOUND_MINUTES) control = min(bounds, key=lambda k: bounds[k]) usable = bounds[control] return {"scenario": name, "absolute_minutes": absolute or "", "idle_minutes": idle or "", "rotation_minutes": rotation or "", "victim_return_minutes": victim_return or "", "detection_minutes": detection or "", "binding": binding, "cookie_ttl_minutes": cookie_ttl or "", "bounding_control": control, "usable_minutes": int(usable), "usable_hours": round(usable / 60, 2), "source_tag": tag, "generated_at": datetime.date.today().isoformat()} def read_scenarios(path): if not path: return SCENARIOS def num(v): v = (v or "").strip() return int(v) if v else None with open(path, newline="", encoding="utf-8") as fh: return [(r["scenario"], num(r.get("absolute_minutes")), num(r.get("idle_minutes")), num(r.get("rotation_minutes")), num(r.get("victim_return_minutes")), num(r.get("detection_minutes")), (r.get("binding") or "none").strip(), num(r.get("cookie_ttl_minutes")), (r.get("source_tag") or "").strip()) for r in csv.DictReader(fh)] def run_model(scenarios): rows = [model_row(*s) for s in scenarios] print(f"\n{'configuration':<62} {'usable':>10} bounded by") for r in rows: print(f"{r['scenario']:<62} {pretty_minutes(r['usable_minutes']):>10} {r['bounding_control']}") best, worst = min(rows, key=lambda r: r["usable_minutes"]), max(rows, key=lambda r: r["usable_minutes"]) print(f"\nwidest window {worst['usable_minutes']:,} minutes; narrowest {best['usable_minutes']:,} " f"minutes; ratio {worst['usable_minutes'] / best['usable_minutes']:,.0f}x") print("note: an inactivity timeout is listed but never bounds the window, because a holder of " "the cookie generates activity") return rows def pretty_minutes(mins: int) -> str: if mins < 120: return f"{mins} min" if mins < DAY: return f"{mins / 60:,.0f} h" if mins == DAY: return "1 day" return f"{mins / DAY:,.0f} days" def chart(rows, path): """Horizontal log-scale bar chart of the usable window. Needs matplotlib (tested with 3.10.9).""" import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt rows = sorted(rows, key=lambda r: -r["usable_minutes"]) labels = [r["scenario"] for r in rows] values = [max(1, r["usable_minutes"]) for r in rows] surface, ink, ink2, muted, grid, axis, bar = ("#fcfcfb", "#0b0b0b", "#52514e", "#898781", "#e1e0d9", "#c3c2b7", "#2a78d6") fig, ax = plt.subplots(figsize=(9.6, 0.46 * len(rows) + 1.8), dpi=110) fig.patch.set_facecolor(surface) ax.set_facecolor(surface) ax.barh(range(len(rows)), values, height=0.55, color=bar, zorder=3) ax.set_xscale("log") ax.set_yticks(range(len(rows))) ax.set_yticklabels(labels, color=ink, fontsize=9) ax.invert_yaxis() ax.set_xlim(1, max(values) * 8) ax.set_xticks([1, 10, 60, DAY, NO_BOUND_MINUTES]) ax.set_xticklabels(["1 min", "10 min", "1 h", "1 day", "30 days"], color=muted, fontsize=9) ax.minorticks_off() ax.tick_params(axis="x", colors=muted, length=0) ax.tick_params(axis="y", length=0) ax.grid(axis="x", color=grid, linewidth=0.8, zorder=0) for side in ("top", "right", "bottom"): ax.spines[side].set_visible(False) ax.spines["left"].set_color(axis) for i, r in enumerate(rows): ax.text(values[i] * 1.2, i, pretty_minutes(r["usable_minutes"]), va="center", ha="left", fontsize=9, color=ink2) ax.set_xlabel("longest window in which a stolen session cookie still works (log scale)", color=muted, fontsize=9) fig.text(0.01, 0.985, "How long a stolen session cookie keeps working, by policy", ha="left", va="top", fontsize=12.5, color=ink) fig.text(0.01, 0.94, "Upper bounds computed by this script; an inactivity timeout is never the " "bound, because whoever holds the cookie stays active.", ha="left", va="top", fontsize=9, color=ink2) fig.tight_layout(rect=(0, 0, 1, 0.9)) fig.savefig(path, facecolor=surface) print(f"wrote chart to {path}", file=sys.stderr) def main(): ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) ap.add_argument("--cookies", help="file of Set-Cookie header lines captured from your own site") ap.add_argument("--model", action="store_true", help="run the exposure-window model") ap.add_argument("--scenarios", help="optional CSV of your own scenarios") ap.add_argument("--out", help="CSV path for the model rows") ap.add_argument("--chart", help="PNG path for the exposure chart; requires matplotlib") args = ap.parse_args() if not args.cookies and not args.model: ap.error("give --cookies FILE, --model, or both") rc = 0 if args.cookies: rc = run_audit(args.cookies) if args.model: rows = run_model(read_scenarios(args.scenarios)) if args.out: with open(args.out, "w", newline="", encoding="utf-8") as fh: w = csv.DictWriter(fh, fieldnames=FIELDS) w.writeheader() w.writerows(rows) print(f"wrote {len(rows)} rows to {args.out}", file=sys.stderr) if args.chart: chart(rows, args.chart) return rc if __name__ == "__main__": sys.exit(main())