Skip to content

Sessions and CSRF

A cookie-authenticated target cannot be probed with a static header. There is no token to paste into identities.owner.headers: the credential only exists after a login, it arrives as a Set-Cookie, and half the time a CSRF token has to travel with every request after that.

An identity may declare a session instead: an ordered login, the proof that it worked, and the tokens to carry forward.

{
  "identities": {
    "victim": {
      "session": {
        "login": [
          {"method": "GET", "path": "/login", "expect_status": [200],
           "capture": {"login_csrf": {"from": "html_meta", "name": "csrf-token"}}},
          {"method": "POST", "path": "/login",
           "headers": {"X-CSRFToken": "${capture.login_csrf}"},
           "body": "user=victim&password=${VICTIM_PASSWORD}",
           "expect_status": [200, 302]}
        ],
        "establishes": {"cookie": "sessionid"},
        "capture": {"csrf": {"from": "cookie", "name": "csrftoken"}},
        "inject": [{"header": "X-CSRFToken", "value": "${capture.csrf}"}],
        "reestablish": "run"
      },
      "marker": "vdr-Xk7Qm2Ls9Rt4Vb8Nc3Zp6Wf1"
    }
  }
}

login_csrf is the one-shot token scraped from the login page. csrf is the live cookie every later request injects. Two names, because they are two different values with two different lifetimes — and because one name would not load. That is the single most common mistake on this page and it is covered below.

Cookies never cross identities

Each identity gets its own cookie jar, built on http.cookiejar with a policy that accepts and returns cookies for exactly one host. The jar reaches a request as an argument, so isolation is a property of the call rather than of a registry someone could key wrongly, and there is no API anywhere in the tool that copies, unions, or seeds one jar from another.

Danger

This is the most important property in the feature, and both directions of getting it wrong manufacture a green run over a live vulnerability.

A leaked jar turns the attacker's probe into the owner's: the attacker reads the owner's data with the owner's session, the response looks exactly as the owner's would, and the boundary was never tested.

A lost session turns the same probe into an anonymous one: it draws a 401, and a 401 matches a declared denial. The check passes. Nothing was logged in.

Two consequences follow directly:

  • An identity may not declare both a static Cookie header and a session. CookieJar.add_cookie_header does nothing when a Cookie header is already present, so the session cookie would be silently dropped:
identity 'victim' declares both a static Cookie header and a session; the
session cookie would be silently dropped. Remove one.
  • session.inject may not set Cookie, Authorization or Proxy-Authorization. The jar and the identity's own static headers own the credential slots. A capture in one of them would let one identity borrow another's credential and fabricate a confirmed finding out of legitimate access.

A static Authorization header alongside a session is allowed — a bearer flow with a session cookie is a real shape — and the jar has no opinion about it.

login

An ordered list of one to four requests, sent as this identity before any check runs.

Field Type Required Notes
method HTTP method no defaults to GET; must be upper case
path absolute same-origin path yes
headers object no may reference ${capture.…}
json / body any / string no mutually exclusive
expect_status array of integers yes a bare integer is normalised to a list
capture object no see captures

expect_status is required on every step and has no default:

identity 'victim' session login step 0 requires 'expect_status' (a 302 login is
common; declare it)

The common real login is POST /login → 302, redirects are never followed, and the cookie is on the 302. A default of [200] would fail that login; a default of "any status" would let a failed login pass silently.

More than four steps is refused: identity 'victim' session declares 6 login steps; the limit is 4.

A login step may not fill Cookie, Authorization or Proxy-Authorization from a capture, for the same reason inject may not.

A login step is a request block: a key other than those above (or a _ annotation) is refused, params and a misspelt Headers included, and so is a Host header, in any case. Its path follows the same rules as a check's: no . or .. segment in any spelling, and nothing that becomes an absolute URL when joined (configuration reference).

establishes — the proof, not the assumption

establishes is required, and this is the field that makes "the session is up" a measured fact rather than something the tool assumed.

It names exactly one of:

Key Means
cookie a cookie of that name is present in this identity's jar and usable against this origin
capture that capture resolved to a non-empty value — for bearer flows
"establishes": {"cookie": "sessionid"}

The cookie form checks usability, not only presence. A Secure cookie over plain http fails here — at the login, naming the cookie — rather than three checks later as an unexplained 401.

The capture form must name a capture the session actually declares:

identity 'victim' session 'establishes' names capture 'nope', which the session
does not declare

Unknown keys inside establishes are refused rather than ignored, because "csrf" is a reasonable guess and would silently establish nothing:

identity 'victim' session 'establishes' has unsupported keys: csrf (expected one
of capture, cookie). A CSRF token is captured on a login step and applied with
'inject', not declared here.

What happens when a session does not establish

Every probe naming that identity reports inconclusive with VERIDOR-EXEC-001 and no request is issued. Its marker_probe is skipped and its control degrades to unverified. Its teardown entries are skipped and reported.

Degraded state is never allowed to look like a result. That is the whole point: an unauthenticated probe draws a denial, and a denial matches a declared denial.

Captures

A capture reads one value out of a response — or out of the jar — and makes it available as ${capture.<name>}.

"capture": {"login_csrf": {"from": "html_meta", "name": "csrf-token"}}
Field Required Notes
from yes one of cookie, header, json_pointer, html_meta
name for cookie, header, html_meta non-empty; for header, a valid header name
pointer for json_pointer an RFC 6901 pointer starting with /

Capture names must be identifiers — [A-Za-z_][A-Za-z0-9_]* — because they have to be spellable inside ${capture.<name>}. A hyphen is refused: identity 'victim' session capture names must be identifiers.

${capture.<name>} is deliberately dotted. The environment-substitution pattern ${NAME} cannot match a dot, so the two namespaces can never collide and no new escaping rule exists. A ${capture.…} outside a session is a load error rather than literal text on the wire.

Capture names are one flat namespace

Step-level and session-level captures share one namespace per identity, and it is single-assignment. Declaring the same name in both is a load error — exit 2, before any request — rather than a silent override:

"login": [
  {"method": "GET", "path": "/login", "expect_status": [200],
   "capture": {"csrf": {"from": "html_meta", "name": "csrf-token"}}}
],
"capture": {"csrf": {"from": "cookie", "name": "csrftoken"}}
veridor: identity 'victim' session declares capture 'csrf' more than once

This is why the example at the top of the page uses login_csrf and csrf. They are two different values with two different lifetimes, and collapsing them to one name does not pick a winner — it fails the load.

An override would be worse than the error. Whichever value won, half the requests would carry a token from the wrong lifetime, and the resulting 403 would match any declared denial. The failure would be invisible.

A reference to a name nothing declares is also refused: identity 'victim' session references undefined capture 'nope'.

Which sources are available where

Declared on May read Read when
a login step cookie, header, json_pointer, html_meta once, from that step's response, and frozen for the rest of the run
the session cookie only live, at send time — so a rotating CSRF cookie needs no logic at all

Declaring anything else at session level is refused:

identity 'victim' session capture 'csrf' 'from' must be one of cookie

A session-level capture is cookie-only because the jar is the only source that still holds a value after the login response is gone. A session capture is read live, at the moment each request is sent — that is what makes a rotating CSRF cookie work with no logic in the config at all. A header, a JSON pointer and an HTML meta tag are all properties of one response, and by the time a check probe is sent that response no longer exists. There is nothing live to read. So they belong on the login step that produced them, where they are captured once and frozen.

from Reads
cookie that identity's own jar, live at send time
header the named response header of the login step that declares it
json_pointer an RFC 6901 pointer into that step's response, in memory
html_meta <meta name="X" content="Y">, via the stdlib HTML parser

A header capture whose field name arrives twice with two different values resolves to nothing rather than to the first one: which one the target meant is unknowable, and guessing puts the wrong token on an authenticated request. A cookie that has expired, or that is Secure against a plain-http base URL, does not resolve either.

A captured value must be non-empty, at most 4,096 bytes, ASCII, and free of control characters and spaces — the value goes onto the wire in a header, where a \r is a second request. A value that fails any of those is a capture miss, and a capture miss means the request is not sent.

There is deliberately no regex source and no transform. A regex over a response body is an unreviewable mini-language inside a config whose reviewability is the product's premise, it is the one extractor form where a target-influenced body steers which bytes enter a later authenticated request, and re over target-controlled input is a backtracking denial of service in the CI runner.

inject

inject applies to every request issued as that identity once the session is established: check probes, marker probes, sequence steps, and teardown entries.

Each entry sets exactly one of three slots, plus a value:

Slot Effect
header sets a request header
form_field appends a field to a form-encoded body
json_field sets a top-level field on a JSON body
"inject": [
  {"header": "X-CSRFToken", "value": "${capture.csrf}"},
  {"form_field": "csrfmiddlewaretoken", "value": "${capture.csrf}"}
]

Setting two slots, or none, is refused:

identity 'victim' session inject 1 must set exactly one of 'header',
'form_field', or 'json_field'

Cookie, Authorization and Proxy-Authorization are refused as header names, and so is Host, in any case, because urllib would send it in place of the URL's host:

identity 'victim' session inject cannot set the 'Cookie' header; the session
cookie jar owns it

Warning

If a ${capture.…} reference cannot be resolved at send time, the request is not sent. It is not sent with the literal text, and it is not sent without the token.

An untokened request draws a 403, and a 403 that matches a declared denial is a false pass. Not sending is the only outcome that cannot be mistaken for a measurement.

reestablish

Value Means
run (default) log in once, at the start of the run
check replace the jar and log in again before each check and each sequence
"reestablish": "check"

Anything else: identity 'victim' session 'reestablish' must be one of check, run. Use check when the target invalidates a session on some other request in the suite, or rotates a session cookie per action.

A session without a login

login may be omitted when the session declares a capture, which covers a target where the credential already lives somewhere the jar can read. A session with neither is refused:

identity 'victim' session declares neither 'login' nor 'capture', so it
establishes nothing

Credentials never reach an artifact

Cookie values, captured tokens and Set-Cookie attributes are never placed in any object that is serialised, printed, or handed to SARIF. Not redacted on the way out — never put there in the first place.

The run document's sessions block records cookie names (sanitised), a count, capture state and declared source, and per-step status, size and digest. No hash, prefix, suffix, or length of an isolated secret is recorded: many CSRF-shaped tokens live in a guessable domain, and a hash of one makes offline confirmation trivial. A cookie the parser rejects is reported by a fixed string, because a cookie parse error can embed the cookie.

No message produced by session validation echoes a header value, a cookie value, or a capture value.

map refuses a session-backed identity

veridor map builds every probe from an identity's static headers. It has no cookie jar and does not log in. An identity that authenticates through a session would therefore be probed anonymously, and the anonymous reach recorded under its name — a blast radius smaller than the truth, which is the one direction this tool must not be wrong in.

map refuses the configuration before it opens a socket and tells you to use run, which does log in.

Session refusal index

Every entry exits 2, before any request is sent.

Refusal Cause
identity 'X' session requires 'establishes' naming the cookie or capture that proves login succeeded no establishes
identity 'X' session 'establishes' must set exactly one of 'cookie' or 'capture' neither, or both
identity 'X' session 'establishes' has unsupported keys: csrf anything other than cookie or capture
identity 'X' session 'establishes' names capture 'Y', which the session does not declare dangling reference
identity 'X' session declares capture 'Y' more than once same name on a step and on the session
identity 'X' session references undefined capture 'Y' ${capture.Y} with no declaration
identity 'X' session capture names must be identifiers a name outside [A-Za-z_][A-Za-z0-9_]*
identity 'X' session capture 'Y' 'from' must be one of cookie a non-cookie source at session level
identity 'X' session capture 'Y' requires an RFC 6901 'pointer' starting with '/' json_pointer with no pointer
identity 'X' session login step N requires 'expect_status' missing status declaration
identity 'X' session login step N cannot fill the 'Cookie' header from a capture a capture in a credential slot
identity 'X' session declares N login steps; the limit is 4 too many steps
identity 'X' session inject N must set exactly one of 'header', 'form_field', or 'json_field' zero or two slots
identity 'X' session inject cannot set the 'Cookie' header a credential slot in inject
identity 'X' session inject N sets a Host header Host, in any case, as an inject header
identity 'X' session login step N.headers sets a Host header a Host header on a login step
identity 'X' session login step N has unsupported keys: … any key a login step does not read, unless it starts with _
identity 'X' session 'reestablish' must be one of check, run unknown scope
identity 'X' session declares neither 'login' nor 'capture' a session that establishes nothing
identity 'X' declares both a static Cookie header and a session two cookie sources
identity 'X' session has unsupported keys: … a near-miss of a real key

Non-session refusals are listed in the configuration reference.