Security: SSRF Prevention for Checkout Redirect URLs
Security: SSRF Prevention for Checkout Redirect URLs
Release: v1.0.27 · Control: SEC-10 · Category: OWASP
Background
The POST /v1/checkout/sessions endpoint accepts two caller-supplied redirect URLs:
| Field | Purpose |
|---|---|
success_url | Where the customer is sent after a successful payment |
cancel_url | Where the customer is sent if they abandon the checkout |
Prior to v1.0.27, these fields were validated only with z.string().url(), which confirms URL syntax but places no restriction on the destination host.
The Risk
z.string().url() accepts addresses such as:
http://localhost/admin
http://127.0.0.1:8080/internal
http://169.254.169.254/latest/meta-data/ # AWS/GCP instance metadata
http://10.0.0.1/
http://192.168.1.1/
Both URLs are stored in the database at session creation time. The immediate code path uses NextResponse.redirect() — a browser-level 303 redirect — so no outbound server request is made today. However, the stored URLs represent a latent Server-Side Request Forgery (SSRF) risk: if any future server-side logic consumes these URLs (e.g. posting a webhook notification to the merchant's success_url), an attacker could direct internal requests to the instance metadata service or private network infrastructure.
Fix Applied
A isSafeUrl() validation helper is now enforced on both success_url and cancel_url before the checkout session is persisted.
Validation rules
A URL is considered safe only when all of the following are true:
- Protocol is
https:— plain HTTP URLs are rejected. - Hostname is not localhost — the literal string
localhostis blocked. - Hostname is not a loopback address —
127.0.0.0/8is blocked. - Hostname is not a link-local address —
169.254.0.0/16is blocked (covers the AWS EC2 and GCP instance metadata endpoints). - Hostname is not an RFC-1918 private range —
10.0.0.0/8,172.16.0.0/12, and192.168.0.0/16are all blocked.
Helper signature
function isSafeUrl(url: string): boolean {
const u = new URL(url);
return u.protocol === 'https:' && !isInternalHost(u.hostname);
}
If either success_url or cancel_url fails this check, the request is rejected with an HTTP 400 response before any database write occurs.
Impact on Existing Integrations
| Scenario | Behaviour |
|---|---|
success_url is a public https:// domain | ✅ No change |
success_url uses http:// | ❌ Rejected — upgrade to https:// |
| Either URL points to a private or loopback address | ❌ Rejected |
| Either URL points to the instance metadata endpoint | ❌ Rejected |
Action required for local development: If you use
http://localhostredirect URLs in a local or staging environment, update your test sessions to use a tunnelledhttps://URL (e.g. from ngrok or Cloudflare Tunnel) or configure an environment-specific bypass for non-production builds.
Affected Endpoint
POST /v1/checkout/sessions
Fields validated: success_url, cancel_url
File: src/app/api/v1/checkout/sessions/route.ts