Authorize
Most access-control bugs are invisible from one session. You are logged in as an admin, /admin/users returns the list, and everything looks correct β because you never asked what the anonymous client gets, or the read-only user, or the tenant next door. Authorize asks: it takes a request you already captured, replays it under several identities, and compares each response against a baseline. An identity that is served what the baseline was served is a likely authorization bypass. It is gori's counterpart to Burp's Autorize / Auth Analyzer and to AuthMatrix.
The Authorize tab is hidden by default. Reveal it from the tab-bar β― menu, the command palette (Ctrl-P β Go to Authorize), or Preferences (Ctrl-,) β Network & Tabs β Tabs.
What an Identity Is
An identity is a session slot: a name, a static header overlay applied to the captured request before it is replayed, and the extract rules whose bound values belong to it. It is the same object everywhere in gori β the Authorize tab replays under every slot, and a Repeater or Fuzzer send goes out as the one that is active (see Session slots below). One list, one settings row, three surfaces.
| Field | Effect |
|---|---|
| set | Upsert a header: replace the value of every header of that name (case-insensitive, original casing kept), or append it when absent |
| remove | Drop every header of that name |
An "anonymous" identity removes Cookie and Authorization. An "admin" or "low-priv" identity sets them to another session's values. An API identity sets X-Api-Key. The overlay touches header lines only β the request line, the body, and Content-Length never move, so what you are comparing really is the same request under different credentials.
Press i on the tab to open the identities card. A fresh project starts with two: as-captured (no overlay at all) and anonymous (drops Cookie and Authorization).
| Key | In the identities card |
|---|---|
β / β |
Pick an identity |
a |
Add |
e / β΅ |
Edit |
d |
Delete |
b |
Make this one the baseline |
esc |
Close |
The add / edit form has three fields: a name (unique β two rows under one label would make the results table unreadable, and all three surfaces refuse a duplicate; names are compared case-insensitively), the headers to set, one Name: value per line, and the headers to remove, comma-separated. β₯ moves between fields, β΅ saves. A header line whose name is not a valid token, or whose value carries a CR or LF, is refused with the offending line named rather than silently dropped.
Identities are saved with the project, so gori run authorize and the MCP tools default to the same set you configured here. The list shows header names only β a session cookie is a credential, and a list that paints it on screen leaks it to anyone glancing at your terminal. The form shows values, because that is what editing means.
Changing the identity set marks every result already on screen as pending again. Those verdicts were produced under the old set, and reporting them beside new ones would compare two different tests.
Session Slots: One List, Two Readers
This tab reads the list across: every slot, one request, compare the answers. Every other send seam reads it down: pick one slot, and every request from then on goes out wearing it. Same rows, same card, two questions.
Picking the active one is a separate action from editing the list, because it is a different kind of state:
| Surface | Pick the active slot | Edit the list |
|---|---|---|
| TUI | Ctrl-P β Session slot, or click the session:NAME chip |
i on this tab |
gori run |
--slot NAME on the sending command |
gori run session list | show | add | edit | rm | baseline |
| MCP | set_active_session_slot |
list_session_slots, create_session_slot, update_session_slot, delete_session_slot |
What the active slot changes, on send_request, a Repeater or Fuzzer send, and an intercept forward:
- its header overlay is applied to the final wire bytes β after
$NAMEsubstitution, header lines only, soContent-Lengthnever moves and the body is byte-exact; $NAMEresolves against its binding table. An extract rule a slot claims writes that slot's table; a rule no slot claims keeps writing the one global table it always did. SoAuthorization: Bearer $SESSIONmeans admin's token on theadminslot and the low-priv user's onlow-priv, off one saved string.
Three things the active slot deliberately does not do:
- It does not apply to an Authorize run. This tab supplies the identity itself, once per send, and comparing them is the measurement β so a run reads the list across and wears none of it, whichever slot is active. Left to apply, the active slot would write its
Cookieover the top of every identity, including the one whose whole job is to remove it: every response would match the baseline by construction and every queued row would report a bypass that does not exist. What does still apply is the second half above: each identity's own$NAMEresolves out of that identity's binding table as its overlay goes on, soCookie: session=$SESSIONon bothadminandlow-privis two different sessions on the wire β which is the whole point of running them side by side. - It is never persisted. Reopening a project, or a new
gori mcpconnection, starts as-captured. A slot's values are memory-only by design, so restoring "admin is active" into an empty admin table would hand the next send an overlay whose$SESSIONis literal β a401with no visible cause. Activation is one keystroke; a stale one is a support ticket. as-capturedis the baseline in both senses. With no slot active nothing changes a byte, which is what makes every project and every playbook written before slots existed behave exactly as it did.
There is no cookie jar and no auto-login macro here. A slot carries the headers you wrote and the values gori observed; --bind-from replays one flow you named to fill them.
The Baseline
Exactly one identity in a run is the baseline β the response every other identity is judged against. Unless an identity claims it (b in the card, "baseline":true in JSON), the request as captured is the baseline: it goes out with its original session, and everything else is a lens over that same request.
A run needs at least one identity besides the baseline, and all three surfaces refuse one that does not have it. With a single identity, every trial would be the baseline judged against itself; the run would finish, report "no identity matched the baseline", and mean nothing at all.
The Loop
- In History, select the flows worth testing and
Spaceβ Send to Authorize. From Sitemap, the same verb queues the selected endpoint's captured flow. - On the Authorize tab, press
iand set up the identities you want to compare. Ctrl-Rreplays every queued request that has no result yet;β§Rre-runs everything;truns just the request under the cursor.- Read the table. The top pane is one row per request with an aggregate verdict;
β₯drills into the selected request's identities in the bottom pane.
Each identity gets its own connection. That is deliberate and costs a handshake per trial: connection-oriented authentication (NTLM, Negotiate β ordinary on internal engagements) authenticates the connection, not the message, so a reused socket would serve an identity that dropped Cookie the baseline's content anyway and manufacture a bypass that does not exist.
Ctrl-X stops. The stop is polled between requests and between identities, so it takes effect at the next send rather than after the five identities already queued for the request in flight. A request cut short mid-identity yields no verdict at all: a partial set of trials must never read as "enforced".
Reading a Verdict
Each identity's response is reduced to three facts β status, decoded body size, and a SimHash content fingerprint β and compared with the baseline's. Bodies are decoded (gzip / deflate / br / chunked) before hashing, because a fingerprint over compressed bytes is meaningless.
| Verdict | Means |
|---|---|
baseline |
This row is the baseline |
different |
A different status class (2xx vs 4xx vs 3xx) β the clearest sign access control engaged. Or two redirects that point somewhere else |
same |
Same status class, and the body matches: within a SimHash distance of 3 and within 10% in size. Or two redirects to the same place |
review |
Same status class, divergent body β or the baseline itself errored, so there was nothing to anchor against |
error |
This identity's send failed (TLS, DNS, timeout, refused); nothing was compared |
Two redirects are judged on their Location, before the body is looked at. A redirect's body is empty, so on the three facts above every 3xx matched every other 3xx β and an authenticated 302 β /dashboard against an anonymous 302 β /login, which is the clearest enforcement there is, came back same. Where the origin steers each identity is the only thing a redirect says, so that is what gets compared: an exact string match, because /login and /login/ are a difference worth showing you rather than one worth deciding for you.
The per-request row aggregates them: BYPASS when any non-baseline identity came back same, enforced when every one clearly differed, error when every one of their sends failed, review otherwise.
That fourth word is not a formality. A request nothing answered has no same verdict and no different one either, so an aggregate built from those two alone calls it enforced β a clean bill of health for a host gori could not reach. "The server held" and "we never got a reply" are opposite findings, and every surface reports them apart: the tab paints the row error, gori run authorize prints [x] error for it, and the MCP verdict is error with an unanswered_count beside it. When nothing in the run was compared β every request either refused by the gate or unanswered β the CLI says so on its summary line and exits non-zero, so a script that gates on the exit code cannot read a dead host as an endpoint that held.
Both halves of the same test matter. SimHash skips numeric and hex tokens, so two differently-sized pages can hash close; the size band catches that. The 10% tolerance exists because real pages carry per-request noise β CSRF tokens, timestamps β and an exact match would flag all of it.
This is a heuristic, and the names are deliberately neutral, because the security meaning depends on the identity's intended privilege and only you know that. same on a low-privilege identity is a likely bypass. same on a second admin session is exactly right. review is where a tailored "access denied" page rendered at 200 and a legitimately per-user page look identical to a fingerprint. The tab states the comparison; you read the intent.
What Gets Skipped, and Why
A selection can reach flows that cannot be replayed meaningfully. gori names every one of them rather than quietly sending less than you asked for.
| Reason | Why | Override |
|---|---|---|
| no identity changes them | No identity would alter this request's bytes, so every trial sends the same thing, the responses match by construction, and the row would read same β a finding manufactured out of nothing |
Add an identity that sets or drops the header this endpoint authenticates with |
| not a safe method to repeat | Only GET / HEAD / OPTIONS are replayed by default. A replayed POST / PUT / PATCH / DELETE runs its side effect again, once per identity |
--unsafe-methods (CLI), unsafe_methods:true (MCP). The TUI's manual queue takes any method, because there a human picked the request |
| never completed | The capture has no response to compare against | β |
| answered by gori | gori short-circuited this request itself; there is no origin behind it | β |
| outside project scope | The outbound gate refused the target before the socket | --allow-unscoped (CLI), allow_unscoped:true (MCP), or add a scope include rule |
| already queued | The same flow was named twice | β |
The first row is the one worth internalizing. The skip is not a "does this request carry a Cookie?" test β that question missed APIs authenticating through X-Api-Key, and on a site you were not logged into it skipped everything while saying nothing. gori asks the exact question instead: would any identity change these bytes? If not, there is nothing to compare, and it says so.
Passive Replay
p turns on unattended replay: as you browse through the proxy, every completed, in-scope, safe-method flow that at least one identity would change is queued and replayed automatically. It is off by default and nothing else turns it on β this is the one control in the tab that puts requests on a target with nobody pressing a key.
Passive is gated harder than the manual queue: it needs a scope include rule, and with none configured nothing is replayed at all. gori says so at the keypress rather than leaving you to wonder. A browser session reaches a great deal that is not the engagement, and passive follows the browser.
Two more properties worth knowing. Passive dedups by endpoint (METHOD + URL), not by flow id, so a session's tenth visit to /orders does not requeue it while /orders?id=2 still gets its own row. And the queue is capped at 200 requests β a long browse would otherwise replay more traffic than the browsing itself did. The cap is announced when it bites.
The tab keeps a live readout of what passive has actually done (N seen Β· M queued Β· K skipped (reason)), because "nothing happened" and "nothing matched" otherwise look identical.
Keys
| Key | Action |
|---|---|
Ctrl-R |
Run pending β replay every queued request with no result yet (never run, or the send failed) |
β§R |
Run all β replay everything, re-sending requests that already have a result |
t |
Run this request only |
Ctrl-X |
Stop the run |
i |
Identities β edit the set every request is replayed under |
p |
Toggle passive replay |
d |
Remove the selected request from the queue |
β / β |
Move between requests |
β₯ |
Move between the selected request's identities |
PgUp / PgDn |
Scroll the detail pane |
Space β X |
Clear β empty the queue and its results (menu only) |
From History or Sitemap, Space β Send to Authorize queues a request here.
Headless
# Two captured flows, under the identities saved in the project
gori run authorize 12 13
# An explicit identity set, and a QL query instead of ids
gori run authorize --query 'host:acme.test method:GET status:200' \
--identities identities.json --limit 20
identities.json is the same shape the tab persists and the MCP tools take:
[{"name": "anonymous", "remove": ["Cookie", "Authorization"]},
{"name": "low-priv", "set": [{"name": "Cookie", "value": "session=β¦"}]}]
Text output is one block per request β a headline you can scan down the left edge for [!] BYPASS, then one row per identity:
authorizing 2 requests Γ 2 identities (as-captured, anonymous) = 4 requests
[!] BYPASS #1 GET http://127.0.0.1:8399/admin/users Β· 1 of 1 identity matched the baseline
as-captured baseline 200 118B β
anonymous same 200 118B Ξ status 200 Β· size same Β· time -434 Β΅s
[ ] enforced #2 GET http://127.0.0.1:8399/orders
as-captured baseline 200 118B β
anonymous different 403 9B Ξ status 200 β 403 Β· size -109 B Β· time +272 Β΅s
done Β· 2 requests replayed Β· 4 sends Β· 1 possible bypass
Skips are stated up front, per flow, before anything is sent:
skipped 1 flow Β· 1 no identity changes them
#1 GET http://acme.test/pricing β no identity changes them
Manage the slot list β the same rows the i card edits β without opening the TUI:
gori run session list # names, overlays (values [REDACTED]), claimed rules
gori run session add --name low-priv --set 'Cookie: session=β¦' --rule SESSION
gori run session edit low-priv --clear-set --set 'Cookie: session=new'
gori run session baseline as-captured
gori run session rm low-priv
There is no gori run session activate: a gori run process sends and exits, so the active pointer has nothing to span. Name the identity on the send instead β --slot NAME works on repeater, fuzz, mine, sequence and discover, and applies before --bind-from replays its seed, so the seed fills the slot the run then sends as.
--format jsonl streams one object per request as it lands; --format json buffers and emits a single array at the end. Both carry the decoded body size the verdict actually compared alongside the wire size, which a gzipped response makes disagree by an order of magnitude. Full flags are in the CLI Reference.
From an Agent
Four MCP tools drive the same engine as a background job: authorize_start (returns a job_id, the planned send count, the identity names, the scope gate, and everything it skipped), authorize_status, authorize_results, and authorize_stop.
authorize_results puts the answer first. access_control names the outcome in one token β BYPASS, enforced, review, error, or nothing_sent (the last two both mean nothing was compared) β summary says it in a sentence, and bypasses lists every request where a non-baseline identity was served the baseline's response, flat and never paged. An agent that reads nothing else still gets the finding.
Five more manage the slots themselves: list_session_slots (with the active one named, header values [REDACTED] unless you ask), create_session_slot, update_session_slot, delete_session_slot, and set_active_session_slot β which picks the identity every other tool's sends go out as, for the life of that server process.
A run is capped at 2,000 sends, and the cap counts flows Γ identities: a 500-row query under four identities is refused up front, naming both factors, rather than truncated into a run that would report "enforced" for flows it never sent. Layer-1 scope is strict here β an out-of-scope target needs an explicit allow_unscoped:true, because nobody eyeballed it.
A Run That Sent Nothing Is Not Evidence
This is the property the code goes out of its way to enforce, on every surface.
If the sandbox or an exclude rule refused every send before the socket, if every selected flow was skipped, or if you stopped the run part-way, gori does not report "no identity matched the baseline". It says nothing was sent. A clean bill of health for traffic that never left the machine is the worst way an access-control test can fail β worse than a false positive, because you would close the ticket.
The same caution applies to a genuine enforced: it means access control held for the identities you tested, on the requests you replayed. A different endpoint, a different privilege boundary, or an identity you did not model is untested, not safe.
Next Steps
- Proxy & History: capture the traffic and set the scope this tab replays inside
- Scanning & Issues: file a confirmed bypass as an Issue
- Scripting: run the same comparison headless, in CI
- MCP Server: hand the whole loop to an agent