Table of contents:

Part 1 followed a request through several servers. Now we’ll look inside a single nginx or Apache instance. A config is easy to read in the wrong order: you see auth_request, rewrite, a couple of location blocks, and it looks like the top line runs first, then the one below it. But the server has separate processing phases. Line order matters within some of them, while switching to another rule can change both the path and the program that receives the request.

On a bug bounty target, you do not have those lines in front of you. The configurations below are teaching examples: the difference between SNI and Host suggests checking which site is selected, while an alias rule suggests examining how the file path is built.

Take a short chain:

browser -> CDN -> nginx -> PHP-FPM -> application

Nginx does not run PHP code itself. It parses the HTTP request and hands it over the FastCGI protocol to PHP-FPM - a service that keeps PHP processes running and spreads work across them. The application here is the site’s PHP code itself.

The CDN and nginx parse HTTP. PHP-FPM receives FastCGI fields, and the PHP code uses the method, path, parameters, and body passed to it. In this example setup, we can follow how the path changes and which string each server checks. On a real target, the exact point where it changes may remain unknown. What we check in the response is whether the request retrieved a restricted file or performed a forbidden action.

TLS first, then HTTP

On an initial HTTPS connection, the client first establishes TLS. TLS encrypts the data and lets the client verify the server’s certificate. In the first message, ClientHello, the client lists the parameters it supports. It also usually includes two extensions that matter when selecting the site and the HTTP protocol.

SNI, server name indication, carries the site name, say example.com. A single IP address can front dozens of sites with different certificates, and the HTTP request has not started yet. So the name has to be announced during the TLS handshake. From it the server picks a certificate and part of the virtual host settings.

ALPN is the extension for choosing a protocol inside the encrypted connection. The browser can offer h2 and http/1.1, and the server picks one. h2 means HTTP/2. On an ordinary initial connection, HTTP follows the handshake. Early data during session resumption is a separate case, covered in part 11.

After that the site name arrives again inside HTTP: in Host for HTTP/1.1, or in :authority for HTTP/2 and HTTP/3. The client can supply a name different from SNI. With mTLS, the server also requests and verifies a client certificate. If SNI determines whether that certificate is required, while Host selects the site, check whether a connection with different verification settings can reach the protected site.

If the browser connects to a CDN, nginx does not receive that TLS connection. The CDN decrypts the request and, if needed, establishes a separate TLS connection to nginx. The CDN supplies SNI on that connection. During a test, record which connection you are examining and who supplied its parameters.

We compared HTTP/1.1 and HTTP/2 in part 1. Here, remember that the protocol between the CDN and nginx is chosen separately. A proxy translating HTTP/2 to HTTP/1.1 must validate the incoming request. content-length must match the body length, and Transfer-Encoding is forbidden in HTTP/2. A malformed request must not be silently repaired and forwarded. For a valid request, the proxy then defines the HTTP/1.1 body boundary using Content-Length or chunked encoding. Part 9 covers H2.CL and H2.TE, the vulnerabilities caused by errors in this conversion.

How nginx picks a config

For ordinary request processing in nginx, the order is:

pick server -> server-level rewrite -> pick location
-> location-level rewrite -> access check
-> file or request to the application -> response filters -> log

rewrite ... last inside location -> search for location again

A server block configures a site, or virtual host. A location defines how to handle a path, and an upstream defines a group of application servers. The rewrite rules in the server block run first; only then does nginx look for a matching location. If rewrite ... last changes the path inside that location, the search starts again. With break, processing continues in the same location.

try_files, error_page and the response header X-Accel-Redirect can also cause an internal redirect. Nginx starts processing the request again internally; the browser receives no 301 or 302. The server selects another URI or a named location such as @fallback. A named location is called by other nginx rules and cannot be selected directly by a client URL. Nginx permits at most ten internal redirects for a request; exceeding the limit returns 500.

server - SNI and Host are not one field

For plain HTTP, nginx looks first at the address and port from listen, then at Host. With TLS, SNI comes into play before HTTP starts. For an access check based on the domain, we need to know which of these names it uses.

You need a dedicated default server that will not accept an arbitrary Host:

server {
    listen 80 default_server;
    server_name _;
    return 444;
}

server {
    listen 443 ssl default_server;
    ssl_reject_handshake on;
}

444 is an internal nginx code: it closes the connection without an HTTP response. ssl_reject_handshake, introduced in 1.19.4, rejects the TLS handshake. An unknown SNI otherwise uses the default virtual host for the listening address and port. If no default_server is declared, the first server for that address and port is the default. The shown configuration rejects unknown SNI before HTTP; allowed TLS connections still need the appropriate Host checks.

One setting takes effect before Host is read: merge_slashes. By default, it collapses adjacent / characters into one. In HTTP/1.1, the path is in the request line, before the headers. When nginx parses //admin, it has not read Host yet, so it takes merge_slashes from the default configuration or the virtual host already selected by SNI.

In the source, ngx_http_process_request_uri() calls ngx_http_parse_complex_uri(r, cscf->merge_slashes). The documentation describes the same order under virtual server selection. This is why merge_slashes is configured in http or server, not inside location: the path has already been processed by the time nginx selects a location.

With a particular configuration, this can bypass a path restriction. SNI selects a site with merge_slashes off, while Host names a neighboring site whose restriction uses location = /admin. If nginx accepts that combination of names, //admin does not match the exact location. Suppose another block forwards the request to an application that merges the slashes and opens /admin without its own authorization check. Under those conditions, the nginx restriction is bypassed.

An external test starts with two known in-scope names, not a search for neighboring sites on a shared IP. Suppose they are public.example and private.example. First, I request each site with its usual name in both TLS and HTTP. Then I keep the public site’s SNI and change Host to the private site:

curl --http1.1 -i --resolve public.example:443:192.0.2.10 \
  'https://public.example/account' -H 'Host: private.example'

All addresses here are placeholders. --resolve sets the connection IP; the hostname in the URL still determines SNI and certificate verification. The header changes only the HTTP name. Certificate verification stays enabled in this comparison, so we know the TLS connection is valid for the URL’s hostname. Do not follow redirects automatically: otherwise, it is easy to confuse the response from this connection with one from another site.

If curl reads options from your .curlrc, they also affect the request. The file might enable redirects or supply credentials, for example. To ignore it, put -q first, immediately after curl. Then supply the cookies and other options needed for this test explicitly.

Selecting another public site is ordinary routing. The mTLS test needs a resource protected specifically by a client-certificate requirement: a normal connection to private.example cannot read it without the certificate, while the mixed names can. Keep any additional application session unchanged. Changing these names does not disable the application’s object-ownership check.

Test merge_slashes separately by comparing /admin and //admin with the same SNI, Host and credentials. In the example above, the exact location enforces the restriction and the application trusts it. An independent application check on /admin would still deny the request after slash merging. Even a successful bypass does not identify merge_slashes; another path rule can produce a similar difference.

In a location the first match does not always win

For locations at one level, nginx first looks for an exact location =, then remembers the longest matching prefix. Unless that prefix has ^~, it checks regular expressions in their configured order. The first matching regex overrides the prefix. Nested locations add further search stages; the example below has no nesting.

Here is a config:

location /api/ {
    proxy_pass http://app;
}

location ~ \.php$ {
    fastcgi_pass unix:/run/php/php-fpm.sock;
}

/api/test.php fits both blocks, but the \.php$ regex wins. The request goes to PHP-FPM rather than http://app. This abbreviated configuration illustrates handler selection; executing PHP also requires FastCGI parameters, including the script path. Then check which file FPM selects, whether the user can create it and whether its extension is allowed by the selected FPM pool’s settings. Here a pool is a group of PHP processes sharing configuration. A complete example appears below.

On an external test, start with the known URL of your own upload and save the normal response. I then try filename or path-suffix variations suggested by these rules. If the application’s JSON error changes to a PHP-FPM message, the request may have reached a different handler. Code execution can be checked using a result computed by your own test file. Part 16 covers that test in more detail.

alias without a slash walks out of the directory

A classic case where the checked URL diverges from the file actually opened is an alias in a location without a trailing slash. The vulnerable form:

location /i {
    alias /data/w3/images/;
}

Nginx joins the request /i../app/config.py into /data/w3/app/config.py. The /i part matched the location, and the remainder ../app/config.py gets appended straight onto the alias value, so the path leaves images behind. No %2f and no double decoding needed - one missing character is enough. The fix is a trailing slash on both the location and the alias:

location /i/ {
    alias /data/w3/images/;
}

From outside, I start with static content that actually exists. Suppose the site loads /i/logo.svg. With this configuration, /i../images/logo.svg points to the same file: substitution produces /data/w3/images/../images/logo.svg. If both URLs return the same SVG, this path construction is a candidate explanation. A nonexistent filename helps rule out a site that returns the same fallback page for every path:

curl --path-as-is -i 'https://example.com/i/logo.svg'
curl --path-as-is -i 'https://example.com/i../images/logo.svg'
curl --path-as-is -i 'https://example.com/i../images/no-such-pgi-42.svg'

Here images comes from the teaching configuration. On a real target, you still need to establish that directory name, perhaps from published source code or a message exposing a path. Matching public files does not prove reading outside the allowed directory. That requires a separate file unavailable through its intended public route: your own restricted test document or another minimal disclosure permitted by the testing rules. Include both requests and the content that distinguishes the file from a fallback page in the report. A 200 after .. is not enough.

gixy needs the configuration itself, from your own server or supplied during a pentest. Its alias_traversal check flags off-by-slash patterns. Install the maintained dvershinin/gixy fork with pip install gixy-ng; it cannot obtain a site’s configuration from its URL.

Another alias bug was disclosed on October 14, 2026: CVE-2026-15176. This one causes a buffer overflow rather than simply opening a neighboring directory. It requires alias, a URI changed by rewrite, and evaluation of $request_filename, for example in set, return or a log format. While building the filename after a rewrite, the vulnerable code could access memory beyond the allocated buffer. The first fixed releases are 1.31.7 and 1.30.6.

The two matching SVGs above therefore do not confirm this CVE. They help investigate path construction. The memory error requires additional conditions; a dropped connection reveals neither the configuration nor the reason for the failure. From outside, start with ordinary file reads and leave overflow reproduction to an agreed test environment. Part 16 covers the modules and versions. F5’s description.

$request_uri and $uri

$request_uri holds the original URI including the query string. $uri is the current path without that query string. Before choosing a location, nginx decodes %XX, processes . and .. segments and, with merge_slashes enabled, merges adjacent slashes. This preprocessing is called normalization. Rewrites and internal redirects can change $uri again.

For /api/a/%2e%2e/users?sort=name, the values are:

$request_uri = /api/a/%2e%2e/users?sort=name
$uri         = /api/users

%2e encodes a dot, so a/%2e%2e/ becomes a/../ and is removed during normalization. Nginx selects the location using /api/users, but the original string remains available in $request_uri.

Neither variable is “safer” on its own. A file name, a redirect, a cache key, and a backend request each need different checks. What matters is where the variable is substituted and at which phase.

How nginx decodes the path

ngx_http_parse_complex_uri() processes the path one character at a time. The state variable records what has already been read: an ordinary character, a slash, one dot, two dots or the start of %XX. This way of parsing is called a state machine. After /, for example, the function remembers that a new segment has begun, so it can distinguish a standalone .. from dots inside a filename.

When it encounters %, it saves the state in quoted_state and combines the next two hexadecimal digits into a byte. The assignment state = quoted_state restores the parsing state from before %. A decoded / or . is then processed by the same rules as an unencoded one. This is why /a/%2e%2e/b becomes /b before nginx selects a location.

%25, %23 and %3f take separate branches. Their decoded %, # and ? are copied into the result without being processed again as the start of an escape, fragment or query string. Thus %252f becomes %2f in this pass, not /. In sw_quoted_second, these checks are split according to the second hexadecimal digit: numeric for %25 and %23, alphabetic for %3f.

Decoded NUL (%00) is rejected. CR and LF from %0d and %0a, however, can enter $uri: they are not marked as ordinary characters in usual[], and the general branch copies the decoded byte. The control-character check applied to the original request line is not repeated over that result.

The destination determines the effect of $uri or its alias $document_uri. return 200 "$uri"; places it in a response body. A substitution into a redirect, response header or a new HTTP/1.1 request can change message structure. When reviewing an available configuration, inspect the argument and serialization used by rewrite, return, add_header, proxy_set_header or proxy_pass; gixy’s http_splitting check also finds these cases. FastCGI parameters have different framing, so a newline in one is not automatically HTTP splitting.

Without the config, I look for a function that already inserts the path into a response, such as a redirect that adds a trailing slash. I place %0d%0aX-Audit-Note:%20pgi-42 in the path and read the first response without following Location. If a separate X-Audit-Note: pgi-42 field appears, the line breaks really changed the response headers. If the string remains inside HTML or an encoded Location, they did not. A 404 may mean the changed URL no longer reaches the original redirect.

The bytes can also enter the outgoing request. In this unsafe example, app is an existing upstream group in the http block. Using a hostname with this variable-based proxy_pass instead requires a DNS server configured through the resolver directive:

location / {
    proxy_http_version 1.1;
    proxy_set_header Host $http_host;
    proxy_pass http://app$uri;
}

Here the variable is inserted into the URI as a ready-made string. After decoding a request with this path:

/download/a%20HTTP/1.1%0d%0aX-Audit-Note:%20pgi-42%0d%0aX-End:%20z

the outgoing request can begin:

GET /download/a HTTP/1.1
X-Audit-Note: pgi-42
X-End: z HTTP/1.1

The proxy’s appended version becomes part of X-End, while X-Audit-Note is now a separate request header. It will not appear among the response headers unless the application exposes it there. Part 5 tests the injected field through an ordinary application function and follows the transition to request splitting. Tom Stacey and Tobia Righi examined these mechanisms in CRLF-Powered Desync Attacks in August 2026.

What the slash in proxy_pass changes

If proxy_pass has no URI part (proxy_pass http://app;) and the path has not changed during subsequent processing in nginx, the application server receives the client’s original URI. If there is a URI part (proxy_pass http://app/;), nginx takes the normalized path and replaces the matching location prefix with the specified URI.

For the same request, /api/a/%2e%2e/users?sort=name, in location /api/, the difference is as follows. These are two alternative configurations, not duplicate blocks in one file:

proxy_pass http://app;  -> /api/a/%2e%2e/users?sort=name
proxy_pass http://app/; -> /users?sort=name

In the second case, /api/ in the normalized path /api/users is replaced by /. There are no rewrites or variables in this proxy_pass; those have additional rules.

Choosing the original or normalized URI

Nginx keeps both the original r->unparsed_uri and the normalized r->uri in the request structure. The flag r->valid_unparsed_uri records whether the original form can still be used. During ordinary parsing, it is set by r->empty_path_in_uri ? 0 : 1: if the path in the original URI was not empty, the flag is one. The presence of %2f, // or /./ does not by itself clear it.

When building the request, ngx_http_proxy_create_request() checks ctx->vars.uri.len == 0 && r->valid_unparsed_uri: proxy_pass has no URI part and the original path is still usable. That path is then copied into the request to the application. Adding / makes the first half of the condition false and selects another branch. The slash itself does not change the flag.

The other branch takes the normalized r->uri, replaces the location prefix and calls ngx_escape_uri(..., NGX_ESCAPE_URI) where necessary. This function writes certain characters as %XX again so that they do not change the outgoing URI’s structure. A decoded question mark in the path, for example, needs to remain part of the path rather than start the query string.

That escaping table leaves /, ;, : and = unencoded. After normalization, %3B can therefore reach Tomcat as a literal ;. If Tomcat treats it as the start of a path-segment parameter, the string’s meaning changes. For example, in /item;id=7/view, the parameter id=7 belongs to the item segment. Part 3 covers these processing rules. The proxy_pass_normalized check in gixy-ng flags a URI part in proxy_pass for manual review, not as proof of a vulnerability.

ngx_http_internal_redirect() clears valid_unparsed_uri, so even proxy_pass without a URI can send the changed normalized path. ngx_http_named_location() does not clear it, but entering a named location does not restore a flag already cleared by earlier processing.

Comparing /api/users with /api/a/%2e%2e/users first tests whether they resolve to the same resource. Nginx normally normalizes both to /api/users before selecting a location, so this difference alone does not bypass that location’s protection. Equal responses cannot reveal the slash in proxy_pass either: the application could have normalized both paths.

A bypass needs another mismatch. Suppose an authorization service checks the original path from $request_uri and denies an ordinary account only the literal /api/users. It permits /api/a/%2e%2e/users, while the application receives normalized /api/users and trusts the service’s decision. If the handler independently checks the user’s role, both paths will be denied.

What the authorization service receives

auth_request /auth; creates an additional internal request to /auth. Nginx processes it and waits for the result before allowing the main request. This is called a subrequest. If /auth is proxied to a separate service, that service receives a request built by this location’s settings, not a copy of the entire original HTTP request.

In ngx_http_subrequest(), an ordinary subrequest is created with the GET method. There is a flag for preserving the method, NGX_HTTP_SUBREQUEST_CLONE, but auth_request passes a different one: NGX_HTTP_SUBREQUEST_WAITED. ngx_http_auth_request_handler() also creates an empty body structure so that the subrequest does not try to read the main request’s body, and sets header_only = 1: the access decision needs the response status, not its body.

With ordinary proxy_pass settings, the service therefore sees GET for /auth, not the original POST for /admin. The official example passes the original URI in X-Original-URI. If access rules also depend on the method, that needs to be passed and checked too. For example:

location = /auth {
    internal;
    proxy_pass http://auth_service;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header X-Original-URI $request_uri;
    proxy_set_header X-Original-Method $request_method;
}

The service must base its decision on these fields, not just on its own GET. Here $request_method comes from the main request (r->main->method_name in the nginx source). If needed, proxy_method $request_method can change the outgoing method itself. A URI can also be specified in proxy_pass, and a body can be built through proxy_set_body.

The path representation still needs checking. $request_uri contains the original string, while the location was chosen using normalized $uri. If the service checks the original string, it needs to interpret it the same way as the program that eventually handles the request. Otherwise, it can allow one path while another is executed. Using auth_request does not by itself prove such a bypass.

Codes 200–299 allow the request: ctx->status >= NGX_HTTP_OK && ctx->status < NGX_HTTP_SPECIAL_RESPONSE. Codes 401 and 403 deny access; other codes are errors. A 302 from the authorization service therefore produces 500 unless the configuration handles that case separately. Part 16 examines how error_page 401 can reroute a denied request.

From outside, I test the main function rather than a guessed /auth URL: that path may be internal or have a different name. A method-check failure needs a function where reading is allowed but writing is denied. If the authorization service sees GET instead of the original POST, it may permit a write as a read; the application must rely on that decision instead of enforcing write access again. Record a disposable test object’s initial value, send the API’s normal update request, then read the object. A new value shows that the action ran.

How Apache handles the same request

Apache has a different scheme. One module translates the URL into a file name, another checks access, a third assigns a handler, a fourth builds the response. For this the code has separate hook points, and a module plugs into the processing phase it needs.

The simplified sequence looks like this:

parse request -> translate URI to file name -> access check
-> pick type and handler -> response filters -> log

The URI /download/report.pdf, file /srv/www/download/report.pdf and handler default-handler are separate values. mod_rewrite changes the URI, Alias changes the file path, and SetHandler changes the handler. In the 2024 Confusion Attacks research, Orange Tsai showed how Apache modules interpreted these values differently: Filename Confusion (CVE-2024-38474), DocumentRoot Confusion (CVE-2024-38475) and Handler Confusion (CVE-2024-38476). Part 4 covers the mechanisms. When reading a configuration, follow what the URI, file and handler become after all the mod_rewrite and Alias rules.

MPM

An MPM is the module that decides how Apache spreads connections across processes and worker threads. It governs how many requests the server holds at once and how much memory that costs.

There are three main variants:

  • prefork - several processes with no worker threads;
  • worker - several processes with threads;
  • event resembles worker but handles some of the idle keep-alive connections separately.

event keeps some idle keep-alive connections from occupying worker threads, but it does not fully prevent Slowloris. Slow headers and bodies can still tie up a worker. A separate module, mod_reqtimeout, limits their read time through RequestReadTimeout (default handshake=0 header=20-40,MinRate=500 body=20,MinRate=500). Header and body size limits are set by their own directives.

The old mod_php often ran with prefork: not every PHP build and third-party library was thread-safe. PHP is now usually moved out to PHP-FPM. When mod_php is used, check whether the specific build supports multiple threads.

.htaccess

Apache reads .htaccess in the directories along the path to the file, if that is allowed via AllowOverride or AllowOverrideList. Apache’s documentation advises the server owner to use the main configuration when it is available. It is faster and makes the source of a rule easier to establish.

For the server owner, an uploads-directory restriction can look like this:

<Directory "/srv/www/uploads">
    AllowOverride None
    AllowOverrideList None
    Options -ExecCGI
    Require all granted
</Directory>

Both AllowOverride* directives are needed to stop Apache from reading .htaccess in that directory at all. If only AllowOverride None is set, an inherited AllowOverrideList may still permit individual directives.

Disabling .htaccess does not cancel handlers from the main configuration. Apache applies ordinary <Directory> sections together with .htaccess, then <DirectoryMatch>, then <Files> and <FilesMatch>, then <Location> and <LocationMatch>; matching <If> sections follow. A <FilesMatch "\.php$"> from the main configuration therefore applies after <Directory> and can still send the file to an interpreter. Check the file’s final handler.

On a bug bounty target, it is more useful to start with the upload itself: does the chosen filename survive, where is the file served, and does its content change? A failed GET for /.htaccess does not prove Apache ignores these files. Refusing to serve the file and applying its directives are separate things.

What the web server passes to the application

Once the route is chosen, the web server either returns a file or reaches out to the next program:

  • over HTTP to another web server;
  • over FastCGI to PHP-FPM;
  • over uWSGI to a Python application;
  • over SCGI to an application in Python or Perl;
  • over gRPC on top of HTTP/2.

AJP also passes requests to applications, but it is not included in the stock nginx distribution. Apache supports it through mod_proxy_ajp or mod_jk; below, we’ll compare it with FastCGI.

A local connection can run not over a TCP port but over a Unix socket - a special file for communication between processes on one machine.

FastCGI does not pass the original HTTP request to PHP-FPM. Nginx builds a set of parameters: SCRIPT_FILENAME with the path to the PHP file, DOCUMENT_ROOT with the document root, and REQUEST_URI with the original URI. FPM uses SCRIPT_FILENAME to choose the file to execute. The nginx check and the FPM selection need to refer to the same file. The stock fastcgi_params has no PATH_INFO, the path suffix after the script name. It is usually configured with fastcgi_split_path_info, with $fastcgi_path_info passed separately; the parameter can also be supplied another way through fastcgi_param.

AJP likewise carries parsed fields: method, protocol, req_uri, client address, headers and attributes. Tomcat populates its request object without rebuilding a textual HTTP/1.1 request. Those fields may no longer retain the bytes received before Apache’s transformations. Since 1.2.24, mod_jk defaults to ForwardURIProxy, forwarding a URI parsed by Apache and partially re-encoded. Tomcat uses that forwarded form.

Comparing FastCGI or AJP fields directly with the original request requires your own lab or a pentest with access to the internal exchange. From outside, observe the selected function: which test file is returned, whether a test script executes, and whether the access restriction survives a changed path. That response alone does not reveal the exact SCRIPT_FILENAME or req_uri value.

Take a typical FastCGI block:

location ~ \.php$ {
    try_files $uri =404;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_pass unix:/run/php/php-fpm.sock;
}

try_files $uri =404; checks whether the requested file exists and stops the request with 404 if it does not. Without that line, /uploads/avatar.jpg/x.php matches \.php$. With cgi.fix_pathinfo=1, PHP may discard the nonexistent /x.php suffix and choose the existing /uploads/avatar.jpg as the main script file.

Code execution requires additional conditions. The image must contain PHP code, and PHP-FPM’s extension restriction must allow .jpg or be disabled. By default, security.limit_extensions permits .php .phar, so a default FPM configuration rejects this file. The nginx check for the script’s existence, the value of cgi.fix_pathinfo, and the extensions allowed by the FPM pool all need to be checked separately.

Check how the client-supplied URI affects the final SCRIPT_FILENAME and whether nginx checks that file’s existence. Also check whether nginx and PHP-FPM share a filesystem. If they run in different containers, the same path string can point to different files.

Comparing responses from outside

From outside, it is useful to compare responses to requests with one element changed. Via, X-Cache, Age, CF-Ray and X-Amz-Cf-Id may suggest a proxy or cache is involved. Their absence rules nothing out, and their presence does not reveal the whole processing path. Different status codes or a callback to a test address do not automatically identify which server transformed the URI either.

The comparison needs a known route. For a file, I check its contents; for an API, the object ID and relevant fields; for a page, my own marker in the data. The HTML does not need to match byte for byte: timestamps, tokens and request IDs can change. As a comparison, I request a nonexistent object or the same object from a session without the required permission.

I also check that the client did not make the change itself: I save the request that was sent, use --path-as-is for .. segments, and initially read redirects without following them automatically. I repeat the original request after the modified one. If the result disappears, I need to distinguish the test’s effect from an expired session, changed data or an occasional response from a different server.

Part 3 looks more closely at path transformations: how one string changes and which requests let us check the consequences without a log of intermediate URIs.

Sources