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
Cookieheader and asession.CookieJar.add_cookie_headerdoes nothing when aCookieheader 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.injectmay not setCookie,AuthorizationorProxy-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 aconfirmedfinding 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:
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 |
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:
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>}.
| 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"}}
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:
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:
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:
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 |
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:
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.