<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Excession Labs]]></title><description><![CDATA[Excession Labs is a security lab. We run authorized penetration tests and AI red teaming for web apps, APIs and AI products.]]></description><link>https://excessionlabs.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6af3bb6cbd34b7e1a0d2f694/44f373e3-a17e-4361-a6e6-f3a15c4a1fc9.png</url><title>Excession Labs</title><link>https://excessionlabs.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 12 Nov 2026 09:23:00 GMT</lastBuildDate><atom:link href="https://excessionlabs.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Penetration testing cost in 2026: 68 published prices from 21 providers]]></title><description><![CDATA[Penetration testing cost is what a provider charges to test a system for security flaws, and it is usually priced per test, per year or per asset. On 9…]]></description><link>https://excessionlabs.hashnode.dev/penetration-testing-cost-in-2026-68-published-prices-from-21-providers</link><guid isPermaLink="true">https://excessionlabs.hashnode.dev/penetration-testing-cost-in-2026-68-published-prices-from-21-providers</guid><category><![CDATA[Security]]></category><category><![CDATA[pentesting]]></category><category><![CDATA[cybersecurity]]></category><category><![CDATA[pricing]]></category><dc:creator><![CDATA[Excession Labs]]></dc:creator><pubDate>Thu, 12 Nov 2026 00:07:26 GMT</pubDate><content:encoded><![CDATA[<h1>Penetration testing cost in 2026: 68 published prices from 21 providers</h1>
<p>Penetration testing cost is what a provider charges to test a system for security flaws, and it is usually priced per test, per year or per asset. On 9 November 2026 we read the pricing pages of 66 providers. 21 of them publish at least one price, which gives 68 price rows. For prices in US dollars sold per test, the median is $5,000 across 21 rows from 9 providers. The range is $1,000 to $27,120. 14 of those 21 rows are starting prices.</p>
<p>The data comes from the <a href="https://pentestcompare.com/research/pentest-pricing-index-2026/">PentestCompare pricing index</a>, which our lab publishes. Every row below links to the provider's own pricing page.</p>
<p>On this page: <a href="https://excessionlabs.ai/blog/penetration-testing-cost/#table">Prices by billing unit</a> · <a href="https://excessionlabs.ai/blog/penetration-testing-cost/#rows">Every per-test price</a> · <a href="https://excessionlabs.ai/blog/penetration-testing-cost/#model">By delivery model</a> · <a href="https://excessionlabs.ai/blog/penetration-testing-cost/#method">How we counted</a> · <a href="https://excessionlabs.ai/blog/penetration-testing-cost/#read">How to read the numbers</a> · <a href="https://excessionlabs.ai/blog/penetration-testing-cost/#ours">Our own price</a> · <a href="https://excessionlabs.ai/blog/penetration-testing-cost/#data">Data</a></p>
<h2>Pentest prices by billing unit</h2>
<p>Providers bill in different units, so we never mix them. A median appears only where 3 or more providers share a group. In the other groups the table shows the lowest and highest price only. Each group is split by currency.</p>
<p>Published prices by billing unit and currency (checked 9 November 2026)</p>
<table>
<thead>
<tr>
<th>Billing unit</th>
<th>Rows</th>
<th>Providers</th>
<th>Lowest</th>
<th>Median</th>
<th>Highest</th>
</tr>
</thead>
<tbody><tr>
<td>per test (USD)</td>
<td>21</td>
<td>9</td>
<td>$1,000</td>
<td>$5,000</td>
<td>$27,120</td>
</tr>
<tr>
<td>per test (EUR)</td>
<td>1</td>
<td>1</td>
<td>EUR 1,250</td>
<td>Not computed</td>
<td>EUR 1,250</td>
</tr>
<tr>
<td>per year (USD)</td>
<td>4</td>
<td>2</td>
<td>$2,999</td>
<td>Not computed</td>
<td>$49,990</td>
</tr>
<tr>
<td>per year (EUR)</td>
<td>4</td>
<td>1</td>
<td>EUR 500</td>
<td>Not computed</td>
<td>EUR 15,000</td>
</tr>
<tr>
<td>per month (USD)</td>
<td>6</td>
<td>3</td>
<td>$20</td>
<td>$199.95</td>
<td>$2,000</td>
</tr>
<tr>
<td>per asset per year (USD)</td>
<td>3</td>
<td>1</td>
<td>$50</td>
<td>Not computed</td>
<td>$85</td>
</tr>
<tr>
<td>per asset per month (USD)</td>
<td>2</td>
<td>2</td>
<td>$100</td>
<td>Not computed</td>
<td>$850</td>
</tr>
<tr>
<td>per asset or app, one-off (USD)</td>
<td>3</td>
<td>2</td>
<td>$15</td>
<td>Not computed</td>
<td>$10,000</td>
</tr>
<tr>
<td>per credit (USD)</td>
<td>4</td>
<td>2</td>
<td>$99</td>
<td>Not computed</td>
<td>$2,520</td>
</tr>
<tr>
<td>per hour or day (USD)</td>
<td>1</td>
<td>1</td>
<td>$340</td>
<td>Not computed</td>
<td>$340</td>
</tr>
<tr>
<td>per hour or day (EUR)</td>
<td>1</td>
<td>1</td>
<td>EUR 640</td>
<td>Not computed</td>
<td>EUR 640</td>
</tr>
</tbody></table>
<p>Only the per-test group in US dollars has enough providers for a median in a project-sized unit. The per-month median comes from 6 monthly plans from 3 providers. Those are subscription tiers, so we do not read it as the price of one test.</p>
<p>The dataset has no field for test type, such as web application, API, cloud or network. It cannot give a median for each test type. Where a provider names a scope in a price row, the table of per-test prices below shows it. For example, Secure Ideas lists a smallest-scope External Penetration Test at $8,160, an API Penetration Test at $10,880 and a Web Application Test at $13,600.</p>
<h2>Every per-test price in US dollars</h2>
<p>Each row is one published price. "Starting price" is Yes when the amount or unit says "from", "starts at" or "starting", or when the provider calls it the smallest scope.</p>
<p>All 21 per-test prices in US dollars, lowest first</p>
<table>
<thead>
<tr>
<th>Provider</th>
<th>Item</th>
<th>Amount as published</th>
<th>Unit as published</th>
<th>Starting price</th>
<th>Source</th>
</tr>
</thead>
<tbody><tr>
<td>Prescient Security</td>
<td>Human in the Loop</td>
<td>$1,000</td>
<td>per test</td>
<td>No</td>
<td><a href="https://prescientsecurity.com/penetration-testing-services/cacilian/pricing">prescientsecurity.com</a></td>
</tr>
<tr>
<td>CYBRI</td>
<td>Custom Projects</td>
<td>$2,500</td>
<td>starts at, per engagement</td>
<td>Yes</td>
<td><a href="https://cybri.com/pricing/">cybri.com</a></td>
</tr>
<tr>
<td>Prescient Security</td>
<td>Compliance Pentest</td>
<td>from $3,000</td>
<td>per engagement (scope-based)</td>
<td>Yes</td>
<td><a href="https://prescientsecurity.com/penetration-testing-services/cacilian/pricing">prescientsecurity.com</a></td>
</tr>
<tr>
<td>Stingrai</td>
<td>Autonomous Pentest</td>
<td>US$3,000</td>
<td>per assessment</td>
<td>No</td>
<td><a href="https://www.stingrai.io/pricing">stingrai.io</a></td>
</tr>
<tr>
<td>Cobalt</td>
<td>Autonomous Pentest (limited-time offer)</td>
<td>$3,500</td>
<td>per test</td>
<td>No</td>
<td><a href="https://www.cobalt.io/pricing">cobalt.io</a></td>
</tr>
<tr>
<td>Intruder</td>
<td>Same day pentest</td>
<td>$3,500</td>
<td>per test</td>
<td>No</td>
<td><a href="https://www.intruder.io/pentest-pricing">intruder.io</a></td>
</tr>
<tr>
<td>Payatu</td>
<td>AI-Native Express</td>
<td>$3,500</td>
<td>per test (starts from)</td>
<td>Yes</td>
<td><a href="https://payatu.com/pricing">payatu.com</a></td>
</tr>
<tr>
<td>Aikido</td>
<td>TYPICAL Pentest</td>
<td>$4,000</td>
<td>per assessment</td>
<td>No</td>
<td><a href="https://www.aikido.dev/pricing">aikido.dev</a></td>
</tr>
<tr>
<td>Intruder</td>
<td>AI Pentesting (product page)</td>
<td>$4,000</td>
<td>per test, standalone purchase</td>
<td>No</td>
<td><a href="https://www.intruder.io/platform/ai-pentesting">intruder.io</a></td>
</tr>
<tr>
<td>CYBRI</td>
<td>Web Application</td>
<td>$5,000</td>
<td>starts at, per engagement</td>
<td>Yes</td>
<td><a href="https://cybri.com/pricing/">cybri.com</a></td>
</tr>
<tr>
<td>Payatu</td>
<td>AI-Led Expert</td>
<td>$5,000</td>
<td>per test (starts from)</td>
<td>Yes</td>
<td><a href="https://payatu.com/pricing">payatu.com</a></td>
</tr>
<tr>
<td>Synack</td>
<td>Sara Pentest</td>
<td>$5,085</td>
<td>per test (starting price)</td>
<td>Yes</td>
<td><a href="https://www.synack.com/pricing/">synack.com</a></td>
</tr>
<tr>
<td>Prescient Security</td>
<td>Traditional Pentest</td>
<td>from $6,000</td>
<td>per engagement (scope-based)</td>
<td>Yes</td>
<td><a href="https://prescientsecurity.com/penetration-testing-services/cacilian/pricing">prescientsecurity.com</a></td>
</tr>
<tr>
<td>Stingrai</td>
<td>Hybrid Pentest</td>
<td>US$6,800</td>
<td>per assessment</td>
<td>No</td>
<td><a href="https://www.stingrai.io/pricing">stingrai.io</a></td>
</tr>
<tr>
<td>Secure Ideas</td>
<td>External Penetration Test (smallest scope)</td>
<td>$8,160</td>
<td>per test (flat fee, starting)</td>
<td>Yes</td>
<td><a href="https://secureideas.com/pricing">secureideas.com</a></td>
</tr>
<tr>
<td>CYBRI</td>
<td>Web App, API, Cloud, or AI</td>
<td>$9,500</td>
<td>starts at, per engagement</td>
<td>Yes</td>
<td><a href="https://cybri.com/pricing/">cybri.com</a></td>
</tr>
<tr>
<td>Synack</td>
<td>SynackST (compliance)</td>
<td>$10,283</td>
<td>per test (starting price)</td>
<td>Yes</td>
<td><a href="https://www.synack.com/pricing/">synack.com</a></td>
</tr>
<tr>
<td>Secure Ideas</td>
<td>API Penetration Test (smallest scope)</td>
<td>$10,880</td>
<td>per test (flat fee, starting)</td>
<td>Yes</td>
<td><a href="https://secureideas.com/pricing">secureideas.com</a></td>
</tr>
<tr>
<td>Secure Ideas</td>
<td>Web Application Test (smallest scope)</td>
<td>$13,600</td>
<td>per test (flat fee, starting)</td>
<td>Yes</td>
<td><a href="https://secureideas.com/pricing">secureideas.com</a></td>
</tr>
<tr>
<td>CYBRI</td>
<td>Multiple Apps, APIs, and Clouds</td>
<td>$20,000</td>
<td>starts at, per engagement</td>
<td>Yes</td>
<td><a href="https://cybri.com/pricing/">cybri.com</a></td>
</tr>
<tr>
<td>Synack</td>
<td>Synack14</td>
<td>$27,120</td>
<td>per test (starting price)</td>
<td>Yes</td>
<td><a href="https://www.synack.com/pricing/">synack.com</a></td>
</tr>
</tbody></table>
<p>One euro row sits outside this table. Hadrian lists "From EUR 1,250" per test, excluding VAT.</p>
<p>The widest spread is inside single providers. Synack's three starting prices run from $5,085 to $27,120. CYBRI's four run from $2,500 to $20,000. A provider's scope note decides whether two numbers describe similar work.</p>
<p>The 7 exact prices, without a starting label, have a median of $3,500 across 5 providers: Aikido, Cobalt, Intruder, Prescient Security and Stingrai. The Cobalt $3,500 row is a limited-time offer. Its page says the test must start and finish before 31 December 2026.</p>
<h2>Per-test prices by delivery model</h2>
<p>The delivery model is the dataset's label for each provider: human-led, hybrid or automated.</p>
<p>Per-test prices in US dollars, by delivery model</p>
<table>
<thead>
<tr>
<th>Delivery model</th>
<th>Rows</th>
<th>Providers</th>
<th>Lowest</th>
<th>Median</th>
<th>Highest</th>
</tr>
</thead>
<tbody><tr>
<td>human-led</td>
<td>7</td>
<td>2</td>
<td>$2,500</td>
<td>Not computed</td>
<td>$20,000</td>
</tr>
<tr>
<td>hybrid</td>
<td>11</td>
<td>5</td>
<td>$1,000</td>
<td>$5,000</td>
<td>$27,120</td>
</tr>
<tr>
<td>automated</td>
<td>3</td>
<td>2</td>
<td>$3,500</td>
<td>Not computed</td>
<td>$4,000</td>
</tr>
</tbody></table>
<p>Human-led and automated providers each have 2 providers with per-test rows, so we compute no median for them. The sample is too small to call one model cheaper than another.</p>
<h2>How we counted</h2>
<ul>
<li><p><strong>Predicate.</strong> A price counts when the provider shows an amount on its own public pricing page. We copied the amount, unit, scope note and page URL. We used no search snippets and no estimates.</p>
</li>
<li><p><strong>Date.</strong> We checked every row on 9 November 2026. We have not rechecked them since this page was written. Prices change often.</p>
</li>
<li><p><strong>Count.</strong> 66 providers in the set. 21 publish at least one price. 45 publish none. The 21 providers give 68 price rows. 50 rows fit a unit class and 18 do not, for example free tiers and packs. We list the 18 rows and the reason for each in the data file.</p>
</li>
<li><p><strong>Number used.</strong> For a range such as "640 EUR to 890 EUR" we use the first number.</p>
</li>
<li><p><strong>Provider set.</strong> The 66 providers are a researched set, not every pentest provider. We did not choose by quality or size. We tested no service and read no customer quote.</p>
</li>
</ul>
<h2>How to read the numbers</h2>
<p>These are list prices. The data holds nothing on what buyers paid. A quote after scoping can be higher or lower.</p>
<p>The set under-counts providers that quote after scoping. Of the 66 providers, 37 use a custom quote as their pricing model. The prices above come from the 21 that publish.</p>
<h2>Our own price</h2>
<p>We are not in these medians. Our lab publishes the dataset and competes with the providers in it. We publish no price list. Each test gets a written quote after a short scoping call. <a href="https://excessionlabs.ai/pricing/">The pricing page</a> lists what sets a quote. For the service itself, see <a href="https://excessionlabs.ai/penetration-testing/">penetration testing</a>.</p>
<h2>Data files</h2>
<ul>
<li><p><a href="https://excessionlabs.ai/blog/evidence/penetration-testing-cost/prices.csv">prices.csv</a>: 68 rows, each with a source URL and check date.</p>
</li>
<li><p><a href="https://excessionlabs.ai/blog/evidence/penetration-testing-cost/pricing-index-2026.csv">pricing-index-2026.csv</a>: the same rows with unit class, delivery model, and the reason a row is not comparable.</p>
</li>
<li><p><a href="https://excessionlabs.ai/blog/evidence/penetration-testing-cost/README.txt">README.txt</a> and <a href="https://excessionlabs.ai/blog/evidence/penetration-testing-cost/SHA256SUMS">SHA256SUMS</a>.</p>
</li>
<li><p><a href="https://pentestcompare.com/research/pentest-pricing-index-2026/">PentestCompare pricing index</a> and the <a href="https://github.com/Excession-Labs/pentest-provider-dataset">full dataset on GitHub</a> (MIT licence).</p>
</li>
</ul>
<p>Originally published at <a href="https://excessionlabs.ai/blog/penetration-testing-cost/">https://excessionlabs.ai/blog/penetration-testing-cost/</a> . The test code and recorded output are there.</p>
]]></content:encoded></item><item><title><![CDATA[PKCE: testing authorization code interception locally]]></title><description><![CDATA[PKCE (Proof Key for Code Exchange, RFC 7636) makes a stolen authorization code useless on its own. In our local toy server without PKCE, a party holding…]]></description><link>https://excessionlabs.hashnode.dev/pkce-testing-authorization-code-interception-locally</link><guid isPermaLink="true">https://excessionlabs.hashnode.dev/pkce-testing-authorization-code-interception-locally</guid><category><![CDATA[Security]]></category><category><![CDATA[webdev]]></category><category><![CDATA[appsec]]></category><category><![CDATA[oauth]]></category><dc:creator><![CDATA[Excession Labs]]></dc:creator><pubDate>Wed, 11 Nov 2026 19:54:18 GMT</pubDate><content:encoded><![CDATA[<h1>PKCE: testing authorization code interception locally</h1>
<p>PKCE (Proof Key for Code Exchange, RFC 7636) makes a stolen authorization code useless on its own. In our local toy server without PKCE, a party holding only an intercepted code received an access token in all four interception flows. With an S256 code challenge enforced, the three interception attempts that reached the token endpoint got 400 invalid_grant, a client with no challenge was refused at the authorization endpoint, and the legitimate client still received its token.</p>
<p>This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.</p>
<p>On this page: <a href="https://excessionlabs.ai/blog/pkce/#what">PKCE flow</a> · <a href="https://excessionlabs.ai/blog/pkce/#scope">Scope</a> · <a href="https://excessionlabs.ai/blog/pkce/#reproduce">Reproduce the run</a> · <a href="https://excessionlabs.ai/blog/pkce/#results">Observed results</a> · <a href="https://excessionlabs.ai/blog/pkce/#finding">Verify the finding</a> · <a href="https://excessionlabs.ai/blog/pkce/#fix">Fix and retest</a> · <a href="https://excessionlabs.ai/blog/pkce/#evidence">Evidence</a> · <a href="https://excessionlabs.ai/blog/pkce/#limits">Limits</a></p>
<h2>How the PKCE flow stops authorization code interception</h2>
<p>In the OAuth authorization code grant, the authorization server sends a short-lived code to the client's redirect URI, and the client trades it for an access token. A public client such as a mobile app cannot keep a secret, so anyone who holds the code and the public <code>client_id</code> can make the same trade. <a href="https://www.rfc-editor.org/rfc/rfc7636">RFC 7636</a> calls this the authorization code interception attack. Its example is a malicious app that registers the same custom URI scheme as the real app and receives the redirect.</p>
<p>The PKCE flow adds one secret that never travels in the redirect. For each authorization request the client creates a code verifier: a random string of 43 to 128 unreserved characters, and RFC 7636 recommends 32 random octets base64url-encoded, which gives 43 characters. It sends a code challenge in the authorization request. With the S256 method, the code challenge is the base64url-encoded SHA-256 hash of the code verifier. The server stores the challenge with the code. At the token endpoint the client sends the code verifier, the server hashes it and compares, and a mismatch must return <code>invalid_grant</code>.</p>
<p>This post covers PKCE only. Other OAuth weaknesses are out of scope.</p>
<h2>Scope of the fixture</h2>
<ul>
<li><p>A toy authorization server with two endpoints, <code>GET /authorize</code> and <code>POST /token</code>, bound to IPv4 loopback 127.0.0.1 on a free port the runner picks.</p>
</li>
<li><p>One synthetic public client, <code>demo-public-app</code>, with the registered redirect URI <code>demoapp://callback</code>. Nothing listens there. curl never follows the redirect, so the runner reads the Location header, which is what a malicious app registered for that scheme would receive.</p>
</li>
<li><p>One user who is already signed in and consents. There is no login page.</p>
</li>
<li><p>The interceptor holds the code, the <code>client_id</code> and the redirect URI. It never sees the code verifier. In one flow it also saw the authorization request, so it knows the code challenge.</p>
</li>
</ul>
<p>Vulnerable mode ignores <code>code_challenge</code> and <code>code_verifier</code>. Fixed mode enforces them. Each flow uses its own authorization code, because the server consumes a code on its first use. Tokens, codes and verifiers are one-time demo strings. To test a real system, agree hosts, accounts and stopping conditions with its owner first. The <a href="https://excessionlabs.ai/aup/">Acceptable Use Policy</a> describes authorized use, and this article gives no permission to test another system. The fixture matches the redirect URI as one exact string and does not cover redirect handling in general, which <a href="https://excessionlabs.ai/blog/open-redirect/">open redirect</a> covers.</p>
<h2>Run the downloadable fixture</h2>
<p>Download <a href="https://excessionlabs.ai/blog/evidence/pkce/fixture.py">fixture.py</a> and <a href="https://excessionlabs.ai/blog/evidence/pkce/verify.py">verify.py</a> into one directory. You need curl and Python 3; the code uses only the standard library, and we ran it on Python 3.15.2 only. Read the source, then run:</p>
<pre><code>python3 verify.py --output rerun
</code></pre>
<p>The output directory must be new. The runner starts each mode in turn, runs six flows per mode with real curl requests, saves every response and stops the servers. Each request bypasses proxies with <code>--noproxy '*'</code>, has a one-second connect timeout and a three-second total timeout, and follows no redirects.</p>
<p>Our recorded command was <code>python3 verify.py --output recorded-run</code>, run on 11 November 2026 at about 09:58 UTC with fixture version 1.0.0, Python 3.15.2 and curl 8.7.1 on macOS ARM64. It printed {"checks": 12, "passed": true, "failures": []}. That line means each flow matched what we expected from that mode, including the intended failures in vulnerable mode. It also recomputes the RFC 7636 Appendix B example, where the verifier <code>dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk</code> must produce the challenge <code>E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM</code>. That value matched. Versions, UTC times, commands and source SHA-256 values are in <a href="https://excessionlabs.ai/blog/evidence/pkce/recorded-run/results.json">results.json</a>.</p>
<h2>Compare vulnerable and fixed mode, with controls</h2>
<p>The legitimate client flow is the positive control: it must keep working after the fix. The three interceptor flows send a missing, a guessed and a replayed verifier. The last two rows start from clients that break the fixed server's rules: no challenge at all, and the plain method.</p>
<p>Recorded outcomes of 12 local flows</p>
<table>
<thead>
<tr>
<th>Flow</th>
<th>Redeemer sends</th>
<th>Vulnerable mode</th>
<th>Fixed mode</th>
</tr>
</thead>
<tbody><tr>
<td>Legitimate client, S256 challenge</td>
<td>its code verifier</td>
<td>200, token</td>
<td>200, token</td>
</tr>
<tr>
<td>Interceptor, no verifier</td>
<td>nothing</td>
<td>200, token</td>
<td>400 invalid_grant</td>
</tr>
<tr>
<td>Interceptor, guessed verifier</td>
<td>a random 43-character value</td>
<td>200, token</td>
<td>400 invalid_grant</td>
</tr>
<tr>
<td>Interceptor, challenge replayed as verifier</td>
<td>the code challenge</td>
<td>200, token</td>
<td>400 invalid_grant</td>
</tr>
<tr>
<td>Client sends no code challenge</td>
<td>nothing</td>
<td>302 with code, then 200 token</td>
<td>302 with error=invalid_request</td>
</tr>
<tr>
<td>Client sends method plain</td>
<td>its code verifier</td>
<td>302 with code, then 200 token</td>
<td>302 with error=invalid_request</td>
</tr>
</tbody></table>
<p>In vulnerable mode the interceptor got a token in all four of its flows (rows 2 to 5 of the table). The server accepted an S256 challenge in rows 2 to 4 and never checked it. In fixed mode no interceptor received a token. Rows 5 and 6 never reached the token endpoint because the authorization endpoint refused the request.</p>
<p>The refusals follow RFC 7636. Section 4.4.1 says a server that requires PKCE must answer a missing <code>code_challenge</code> with the <code>invalid_request</code> error, and a server that does not support the requested transformation must do the same. The fixed server supports S256 only. Section 4.3 makes the method default to plain when it is absent, and section 4.2 allows plain only for clients that cannot use S256 and know the server supports it. Refusing plain is therefore a server choice. Section 7.2 gives the reason: with plain, the challenge equals the verifier, so an attacker who observes the authorization request can learn the verifier. We did not run that attack.</p>
<p>The replayed-challenge row models the more capable attacker from section 1 of the RFC, who can also observe authorization requests. Replaying the observed challenge failed because the server hashes whatever it receives, so the result no longer equals the stored value. That shows one shortcut fails and nothing more about SHA-256.</p>
<h2>Verify a missing-PKCE finding before you report it</h2>
<p>A missing or unenforced code challenge is a finding only when an interceptor's request succeeds. Check both halves against a test account you control. First, send an authorization request with no <code>code_challenge</code> and see whether you get a code or an error. Second, redeem a code from a flow that did include a challenge, without the code verifier, and see whether you get a token. A server that rejects the first request but accepts the second enforces PKCE on one path only, and the second request is the one that matters for interception. Repeat for each registered public client, since enforcement can differ per client.</p>
<p>The recorded vulnerable-mode request for the interceptor with no verifier used port 50601. It carries the code the client's redirect returned and no <code>code_verifier</code>. This is the executed command, reformatted across lines:</p>
<pre><code>curl --noproxy '*' --silent --show-error --include \
  --http1.1 --connect-timeout 1 --max-time 3 --request POST \
  --data 'grant_type=authorization_code&amp;code=Awb_hZZmRxtiNaCBTgiQqw&amp;client_id=demo-public-app&amp;redirect_uri=demoapp%3A%2F%2Fcallback' \
  http://127.0.0.1:50601/token
</code></pre>
<p>The <a href="https://excessionlabs.ai/blog/evidence/pkce/recorded-run/vulnerable-intercept-no-verifier-token.http">recorded response</a> holds the token; headers are omitted:</p>
<pre><code>HTTP/1.0 200 OK

{"access_token": "demo-access-c0c5d7751b00", "token_type": "Bearer"}
</code></pre>
<p>The matching authorization request had carried an S256 <code>code_challenge</code>, as the <a href="https://excessionlabs.ai/blog/evidence/pkce/recorded-run/vulnerable-intercept-no-verifier-authorize.http">authorization response</a> and <code>commands.sh</code> show. A report should name the client, the endpoints, what the authorization request contained, what the interceptor sent and the response it received. This run proves a token was issued without the verifier in a fixture. It gives no severity for a real system, and it does not show that anyone can read a real user's redirect. That depends on the platform.</p>
<h2>Require an S256 code challenge and retest</h2>
<p>The same fixture source holds both modes. In fixed mode the authorization endpoint refuses a request without a challenge or with any method other than S256:</p>
<pre><code>if self.server.mode == "fixed":
    if not challenge:
        return self.redirect_error("invalid_request", "code challenge required", q)
    if method != "S256":
        return self.redirect_error("invalid_request", "transform algorithm not supported", q)
</code></pre>
<p>The token endpoint recomputes the challenge from the verifier it receives and compares it with the one stored for that code:</p>
<pre><code>verifier = form.get("code_verifier", "")
ok = bool(verifier) and verifier.isascii() and hmac.compare_digest(s256(verifier), record["challenge"])
if not ok:
    return self.reply(400, {"error": "invalid_grant"})
</code></pre>
<p>The helper is <code>s256(verifier)</code>, which returns the base64url encoding, without padding, of the SHA-256 hash of the ASCII verifier. The fixture returns <code>invalid_grant</code> for a missing verifier as well as a wrong one. RFC 7636 section 4.6 names <code>invalid_grant</code> for a mismatch and does not name the error for an absent verifier, so another server may answer differently.</p>
<p>The runner restarted the server in fixed mode on port 50617 and repeated the six flows. The interceptor with no verifier got <a href="https://excessionlabs.ai/blog/evidence/pkce/recorded-run/fixed-intercept-no-verifier-token.http">400 with {"error": "invalid_grant"}</a>. The legitimate client's token request returned <a href="https://excessionlabs.ai/blog/evidence/pkce/recorded-run/fixed-legit-s256-token.http">200 with a token</a>. The client with no challenge was sent to <code>demoapp://callback</code> with <code>error=invalid_request</code> and the description code challenge required, as <a href="https://excessionlabs.ai/blog/evidence/pkce/recorded-run/fixed-legacy-client-no-pkce-authorize.http">recorded here</a>. Refusing a request with no challenge breaks old clients. Update them to send a challenge before you enforce it on a live server.</p>
<h2>Inspect the evidence bundle</h2>
<ul>
<li><p><a href="https://excessionlabs.ai/blog/evidence/pkce/recorded-run/results.json">Machine-readable results</a>: all 12 flows, expected and observed outcomes, response filenames and hashes.</p>
</li>
<li><p><a href="https://excessionlabs.ai/blog/evidence/pkce/recorded-run/commands.sh">Exact recorded curl commands</a>: the ports belong to the finished run. Use the runner to start fresh servers.</p>
</li>
<li><p><a href="https://excessionlabs.ai/blog/evidence/pkce/SHA256SUMS">SHA-256 manifest</a>: an integrity check against your downloads, not independent attestation.</p>
</li>
<li><p><a href="https://excessionlabs.ai/blog/evidence/pkce/README.txt">Fixture notes</a>, <a href="https://excessionlabs.ai/blog/evidence/pkce/fixture.py">server source</a> and <a href="https://excessionlabs.ai/blog/evidence/pkce/verify.py">verification source</a>.</p>
</li>
</ul>
<p>The fixture is also on GitHub: <a href="https://github.com/Excession-Labs/web-security-fixtures">web-security-fixtures</a>. The bundle holds the raw response text, and all values in it are synthetic.</p>
<h2>What this experiment does not cover</h2>
<p>Twelve flows against a toy server show one boundary: with PKCE enforced, a code alone cannot be redeemed here. We did not test a real operating system hijacking a custom URI scheme, TLS, code expiry, confidential clients, refresh tokens, or redirect URI matching beyond one exact string. The fixture does not check the length or character set of the challenge or verifier against the 43 to 128 character rule in RFC 7636. It consumes a code on any attempt, so we never raced an attacker and the real client for one code. It also does not test the downgrade attack in section 7.2, where a man in the middle causes an S256 error to push the client to plain. The RFC says clients must not fall back to plain after S256 fails.</p>
<p>The authorization server's token rules come from <a href="https://www.rfc-editor.org/rfc/rfc6749">RFC 6749</a>, which says in section 10.5 that authorization codes must be short lived and single use. PKCE adds to those rules and does not replace them. For checks on the APIs behind the token, see <a href="https://excessionlabs.ai/blog/api-authorization-testing/">API authorization testing</a>.</p>
<p>Excession Labs runs this kind of check in its <a href="https://excessionlabs.ai/security-assessment/">security assessments</a>.</p>
<p>Originally published at <a href="https://excessionlabs.ai/blog/pkce/">https://excessionlabs.ai/blog/pkce/</a> . The test code and recorded output are there.</p>
]]></content:encoded></item><item><title><![CDATA[Row level security: three Postgres mistakes, tested]]></title><description><![CDATA[Row level security (RLS) is a PostgreSQL feature that limits which table rows each role can read or write, based on policies. In a throwaway PostgreSQL…]]></description><link>https://excessionlabs.hashnode.dev/row-level-security-three-postgres-mistakes-tested</link><guid isPermaLink="true">https://excessionlabs.hashnode.dev/row-level-security-three-postgres-mistakes-tested</guid><category><![CDATA[Security]]></category><category><![CDATA[webdev]]></category><category><![CDATA[appsec]]></category><category><![CDATA[PostgreSQL]]></category><dc:creator><![CDATA[Excession Labs]]></dc:creator><pubDate>Wed, 11 Nov 2026 01:18:10 GMT</pubDate><content:encoded><![CDATA[<h1>Row level security: three Postgres mistakes, tested</h1>
<p>Row level security (RLS) is a PostgreSQL feature that limits which table rows each role can read or write, based on policies. In a throwaway PostgreSQL 16.15 cluster, we reproduced three ways it fails without raising an error: a table with no RLS enabled, an application that connects as the table owner, and write policies that never check the tenant. Each let one tenant read or change another tenant's invoices. The fixed configuration denied every attempt and left legitimate access working.</p>
<p>This is a scripted Python and psql experiment with downloadable source and recorded output. No AI agent or model ran the test.</p>
<p>On this page: <a href="https://excessionlabs.ai/blog/row-level-security/#definition">Definition</a> · <a href="https://excessionlabs.ai/blog/row-level-security/#scope">Scope</a> · <a href="https://excessionlabs.ai/blog/row-level-security/#reproduce">Reproduce</a> · <a href="https://excessionlabs.ai/blog/row-level-security/#results">Results</a> · <a href="https://excessionlabs.ai/blog/row-level-security/#using-only">USING and WITH CHECK</a> · <a href="https://excessionlabs.ai/blog/row-level-security/#finding">Verify</a> · <a href="https://excessionlabs.ai/blog/row-level-security/#fix">Fix and retest</a> · <a href="https://excessionlabs.ai/blog/row-level-security/#limits">Limits</a></p>
<h2>What Postgres row level security does</h2>
<p>Row level security (RLS) lets PostgreSQL filter rows for each query. A policy attaches to a table and holds up to two expressions. <code>USING</code> decides which existing rows a role can see or target. <code>WITH CHECK</code> decides which new or changed rows a role may store. The PostgreSQL manual describes the feature in <a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html">Row Security Policies</a> and gives the syntax in <a href="https://www.postgresql.org/docs/current/sql-createpolicy.html">CREATE POLICY</a>.</p>
<p>Supabase RLS uses this same PostgreSQL feature, but we did not test Supabase. Every command here runs on plain PostgreSQL, and the three mistakes apply wherever the same policies are used.</p>
<h2>Scope of the fixture</h2>
<ul>
<li><p><strong>Target:</strong> a cluster that <code>initdb</code> creates in a temporary directory, started on a unix socket inside that directory with <code>listen_addresses</code> empty. There is no TCP listener. The runner deletes the cluster at the end.</p>
</li>
<li><p><strong>Roles:</strong> <code>app_owner</code> owns the table and is not a superuser. <code>app_user</code> is the role the application should use. The <code>postgres</code> superuser only reads state back.</p>
</li>
<li><p><strong>Tenant context:</strong> after connecting, the application runs <code>SET app.tenant_id = 'acme'</code>. Policies read the value with <code>current_setting('app.tenant_id', true)</code>.</p>
</li>
<li><p><strong>Data:</strong> one <code>invoices</code> table with four synthetic rows, two for tenant <code>acme</code> and two for tenant <code>globex</code>.</p>
</li>
</ul>
<p>The fixture is intentionally insecure in its vulnerable configurations and touches nothing outside its temporary directory. The <a href="https://excessionlabs.ai/aup/">Excession Labs Acceptable Use Policy</a> describes authorized use. This article gives no permission to test another system. For related reading on the same boundary, see <a href="https://excessionlabs.ai/blog/tenant-isolation/">tenant isolation</a> and <a href="https://excessionlabs.ai/blog/broken-access-control/">broken access control</a>.</p>
<h2>Run the downloadable fixture</h2>
<p>Download <a href="https://excessionlabs.ai/blog/evidence/row-level-security/fixture.py">fixture.py</a> and <a href="https://excessionlabs.ai/blog/evidence/row-level-security/verify.py">verify.py</a> into one directory. You need Python 3.9 or later and a PostgreSQL install that provides <code>initdb</code>, <code>postgres</code>, <code>pg_isready</code> and <code>psql</code>. We ran PostgreSQL 16.15 only. Run:</p>
<pre><code>python3 verify.py --output rerun
</code></pre>
<p>The output directory must be new. For each configuration the runner creates a fresh database, loads the table, applies the policies, then opens a separate <code>psql</code> session per test. It saves each session's full output and stops and deletes the cluster. No network target, login or paid API is involved.</p>
<p>Our recorded command was <code>python3 verify.py --output recorded-run</code>, run on 10 November 2026 with Python 3.15.2 on macOS arm64. It printed <code>{"checks": 30, "passed": true, "failures": []}</code>. That means every case matched its expected outcome, including the vulnerable ones, and that <code>results.json</code> records <code>"cluster_deleted": true</code>. The cluster used port 53847 on a unix socket that no longer exists.</p>
<h2>Postgres row level security: observed results</h2>
<p>Each mistake has a vulnerable configuration and a fixed one. The positive controls confirm that legitimate access survives the fix. The negative controls confirm that denial happens where the policy says it should.</p>
<p>Recorded psql results in a fresh database per configuration. Tenant is acme unless noted.</p>
<table>
<thead>
<tr>
<th>Test</th>
<th>Role</th>
<th>Policy expects</th>
<th>Vulnerable</th>
<th>Fixed</th>
</tr>
</thead>
<tbody><tr>
<td>1. SELECT with no WHERE, no RLS on table</td>
<td>app_user</td>
<td>2 rows</td>
<td>4 rows: globex rows disclosed</td>
<td>2 rows</td>
</tr>
<tr>
<td>1. SELECT with WHERE tenant_id = 'acme' (control)</td>
<td>app_user</td>
<td>2 rows</td>
<td>2 rows</td>
<td>2 rows</td>
</tr>
<tr>
<td>2. SELECT, RLS enabled, non-owner role (control)</td>
<td>app_user</td>
<td>2 rows</td>
<td>2 rows</td>
<td>2 rows</td>
</tr>
<tr>
<td>2. SELECT, RLS enabled, table owner role</td>
<td>app_owner</td>
<td>2 rows</td>
<td>4 rows: policy skipped</td>
<td>2 rows</td>
</tr>
<tr>
<td>2. SELECT as superuser (documented exemption)</td>
<td>postgres</td>
<td>4 rows</td>
<td>4 rows</td>
<td>4 rows</td>
</tr>
<tr>
<td>3. INSERT a globex row</td>
<td>app_user</td>
<td>denied</td>
<td>INSERT 0 1</td>
<td>ERROR: new row violates row-level security policy</td>
</tr>
<tr>
<td>3. UPDATE ... SET tenant_id = 'globex' WHERE id = 1</td>
<td>app_user</td>
<td>denied</td>
<td>ERROR (SELECT policy applied)</td>
<td>ERROR</td>
</tr>
<tr>
<td>3. UPDATE ... SET tenant_id = 'globex', no WHERE</td>
<td>app_user</td>
<td>denied</td>
<td>UPDATE 3</td>
<td>ERROR</td>
</tr>
<tr>
<td>3. INSERT an acme row (control)</td>
<td>app_user</td>
<td>allowed</td>
<td>INSERT 0 1</td>
<td>INSERT 0 1</td>
</tr>
<tr>
<td>3. UPDATE an acme amount (control)</td>
<td>app_user</td>
<td>allowed</td>
<td>UPDATE 1</td>
<td>UPDATE 1</td>
</tr>
<tr>
<td>3. INSERT amount -5 (control)</td>
<td>app_user</td>
<td>denied</td>
<td>ERROR</td>
<td>ERROR</td>
</tr>
</tbody></table>
<p>The superuser row is not a failure. PostgreSQL documents that superusers and roles with the <code>BYPASSRLS</code> attribute always bypass row security, and that <code>FORCE ROW LEVEL SECURITY</code> does not change that.</p>
<h2>A policy with only USING was not the flaw</h2>
<p>Before the run we expected that a policy with <code>USING</code> and no <code>WITH CHECK</code> would let a tenant write into another tenant. It did not. A single <code>FOR ALL</code> policy with only <code>USING</code> denied both the cross-tenant INSERT and the unconditional UPDATE in the control database. The CREATE POLICY page explains why: if only a <code>USING</code> clause is specified, that clause is used for both the <code>USING</code> and <code>WITH CHECK</code> cases.</p>
<p>The flaw appeared when someone wrote an explicit <code>WITH CHECK</code> that left the tenant out. An explicit check replaces the default, so <code>WITH CHECK (amount &gt;= 0)</code> validated the amount and allowed any tenant value. The UPDATE with a <code>WHERE</code> clause was denied even in the vulnerable configuration. The manual says that when an UPDATE reads columns, for example in its WHERE clause, the SELECT policy is also checked against the existing and the new row. The new globex row failed the SELECT policy. An UPDATE that reads no column skips that check, so the unconditional one above moved all three visible acme rows.</p>
<h2>Verify the finding before you report it</h2>
<p>Capture the session, the role and the table state. Three recorded excerpts follow.</p>
<p><strong>Mistake 1.</strong> Tenant acme runs a query that forgot its <code>WHERE</code> clause on a table without RLS (<a href="https://excessionlabs.ai/blog/evidence/row-level-security/recorded-run/m1_vulnerable-tenant-reads-all.txt">full output</a>):</p>
<pre><code>SET app.tenant_id = 'acme';
SELECT id, tenant_id, amount FROM invoices ORDER BY id;
 id | tenant_id | amount 
----+-----------+--------
  1 | acme      |    100
  2 | acme      |    250
  3 | globex    |    400
  4 | globex    |     75
(4 rows)
</code></pre>
<p><strong>Mistake 2.</strong> RLS is enabled and the policy exists, yet the owner role gets the same four rows (<a href="https://excessionlabs.ai/blog/evidence/row-level-security/recorded-run/m2_vulnerable-owner-reads.txt">full output</a>). The flags in <code>pg_class</code> explain it: <code>relrowsecurity</code> is <code>t</code> and <code>relforcerowsecurity</code> is <code>f</code> (<a href="https://excessionlabs.ai/blog/evidence/row-level-security/recorded-run/m2_vulnerable-rls-flags.txt">output</a>). Also check <code>pg_roles</code> for <code>rolsuper</code> and <code>rolbypassrls</code> on the role your application uses. We did not run that query in the fixture.</p>
<p><strong>Mistake 3.</strong> After the two vulnerable writes, a superuser reads every row (<a href="https://excessionlabs.ai/blog/evidence/row-level-security/recorded-run/m3_vulnerable-final-table-superuser.txt">full output</a>). Row 5 was inserted into globex by an acme session, and the unconditional UPDATE moved rows 1, 2 and 6 to globex:</p>
<pre><code>id | tenant_id | amount 
----+-----------+--------
  1 | globex    |    100
  2 | globex    |    300
  3 | globex    |    400
  4 | globex    |     75
  5 | globex    |    999
  6 | globex    |     10
(6 rows)
</code></pre>
<p>A useful report names the connecting role, the table, the policy definitions, the exact statement and the rows that changed. Read the data back as a second identity. A write that reports success is not proof until you see the row.</p>
<h2>Fix the RLS policy and retest</h2>
<p>Mistake 1 is fixed by enabling RLS and adding a policy. Mistake 2 needs one more statement from <a href="https://excessionlabs.ai/blog/evidence/row-level-security/fixture.py">fixture.py</a>:</p>
<pre><code>ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
</code></pre>
<p>With that line, the owner role returned 2 rows instead of 4. The superuser still returned 4. For mistake 3, the fixed write policies check the tenant as well as the amount:</p>
<pre><code>CREATE POLICY tenant_insert ON invoices FOR INSERT
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true) AND amount &gt;= 0);
CREATE POLICY tenant_update ON invoices FOR UPDATE
  USING (tenant_id = current_setting('app.tenant_id', true))
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true) AND amount &gt;= 0);
</code></pre>
<p>We retested every case against the fixed databases. The cross-tenant INSERT and both cross-tenant UPDATEs returned <code>ERROR: new row violates row-level security policy for table "invoices"</code>. Acme could still insert its own row and change its own amount, and the negative amount was still refused. The final table held five rows with rows 1, 2 and 6 still under acme (<a href="https://excessionlabs.ai/blog/evidence/row-level-security/recorded-run/m3_fixed-final-table-superuser.txt">output</a>).</p>
<p>Our recommendation, which the fixture does not test, is to connect the application as a role that does not own the tables and has no <code>BYPASSRLS</code>. Keep the owner role for migrations. FORCE then acts as a second layer if the wrong role is ever used.</p>
<h2>Inspect the evidence bundle</h2>
<ul>
<li><p><a href="https://excessionlabs.ai/blog/evidence/row-level-security/recorded-run/results.json">Machine-readable results</a>: all 30 cases, roles, SQL, expected and observed outcomes, versions and hashes.</p>
</li>
<li><p><a href="https://excessionlabs.ai/blog/evidence/row-level-security/recorded-run/commands.sh">Recorded psql commands</a>: the socket directory and port belong to the finished run. Use <code>verify.py</code> for a fresh cluster.</p>
</li>
<li><p><a href="https://excessionlabs.ai/blog/evidence/row-level-security/SHA256SUMS">SHA-256 manifest</a>: an integrity check, not independent attestation.</p>
</li>
<li><p><a href="https://excessionlabs.ai/blog/evidence/row-level-security/README.txt">Fixture instructions and limits</a>, <a href="https://excessionlabs.ai/blog/evidence/row-level-security/fixture.py">fixture source</a> and <a href="https://excessionlabs.ai/blog/evidence/row-level-security/verify.py">verification source</a>.</p>
</li>
</ul>
<p>All data is synthetic. No production credentials, customer records or external services were involved.</p>
<h2>What this experiment does not cover</h2>
<p>This is one table in one PostgreSQL version, 16.15, with trust authentication on a unix socket. We did not test DELETE, restrictive policies, several permissive policies on one table, views, security-definer functions, <code>ON CONFLICT</code> upserts, connection pools that reuse a session with a stale <code>app.tenant_id</code>, the <code>BYPASSRLS</code> attribute, or Supabase. The fixed result says nothing about an unrelated schema.</p>
<p>In a real engagement, list each table that holds tenant data, record the role the application connects as, and test reads and writes from two tenants. Retest the same cases after every policy change. Keep failed and inconclusive runs with the successful ones. The same two-identity method applies to HTTP APIs, as shown in <a href="https://excessionlabs.ai/blog/api-authorization-testing/">API authorization testing</a>.</p>
<p>Excession Labs runs this kind of test in its <a href="https://excessionlabs.ai/security-assessment/">security assessments</a>, using a second tenant account to prove each cross-tenant effect. See how a finding like this looks in a <a href="https://excessionlabs.ai/sample-report/">sample report</a>.</p>
<p>Originally published at <a href="https://excessionlabs.ai/blog/row-level-security/">https://excessionlabs.ai/blog/row-level-security/</a> . The test code and recorded output are there.</p>
]]></content:encoded></item><item><title><![CDATA[GraphQL introspection: disabling it leaves the field open]]></title><description><![CDATA[GraphQL introspection lets a client ask the server for its whole schema. Turning it off hides the field list, but a client that knows or guesses a field…]]></description><link>https://excessionlabs.hashnode.dev/graphql-introspection-disabling-it-leaves-the-field-open</link><guid isPermaLink="true">https://excessionlabs.hashnode.dev/graphql-introspection-disabling-it-leaves-the-field-open</guid><category><![CDATA[Security]]></category><category><![CDATA[webdev]]></category><category><![CDATA[appsec]]></category><category><![CDATA[GraphQL]]></category><dc:creator><![CDATA[Excession Labs]]></dc:creator><pubDate>Wed, 11 Nov 2026 01:16:46 GMT</pubDate><content:encoded><![CDATA[<h1>GraphQL introspection: disabling it leaves the field open</h1>
<p>GraphQL introspection lets a client ask the server for its whole schema. Turning it off hides the field list, but a client that knows or guesses a field name can still call it. We sent 24 curl requests to a local GraphQL server with an unlisted admin field. With introspection on, a normal user listed the field and read another user's record. With introspection off the field still answered by name, and the error messages still suggested its name. An authorization check in the resolver blocked the read.</p>
<p>This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.</p>
<p>On this page: <a href="https://excessionlabs.ai/blog/graphql-introspection/#scope">Scope</a> · <a href="https://excessionlabs.ai/blog/graphql-introspection/#reproduce">Reproduce the run</a> · <a href="https://excessionlabs.ai/blog/graphql-introspection/#results">Observed results</a> · <a href="https://excessionlabs.ai/blog/graphql-introspection/#finding">Verify the finding</a> · <a href="https://excessionlabs.ai/blog/graphql-introspection/#fix">Fix and retest</a> · <a href="https://excessionlabs.ai/blog/graphql-introspection/#evidence">Evidence</a> · <a href="https://excessionlabs.ai/blog/graphql-introspection/#limits">Limits</a></p>
<h2>What GraphQL introspection exposes, and the scope of this fixture</h2>
<p>GraphQL introspection lets a client query a service for its own schema. The <a href="https://spec.graphql.org/October2021/#sec-Introspection">GraphQL specification</a> says a service supports introspection over its schema and that the schema is queried using GraphQL itself. Tools use this to build documentation and autocomplete. An attacker uses the same query to list every field, including fields the public client never calls.</p>
<p>Our policy is small. Alice, Bob and an admin are users with synthetic records. Only the admin may call <code>adminUserRecord(id)</code>, which returns any user's email and the last four digits of a made-up national ID. Ordinary users may call <code>me</code>. The public client uses only <code>me</code>, so nobody documented the admin field.</p>
<ul>
<li><p><strong>Target:</strong> four short-lived GraphQL servers bound to <code>127.0.0.1</code> on OS-assigned ports, built on graphql-core 3.2.14.</p>
</li>
<li><p><strong>Requests:</strong> 24 POST requests to <code>/graphql</code>, six per server.</p>
</li>
<li><p><strong>Identities:</strong> public demo bearer values for alice, bob and admin. They are fixed test strings with no value outside this fixture.</p>
</li>
<li><p><strong>Data:</strong> three synthetic records in memory, with <code>example.invalid</code> addresses.</p>
</li>
</ul>
<p>The vulnerable configuration is insecure on purpose, so the server binds only to loopback. Test a real system only with the owner's written permission; the <a href="https://excessionlabs.ai/aup/">Acceptable Use Policy</a> describes authorized use. This article gives no permission to test any other system.</p>
<h2>Run the downloadable fixture</h2>
<p>Download <a href="https://excessionlabs.ai/blog/evidence/graphql-introspection/fixture.py">fixture.py</a> and <a href="https://excessionlabs.ai/blog/evidence/graphql-introspection/verify.py">verify.py</a> into one directory. You need Python 3.9 or later and curl. The fixture also needs <code>graphql-core</code>, so the runner builds a temporary virtual environment, installs the pinned version 3.2.14 from PyPI into it and deletes it afterwards. Nothing is installed globally. Read the source, then run:</p>
<pre><code>python3 verify.py --output rerun
</code></pre>
<p>The output directory must be new. Each curl request bypasses proxies, has a one-second connection timeout and a three-second total timeout, and does not follow redirects. The four server configurations are:</p>
<ul>
<li><p><strong>A, vulnerable:</strong> introspection on, no check in the resolver.</p>
</li>
<li><p><strong>B, introspection off:</strong> the schema query is rejected, no check in the resolver.</p>
</li>
<li><p><strong>C, authorization check:</strong> introspection on, the resolver requires the admin role.</p>
</li>
<li><p><strong>D, check and introspection off:</strong> both changes together.</p>
</li>
</ul>
<p>Our recorded command was <code>python3 verify.py --output recorded-run</code>. It exited with <code>{"checks": 24, "passed": true, "failures": []}</code>. That means every response matched what we expected to see, including the intentionally vulnerable ones. The run started at 22:46 UTC on 10 November 2026 with fixture version 1.0.0, Python 3.15.2, graphql-core 3.2.14 and curl 8.7.1 on macOS ARM64. Ports, timestamps and hashes are in <a href="https://excessionlabs.ai/blog/evidence/graphql-introspection/recorded-run/results.json">results.json</a>. A rerun uses different ports and response dates.</p>
<h2>Observed results: introspection off, field still callable</h2>
<p>Every request in the table used the stated identity. Alice is an ordinary user, so the policy expects her call to <code>adminUserRecord</code> for Bob to fail. The authenticated cases all returned HTTP 200 because the fixture reports GraphQL errors in the body, as many servers do. The anonymous request returned 401.</p>
<p>Observed results from 24 local requests (HTTP status, then what the body contained)</p>
<table>
<thead>
<tr>
<th>Request</th>
<th>A: vulnerable</th>
<th>B: introspection off</th>
<th>C: authorization check</th>
<th>D: check and introspection off</th>
</tr>
</thead>
<tbody><tr>
<td>Alice sends the introspection query</td>
<td>200: lists <code>adminUserRecord</code></td>
<td>200: query rejected</td>
<td>200: lists <code>adminUserRecord</code></td>
<td>200: query rejected</td>
</tr>
<tr>
<td>Alice misspells the field name</td>
<td>200: error suggests <code>adminUserRecord</code></td>
<td>200: error suggests <code>adminUserRecord</code></td>
<td>200: error suggests <code>adminUserRecord</code></td>
<td>200: error suggests <code>adminUserRecord</code></td>
</tr>
<tr>
<td>Alice calls <code>adminUserRecord</code> for Bob</td>
<td>200: Bob's email and ID digits</td>
<td>200: Bob's email and ID digits</td>
<td>200: <code>forbidden</code>, data null</td>
<td>200: <code>forbidden</code>, data null</td>
</tr>
<tr>
<td>Alice calls <code>me</code></td>
<td>200: her own email</td>
<td>200: her own email</td>
<td>200: her own email</td>
<td>200: her own email</td>
</tr>
<tr>
<td>Admin calls <code>adminUserRecord</code> for Bob</td>
<td>200: Bob's record</td>
<td>200: Bob's record</td>
<td>200: Bob's record</td>
<td>200: Bob's record</td>
</tr>
<tr>
<td>Anonymous calls <code>adminUserRecord</code></td>
<td>401</td>
<td>401</td>
<td>401</td>
<td>401</td>
</tr>
</tbody></table>
<p>Row three is the finding. Configuration A leaks Bob's record. Configuration B, with introspection disabled, leaks the same record to anyone who already knows or guesses the name. Rows four and five are the positive controls: legitimate use still works after the fix, and the admin still reaches the field. The 401 row shows that authentication is present in all four configurations, so the gap is the authorization decision that comes after login.</p>
<p>The misspelling row is important if you rely on the introspection setting. In graphql-core 3.2.14, query validation adds a "Did you mean" hint to the unknown-field error, and the hint appeared in all four configurations. The <a href="https://cheatsheetseries.owasp.org/cheatsheets/GraphQL_Cheat_Sheet.html">OWASP GraphQL Cheat Sheet</a> describes the same behavior and notes that attackers can still guess fields when introspection is off. Other servers and versions may differ; we tested only this one.</p>
<h2>Verify the finding before you report it</h2>
<p>In the recorded configuration B run, Alice's request used port 53294. This is the executed command with the body shown on its own line:</p>
<pre><code>curl --noproxy '*' --silent --show-error --include \
  --http1.1 --connect-timeout 1 --max-time 3 --request POST \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer demo-alice-token' \
  --data '{"query": "{ adminUserRecord(id: \"bob\") { id email ssn_last4 } }"}' \
  http://127.0.0.1:53294/graphql
</code></pre>
<p>The <a href="https://excessionlabs.ai/blog/evidence/graphql-introspection/recorded-run/B-introspection-off-call-by-name.http">recorded response</a> body is:</p>
<pre><code>{"data": {"adminUserRecord": {"id": "bob", "email": "bob@example.invalid", "ssn_last4": "2222"}}}
</code></pre>
<p>This is a finding because the token belongs to Alice, whose role is user, and the response contains fields from Bob's record. A 200 status alone proves nothing for GraphQL, because errors travel inside a 200 response. Read the <code>data</code> member and compare it with a control, here the admin call that returns the same record and the <code>me</code> call that returns Alice's own email.</p>
<p>Record the query text, the identity, the owner of the returned record, the policy that should have denied it and the response fields. Here the impact is disclosure of one synthetic record. We did not rate severity for any real system.</p>
<h2>Fix with resolver authorization, then retest</h2>
<p>Disabling introspection is one line in graphql-core. It adds a validation rule that rejects schema queries:</p>
<pre><code>rules = list(specified_rules)
if not self.server.introspection:
    rules.append(NoSchemaIntrospectionCustomRule)
</code></pre>
<p>Configuration B used that rule. It removed the schema listing and left the field callable. The <a href="https://graphql.org/learn/introspection/">graphql.org introspection guide</a> describes disabling introspection in production as common attack-surface reduction and describes it as one part of a broader strategy that also includes authentication and authorization. The OWASP cheat sheet recommends disabling introspection and also says to validate in every resolver that the requester may view the data. The check that fixed this fixture is in the resolver:</p>
<pre><code>def resolve_admin_user_record(root, info, id):
    if info.context["authz"] and ROLES[info.context["identity"]] != "admin":
        raise GraphQLError("forbidden", extensions={"code": "FORBIDDEN"})
    return USERS.get(id)
</code></pre>
<p>In configuration C the runner repeated the same six requests with this check on and introspection still enabled. Alice's call for Bob returned <a href="https://excessionlabs.ai/blog/evidence/graphql-introspection/recorded-run/C-authz-only-call-by-name.http">forbidden with <code>data</code> null</a>. The admin and <code>me</code> controls still returned data, and the anonymous request still returned 401. Configuration D, with both changes, gave the same call results. In C the schema listing still named the field, and the call was still denied, so the resolver check alone protected it.</p>
<p>The check suits this toy policy, which has one role. A real service also needs per-object ownership, tenant membership and rules for nested fields and mutations. In the fixed configurations the unknown-field hint still names <code>adminUserRecord</code>. Once the resolver denies the call, that leak has low impact, but it still tells an attacker the field exists. The cheat sheet lists suggestions for mistyped fields among its introspection items; we did not test a change for that.</p>
<h2>Inspect the evidence bundle</h2>
<ul>
<li><p><a href="https://excessionlabs.ai/blog/evidence/graphql-introspection/recorded-run/results.json">Machine-readable results</a>: all 24 cases with identities, queries, expected and observed outcomes, response filenames and hashes.</p>
</li>
<li><p><a href="https://excessionlabs.ai/blog/evidence/graphql-introspection/recorded-run/commands.sh">Exact recorded curl commands</a>: the ports refer to the finished run. Use the runner to create fresh servers.</p>
</li>
<li><p><a href="https://excessionlabs.ai/blog/evidence/graphql-introspection/SHA256SUMS">SHA-256 manifest</a>: compare downloaded files with ours. This is an integrity check, not independent attestation.</p>
</li>
<li><p><a href="https://excessionlabs.ai/blog/evidence/graphql-introspection/README.txt">Instructions and limitations</a>, <a href="https://excessionlabs.ai/blog/evidence/graphql-introspection/fixture.py">server source</a> and <a href="https://excessionlabs.ai/blog/evidence/graphql-introspection/verify.py">verification source</a>.</p>
</li>
</ul>
<p>All bearer values and records are synthetic. The bundle includes full response text with dates and server headers. No production credentials or customer data were involved.</p>
<h2>What this experiment does not cover</h2>
<p>We tested one toy schema with one query field per role on one library version. We did not test mutations, subscriptions, nested types, batching, aliases, query depth, GraphiQL or other schema-exploration tools, other GraphQL servers, persisted queries, or real error-masking settings. graphql-core disables introspection with a validation rule. Other servers use different switches. Check yours by running the same six requests.</p>
<p>For a real test, list the fields in the schema, mark which roles may call each one, and send each field with a low-privilege account and a second account's object IDs. Test again with introspection off, because, as the OWASP sheet notes, attackers can still guess field names by brute force. Related reading: <a href="https://excessionlabs.ai/blog/api-authorization-testing/">API authorization testing</a>, <a href="https://excessionlabs.ai/blog/broken-access-control/">broken access control</a> and <a href="https://excessionlabs.ai/blog/owasp-api-top-10/">the OWASP API Top 10</a>.</p>
<p>Excession Labs runs this kind of authorization testing in its <a href="https://excessionlabs.ai/security-assessment/">security assessments</a>, including checks of GraphQL fields with and without introspection. See how a finding like this looks in a <a href="https://excessionlabs.ai/sample-report/">sample report</a>.</p>
<p>Originally published at <a href="https://excessionlabs.ai/blog/graphql-introspection/">https://excessionlabs.ai/blog/graphql-introspection/</a> . The test code and recorded output are there.</p>
]]></content:encoded></item><item><title><![CDATA[SSRF: reproduce, bypass, and fix a preview endpoint]]></title><description><![CDATA[Server-side request forgery (SSRF) is a flaw where a server fetches a URL chosen by the caller, so the caller can reach hosts that only the server can…]]></description><link>https://excessionlabs.hashnode.dev/ssrf-reproduce-bypass-and-fix-a-preview-endpoint</link><guid isPermaLink="true">https://excessionlabs.hashnode.dev/ssrf-reproduce-bypass-and-fix-a-preview-endpoint</guid><category><![CDATA[Security]]></category><category><![CDATA[webdev]]></category><category><![CDATA[appsec]]></category><category><![CDATA[ssrf ]]></category><dc:creator><![CDATA[Excession Labs]]></dc:creator><pubDate>Wed, 11 Nov 2026 01:15:10 GMT</pubDate><content:encoded><![CDATA[<h1>SSRF: reproduce, bypass, and fix a preview endpoint</h1>
<p>Server-side request forgery (SSRF) is a flaw where a server fetches a URL chosen by the caller, so the caller can reach hosts that only the server can reach. We built a small URL-preview endpoint, pointed it at a second loopback server that stands in for an internal metadata service, and read a synthetic secret back in real HTTP responses. A denylist of host strings was bypassed by a decimal IP encoding and by a redirect. A resolved-address allowlist with redirects turned off stopped both, and the legitimate preview still worked.</p>
<p>This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.</p>
<p>On this page: <a href="https://excessionlabs.ai/blog/ssrf/#scope">Scope</a> · <a href="https://excessionlabs.ai/blog/ssrf/#reproduce">Reproduce the run</a> · <a href="https://excessionlabs.ai/blog/ssrf/#results">Observed results</a> · <a href="https://excessionlabs.ai/blog/ssrf/#naive">Naive fix and bypasses</a> · <a href="https://excessionlabs.ai/blog/ssrf/#fix">Fix and retest</a> · <a href="https://excessionlabs.ai/blog/ssrf/#limits">Limits</a></p>
<h2>Define the SSRF scope and test fixture</h2>
<p>An SSRF vulnerability exists when a server builds an outbound request from caller input and sends it from its own network position. A URL-preview feature is a common example. The caller supplies a URL, the server fetches it to show a title or thumbnail, and nothing stops the URL from pointing at an internal host.</p>
<p>This fixture has three roles, each a small standard-library HTTP server bound to IPv4 loopback, <code>127.0.0.1</code>:</p>
<ul>
<li><p><strong>target</strong> stands in for the public site the preview feature is meant to fetch. It serves <code>/article</code>, an ordinary page, and <code>/redirect</code>, which answers with a 302.</p>
</li>
<li><p><strong>metadata</strong> stands in for an internal metadata service. It serves one path, <code>/latest/meta-data/secret</code>, and holds one synthetic credential string.</p>
</li>
<li><p><strong>preview</strong> is the endpoint under test: <code>GET /preview?url=&lt;url&gt;</code>. It fetches the given URL and reports what it found. A <code>--mode</code> flag selects one of three defenses: <code>vulnerable</code> (none), <code>naive</code> (block one hostname string), or <code>strict</code> (resolve, check the resolved address against an allowlist, do not follow redirects).</p>
</li>
</ul>
<p>This demonstrates the class CWE describes as <a href="https://cwe.mitre.org/data/definitions/918.html">CWE-918, Server-Side Request Forgery (SSRF)</a>: a web server receives a URL from an upstream component and retrieves it without adequately verifying that the request goes to the intended destination.</p>
<ul>
<li><p><strong>Allowed requests:</strong> four GET requests to the preview endpoint, repeated across three defense modes, twelve in total.</p>
</li>
<li><p><strong>Addresses:</strong> the symbolic name <code>target.test</code>, the literal address <code>127.0.0.1</code>, and the decimal IP encoding <code>2130706433</code> (the same loopback address written as one 32-bit integer). All three resolve to <code>127.0.0.1</code>; the fixture never leaves loopback.</p>
</li>
<li><p><strong>Data:</strong> one synthetic metadata secret and one synthetic article page, both held in memory. There are no customer records, external services, or database writes.</p>
</li>
</ul>
<p>The fixture resolves <code>target.test</code> from a fixed local table instead of real DNS, so the run is offline and repeatable. The literal and decimal forms go through the normal socket layer, which turns both into <code>127.0.0.1</code>. The fixture always listens on loopback. For a staging engagement, agree the hosts, accounts and stopping conditions with the system owner first. The <a href="https://excessionlabs.ai/aup/">Excession Labs Acceptable Use Policy</a> describes authorized use. This article gives no permission to test another system.</p>
<h2>Run the downloadable fixture</h2>
<p>Download <a href="https://excessionlabs.ai/blog/evidence/ssrf/fixture.py">fixture.py</a> and <a href="https://excessionlabs.ai/blog/evidence/ssrf/verify.py">verify.py</a> into the same directory. You need Python 3.9 or later and curl; the Python code uses only the standard library. Inspect the source, then run:</p>
<pre><code>python3 verify.py --output rerun
</code></pre>
<p>The output directory must be new. The runner starts the target and metadata servers once, then starts the preview server three times, once per mode. It sends four curl requests to each mode, saves every response and stops every server before it exits. Each curl request bypasses proxies with <code>--noproxy '*'</code>, has a one-second connection timeout and a three-second total timeout, and does not follow redirects. Any redirect following happens inside the preview server, which is the behavior under test.</p>
<p>Our recorded command was <code>python3 verify.py --output recorded-run</code>. It exited successfully with <code>{"checks": 12, "passed": true, "failures": []}</code> plus the evidence path. That line means all twelve responses matched the expected outcomes, including the vulnerable and bypassed ones.</p>
<p>The run on 10 November 2026 used fixture and runner version 1.0.0, Python 3.15.2 and curl 8.7.1 on macOS ARM64. Exact runtime strings, UTC timestamps, commands, and source SHA-256 hashes are in <a href="https://excessionlabs.ai/blog/evidence/ssrf/recorded-run/results.json">results.json</a>. A rerun will use different ports, timestamps, and response dates.</p>
<h2>Establish positive and negative controls</h2>
<p>Each mode received the same four requests. The first is a legitimate preview of the target article (positive control). The second asks for the metadata secret by the plain loopback address. The third asks for it by the decimal IP encoding. The fourth starts at the target site, which redirects to the metadata secret.</p>
<p>Observed preview-endpoint status and whether the secret leaked, from twelve local requests</p>
<table>
<thead>
<tr>
<th>Request</th>
<th>Vulnerable mode</th>
<th>Naive mode</th>
<th>Strict mode</th>
</tr>
</thead>
<tbody><tr>
<td>Legitimate article fetch</td>
<td>200, no leak</td>
<td>200, no leak</td>
<td>200, no leak</td>
</tr>
<tr>
<td>Direct <code>127.0.0.1</code> metadata fetch</td>
<td>200, secret leaked</td>
<td>403, blocked</td>
<td>403, blocked</td>
</tr>
<tr>
<td>Decimal-encoded metadata fetch</td>
<td>200, secret leaked</td>
<td>200, secret leaked</td>
<td>403, blocked</td>
</tr>
<tr>
<td>Redirect to metadata</td>
<td>200, secret leaked</td>
<td>200, secret leaked</td>
<td>200, no leak (302 not followed)</td>
</tr>
</tbody></table>
<p>The legitimate request returned 200 in every mode, so neither fix broke ordinary use. Naive mode blocked the literal address, but the secret still leaked through the two other request shapes.</p>
<h2>Why blocking one hostname string fails</h2>
<p>Naive mode in <code>fixture.py</code> rejects a request only when the parsed hostname string exactly matches one of <code>127.0.0.1</code>, <code>localhost</code> or <code>0.0.0.0</code>. It checks before it fetches anything:</p>
<pre><code>if mode == "naive" and hostname in self.NAIVE_DENYLIST:
    return self.reply(403, {"reason": "denylisted_host", ...})
</code></pre>
<p>In the recorded run, the decimal-encoded request used this exact URL:</p>
<pre><code>http://2130706433:55472/latest/meta-data/secret
</code></pre>
<p>The string <code>2130706433</code> is not in the denylist, so the check passes. Address resolution then turns that integer into <code>127.0.0.1</code> before the connection. The <a href="https://excessionlabs.ai/blog/evidence/ssrf/recorded-run/naive-encoded.http">recorded response</a> returned status 200 with <code>"secret_leaked": true</code>. Many resolvers also accept hexadecimal (<code>0x7f000001</code>) and short dotted (<code>127.1</code>) forms, and IPv6 has its own loopback and IPv4-mapped forms. A denylist of strings misses spellings it does not list. We did not test these other forms.</p>
<p>The redirect case is a second failure in the same mode. The request starts at <code>http://target.test:55473/redirect</code>, which the denylist does not flag. The target server answers with a 302 to the metadata URL. Naive mode's HTTP client follows the redirect automatically and does not run the hostname check again. The <a href="https://excessionlabs.ai/blog/evidence/ssrf/recorded-run/naive-redirect.http">recorded response</a> shows status 200 with the secret present. The check runs once, before the first hop, and nothing checks later hops.</p>
<h2>Resolve, allowlist, and stop following redirects</h2>
<p>Strict mode makes two changes. It resolves the hostname and checks the resolved <code>(address, port)</code> pair against an explicit allowlist. It also turns off automatic redirect handling in the HTTP client:</p>
<pre><code>resolved_ip = resolve(hostname)
if (resolved_ip, port) not in self.server.allowlist:
    return self.reply(403, {"reason": "destination_not_allowlisted", ...})
opener = urllib.request.build_opener(NoRedirect)
</code></pre>
<p>The allowlist holds one entry, the target server's address and port. <code>target.test</code> on the target's port passes. The same IP on the metadata port fails, because the check uses the resolved address and port instead of the string the caller typed. With the redirect handler off, the 302 goes back to the caller and the server fetches nothing past the first hop. One gap remains. After the check, <code>opener.open(url)</code> resolves the hostname a second time, so the connection is not pinned to the address that was checked. A DNS name that changes its answer between the two lookups (DNS rebinding) could pass the check and connect elsewhere. The <a href="https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html">OWASP SSRF Prevention Cheat Sheet</a> says to validate the resolved destination addresses and then make sure the HTTP client connects only to validated addresses. It also says to turn off redirect following in the client to prevent a bypass of input validation. For destinations known in advance, it recommends an allowlist, and it lists the hex, octal and dword encodings that defeat simple string checks.</p>
<p>The retest sent the same four requests to strict mode. The literal address and the decimal encoding both returned 403 with <code>destination_not_allowlisted</code>, in <a href="https://excessionlabs.ai/blog/evidence/ssrf/recorded-run/strict-literal.http">strict-literal.http</a> and <a href="https://excessionlabs.ai/blog/evidence/ssrf/recorded-run/strict-encoded.http">strict-encoded.http</a>. The redirect request returned 200 with <code>"upstream_status": 302</code> and an empty body, in <a href="https://excessionlabs.ai/blog/evidence/ssrf/recorded-run/strict-redirect.http">strict-redirect.http</a>. The preview endpoint reported the redirect and did not follow it. The legitimate article fetch still returned 200 with the article content.</p>
<h2>Inspect the evidence bundle</h2>
<ul>
<li><p><a href="https://excessionlabs.ai/blog/evidence/ssrf/recorded-run/results.json">Machine-readable results</a>: all twelve cases, modes, requested URLs, expected and observed outcomes, response filenames and hashes.</p>
</li>
<li><p><a href="https://excessionlabs.ai/blog/evidence/ssrf/recorded-run/commands.sh">Exact recorded curl commands</a>: the ports refer to the finished run. Use the runner to create fresh servers.</p>
</li>
<li><p><a href="https://excessionlabs.ai/blog/evidence/ssrf/SHA256SUMS">SHA-256 manifest</a>: compare downloaded sources and evidence. This is an integrity check, not independent attestation.</p>
</li>
<li><p><a href="https://excessionlabs.ai/blog/evidence/ssrf/README.txt">Fixture instructions and limitations</a>, <a href="https://excessionlabs.ai/blog/evidence/ssrf/fixture.py">server source</a>, and <a href="https://excessionlabs.ai/blog/evidence/ssrf/verify.py">verification source</a>.</p>
</li>
</ul>
<p>The metadata value and the article are synthetic demo data. The bundle includes the raw HTTP response text, with dates and server headers. No production credentials or customer data were involved.</p>
<h2>What this experiment does not cover</h2>
<p>Twelve requests against three toy servers show one SSRF path and two bypasses of one naive defense. We did not test DNS rebinding (which the strict mode does not block, as noted above), hex, octal, IPv6 or IPv4-mapped forms, cloud metadata quirks, POST-based or webhook-style SSRF, out-of-band confirmation of blind SSRF, or production network segmentation. A fixture confined to loopback cannot show what a real internal boundary would stop. It shows that a check on the host string fails where a check on the resolved address holds, for these four requests.</p>
<p>For a real engagement, list every feature that accepts a URL, a hostname or anything used to build one: webhooks, importers, PDF or image renderers, link previews and SSO metadata URLs. Test each one with encoded addresses and with a redirect chain. The redirect bypass here overlaps with <a href="https://excessionlabs.ai/blog/open-redirect/">open redirect</a> testing. A server that trusts an attacker-controlled <code>Host</code> header has a related failure, covered in <a href="https://excessionlabs.ai/blog/host-header-injection/">host header injection</a>. Authorization checks on the object itself are covered in <a href="https://excessionlabs.ai/blog/api-authorization-testing/">authorization testing for APIs</a>. Keep failed and inconclusive attempts with the successful ones, and rerun the same cases after each change to the fetching code.</p>
<p>Excession Labs tests outbound-request handling like this in its <a href="https://excessionlabs.ai/security-assessment/">security assessments</a>. See how a finding like this looks in a <a href="https://excessionlabs.ai/sample-report/">sample report</a>. Download this finding written up as a <a href="https://excessionlabs.ai/sample-report/ssrf-finding.pdf">one-page sample report (PDF)</a>.</p>
<p>Originally published at <a href="https://excessionlabs.ai/blog/ssrf/">https://excessionlabs.ai/blog/ssrf/</a> . The test code and recorded output are there.</p>
]]></content:encoded></item></channel></rss>