Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions src/api/functions/subscriberCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { createHmac } from "crypto";

Check warning on line 1 in src/api/functions/subscriberCallback.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:crypto` over `crypto`.

See more on https://sonarcloud.io/project/issues?id=acm-uiuc_core&issues=AZ4j1gOuz_qhAFfqmu1_&open=AZ4j1gOuz_qhAFfqmu1_&pullRequest=706
import { lookup } from "dns/promises";

Check warning on line 2 in src/api/functions/subscriberCallback.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:dns/promises` over `dns/promises`.

See more on https://sonarcloud.io/project/issues?id=acm-uiuc_core&issues=AZ4j1gOuz_qhAFfqmu2A&open=AZ4j1gOuz_qhAFfqmu2A&pullRequest=706
import { isIP } from "net";

Check warning on line 3 in src/api/functions/subscriberCallback.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:net` over `net`.

See more on https://sonarcloud.io/project/issues?id=acm-uiuc_core&issues=AZ4j1gOuz_qhAFfqmu2B&open=AZ4j1gOuz_qhAFfqmu2B&pullRequest=706
import type { ValidLoggers } from "api/types.js";

const PRIVATE_IPV4_RANGES: { cidr: string; mask: number }[] = [
{ cidr: "10.0.0.0", mask: 8 },

Check warning on line 7 in src/api/functions/subscriberCallback.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure using a hardcoded IP address 10.0.0.0 is safe here.

See more on https://sonarcloud.io/project/issues?id=acm-uiuc_core&issues=AZ4j1gOuz_qhAFfqmu2C&open=AZ4j1gOuz_qhAFfqmu2C&pullRequest=706

Check notice

Code scanning / SonarCloud

IP addresses should not be hardcoded Low

Make sure using a hardcoded IP address 10.0.0.0 is safe here. See more on SonarQube Cloud
{ cidr: "172.16.0.0", mask: 12 },

Check warning on line 8 in src/api/functions/subscriberCallback.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure using a hardcoded IP address 172.16.0.0 is safe here.

See more on https://sonarcloud.io/project/issues?id=acm-uiuc_core&issues=AZ4j1gOuz_qhAFfqmu2D&open=AZ4j1gOuz_qhAFfqmu2D&pullRequest=706

Check notice

Code scanning / SonarCloud

IP addresses should not be hardcoded Low

Make sure using a hardcoded IP address 172.16.0.0 is safe here. See more on SonarQube Cloud
{ cidr: "192.168.0.0", mask: 16 },

Check warning on line 9 in src/api/functions/subscriberCallback.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure using a hardcoded IP address 192.168.0.0 is safe here.

See more on https://sonarcloud.io/project/issues?id=acm-uiuc_core&issues=AZ4j1gOuz_qhAFfqmu2E&open=AZ4j1gOuz_qhAFfqmu2E&pullRequest=706

Check notice

Code scanning / SonarCloud

IP addresses should not be hardcoded Low

Make sure using a hardcoded IP address 192.168.0.0 is safe here. See more on SonarQube Cloud
{ cidr: "127.0.0.0", mask: 8 },
{ cidr: "169.254.0.0", mask: 16 },

Check warning on line 11 in src/api/functions/subscriberCallback.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure using a hardcoded IP address 169.254.0.0 is safe here.

See more on https://sonarcloud.io/project/issues?id=acm-uiuc_core&issues=AZ4j1gOuz_qhAFfqmu2F&open=AZ4j1gOuz_qhAFfqmu2F&pullRequest=706

Check notice

Code scanning / SonarCloud

IP addresses should not be hardcoded Low

Make sure using a hardcoded IP address 169.254.0.0 is safe here. See more on SonarQube Cloud
{ cidr: "0.0.0.0", mask: 8 },
{ cidr: "100.64.0.0", mask: 10 },

Check warning on line 13 in src/api/functions/subscriberCallback.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure using a hardcoded IP address 100.64.0.0 is safe here.

See more on https://sonarcloud.io/project/issues?id=acm-uiuc_core&issues=AZ4j1gOuz_qhAFfqmu2G&open=AZ4j1gOuz_qhAFfqmu2G&pullRequest=706

Check notice

Code scanning / SonarCloud

IP addresses should not be hardcoded Low

Make sure using a hardcoded IP address 100.64.0.0 is safe here. See more on SonarQube Cloud
];
Comment on lines +6 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

SSRF coverage gap: missing special-use IPv4 ranges.

The blocklist omits several non-routable / special-use ranges that should not be valid callback destinations:

  • 224.0.0.0/4 — multicast
  • 240.0.0.0/4 — reserved/future
  • 198.18.0.0/15 — benchmarking (RFC 2544)
  • 192.0.0.0/24 — IETF protocol assignments
  • 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 — documentation (RFC 5737)

Any of these reaching fetch could either fail unexpectedly or, on misconfigured networks, reach internal services.

🛡️ Proposed additions
 const PRIVATE_IPV4_RANGES: { cidr: string; mask: number }[] = [
   { cidr: "10.0.0.0", mask: 8 },
   { cidr: "172.16.0.0", mask: 12 },
   { cidr: "192.168.0.0", mask: 16 },
   { cidr: "127.0.0.0", mask: 8 },
   { cidr: "169.254.0.0", mask: 16 },
   { cidr: "0.0.0.0", mask: 8 },
   { cidr: "100.64.0.0", mask: 10 },
+  { cidr: "224.0.0.0", mask: 4 },   // multicast
+  { cidr: "240.0.0.0", mask: 4 },   // reserved
+  { cidr: "198.18.0.0", mask: 15 }, // benchmarking
+  { cidr: "192.0.0.0", mask: 24 },  // IETF protocol assignments
+  { cidr: "192.0.2.0", mask: 24 },  // TEST-NET-1
+  { cidr: "198.51.100.0", mask: 24 }, // TEST-NET-2
+  { cidr: "203.0.113.0", mask: 24 }, // TEST-NET-3
 ];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const PRIVATE_IPV4_RANGES: { cidr: string; mask: number }[] = [
{ cidr: "10.0.0.0", mask: 8 },
{ cidr: "172.16.0.0", mask: 12 },
{ cidr: "192.168.0.0", mask: 16 },
{ cidr: "127.0.0.0", mask: 8 },
{ cidr: "169.254.0.0", mask: 16 },
{ cidr: "0.0.0.0", mask: 8 },
{ cidr: "100.64.0.0", mask: 10 },
];
const PRIVATE_IPV4_RANGES: { cidr: string; mask: number }[] = [
{ cidr: "10.0.0.0", mask: 8 },
{ cidr: "172.16.0.0", mask: 12 },
{ cidr: "192.168.0.0", mask: 16 },
{ cidr: "127.0.0.0", mask: 8 },
{ cidr: "169.254.0.0", mask: 16 },
{ cidr: "0.0.0.0", mask: 8 },
{ cidr: "100.64.0.0", mask: 10 },
{ cidr: "224.0.0.0", mask: 4 }, // multicast
{ cidr: "240.0.0.0", mask: 4 }, // reserved
{ cidr: "198.18.0.0", mask: 15 }, // benchmarking
{ cidr: "192.0.0.0", mask: 24 }, // IETF protocol assignments
{ cidr: "192.0.2.0", mask: 24 }, // TEST-NET-1
{ cidr: "198.51.100.0", mask: 24 }, // TEST-NET-2
{ cidr: "203.0.113.0", mask: 24 }, // TEST-NET-3
];
🧰 Tools
🪛 GitHub Check: SonarCloud

[notice] 7-7: IP addresses should not be hardcoded

Make sure using a hardcoded IP address 10.0.0.0 is safe here.

See more on SonarQube Cloud


[notice] 8-8: IP addresses should not be hardcoded

Make sure using a hardcoded IP address 172.16.0.0 is safe here.

See more on SonarQube Cloud


[notice] 9-9: IP addresses should not be hardcoded

Make sure using a hardcoded IP address 192.168.0.0 is safe here.

See more on SonarQube Cloud


[notice] 11-11: IP addresses should not be hardcoded

Make sure using a hardcoded IP address 169.254.0.0 is safe here.

See more on SonarQube Cloud


[notice] 13-13: IP addresses should not be hardcoded

Make sure using a hardcoded IP address 100.64.0.0 is safe here.

See more on SonarQube Cloud

🪛 GitHub Check: SonarCloud Code Analysis

[warning] 8-8: Make sure using a hardcoded IP address 172.16.0.0 is safe here.

See more on https://sonarcloud.io/project/issues?id=acm-uiuc_core&issues=AZ4j1gOuz_qhAFfqmu2D&open=AZ4j1gOuz_qhAFfqmu2D&pullRequest=706


[warning] 13-13: Make sure using a hardcoded IP address 100.64.0.0 is safe here.

See more on https://sonarcloud.io/project/issues?id=acm-uiuc_core&issues=AZ4j1gOuz_qhAFfqmu2G&open=AZ4j1gOuz_qhAFfqmu2G&pullRequest=706


[warning] 9-9: Make sure using a hardcoded IP address 192.168.0.0 is safe here.

See more on https://sonarcloud.io/project/issues?id=acm-uiuc_core&issues=AZ4j1gOuz_qhAFfqmu2E&open=AZ4j1gOuz_qhAFfqmu2E&pullRequest=706


[warning] 7-7: Make sure using a hardcoded IP address 10.0.0.0 is safe here.

See more on https://sonarcloud.io/project/issues?id=acm-uiuc_core&issues=AZ4j1gOuz_qhAFfqmu2C&open=AZ4j1gOuz_qhAFfqmu2C&pullRequest=706


[warning] 11-11: Make sure using a hardcoded IP address 169.254.0.0 is safe here.

See more on https://sonarcloud.io/project/issues?id=acm-uiuc_core&issues=AZ4j1gOuz_qhAFfqmu2F&open=AZ4j1gOuz_qhAFfqmu2F&pullRequest=706

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/functions/subscriberCallback.ts` around lines 6 - 14, Update the
PRIVATE_IPV4_RANGES blocklist to include additional special-use and non-routable
IPv4 ranges so callbacks cannot target them: add entries for 224.0.0.0/4,
240.0.0.0/4, 198.18.0.0/15, 192.0.0.0/24, and the documentation ranges
192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24; ensure the same { cidr: "...",
mask: N } shape is used and update any unit tests or validation logic that
iterates over PRIVATE_IPV4_RANGES (e.g., the callback URL validation path) to
treat these as blocked destinations.


const ipv4ToInt = (ip: string): number =>
ip.split(".").reduce((acc, part) => (acc << 8) + Number(part), 0) >>> 0;

const isPrivateIPv4 = (ip: string): boolean => {
const ipInt = ipv4ToInt(ip);
return PRIVATE_IPV4_RANGES.some(({ cidr, mask }) => {
const cidrInt = ipv4ToInt(cidr);
const maskBits = mask === 0 ? 0 : (~0 << (32 - mask)) >>> 0;
return (ipInt & maskBits) === (cidrInt & maskBits);
});
};

const isPrivateIPv6 = (ip: string): boolean => {
const normalized = ip.toLowerCase();
if (normalized === "::1") {
return true;
}
if (normalized.startsWith("fc") || normalized.startsWith("fd")) {
return true; // fc00::/7
}
if (
normalized.startsWith("fe8") ||
normalized.startsWith("fe9") ||
normalized.startsWith("fea") ||
normalized.startsWith("feb")
) {
return true; // fe80::/10
}
return false;
};
Comment on lines +28 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

IPv6 SSRF bypass: IPv4-mapped addresses not detected.

isPrivateIPv6 does not handle IPv4-mapped IPv6 addresses (::ffff:0:0/96). A hostname with an AAAA record like ::ffff:127.0.0.1 or ::ffff:10.0.0.1 will return from isIP() as version 6 and pass this check, then fetch will route the request to the embedded IPv4 address — defeating the IPv4 private-range guard entirely.

Also worth covering for completeness:

  • :: (unspecified)
  • ::ffff:0:0/96 (IPv4-mapped)
  • 64:ff9b::/96 (NAT64)
  • 2001:db8::/32 (documentation)
🛡️ Suggested approach

When isIP(host) === 6, extract the embedded IPv4 from IPv4-mapped forms and re-run isPrivateIPv4 on it before doing the prefix-based checks. Example sketch:

 const isPrivateIPv6 = (ip: string): boolean => {
   const normalized = ip.toLowerCase();
+  if (normalized === "::" || normalized === "::1") {
+    return true;
+  }
+  // IPv4-mapped/compat: ::ffff:a.b.c.d or ::ffff:0:a.b.c.d
+  const mappedMatch = normalized.match(
+    /^::(?:ffff(?::0)?:)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/,
+  );
+  if (mappedMatch) {
+    return isPrivateIPv4(mappedMatch[1]);
+  }
-  if (normalized === "::1") {
-    return true;
-  }
   if (normalized.startsWith("fc") || normalized.startsWith("fd")) {
     return true;
   }
   if (
     normalized.startsWith("fe8") ||
     normalized.startsWith("fe9") ||
     normalized.startsWith("fea") ||
     normalized.startsWith("feb")
   ) {
     return true;
   }
+  if (normalized.startsWith("2001:db8:") || normalized.startsWith("64:ff9b:")) {
+    return true;
+  }
   return false;
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/functions/subscriberCallback.ts` around lines 28 - 45, The
isPrivateIPv6 function misses IPv4-mapped and other special IPv6 ranges; update
isPrivateIPv6 to detect and handle the following: treat "::" as
private/unspecified, detect IPv4-mapped addresses in the ::ffff:0:0/96 space,
extract the embedded IPv4 and call the existing isPrivateIPv4(...) to reject
mapped private IPv4s, and also add explicit checks for NAT64 (64:ff9b::/96) and
the documentation range (2001:db8::/32) alongside the existing fc00::/7 and
fe80::/10 checks; reference the isPrivateIPv6 function and the isPrivateIPv4
helper when making these changes.


export class SubscriberCallbackBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = "SubscriberCallbackBlockedError";
}
}

export const assertCallbackUrlIsExternal = async (
url: string,
): Promise<void> => {
const parsed = new URL(url);
if (parsed.protocol !== "https:") {
throw new SubscriberCallbackBlockedError("callbackUrl must use https://");
}
const host = parsed.hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase();
if (host === "localhost") {
throw new SubscriberCallbackBlockedError(
"callbackUrl host is not reachable.",
);
}
const ipVersion = isIP(host);
if (ipVersion === 4 && isPrivateIPv4(host)) {
throw new SubscriberCallbackBlockedError(
"callbackUrl resolves to a private IPv4 range.",
);
}
if (ipVersion === 6 && isPrivateIPv6(host)) {
throw new SubscriberCallbackBlockedError(
"callbackUrl resolves to a private IPv6 range.",
);
}
if (ipVersion !== 0) {
return;
}
const resolved = await lookup(host, { all: true });
if (resolved.length === 0) {
throw new SubscriberCallbackBlockedError(
`callbackUrl host ${host} did not resolve.`,
);
}
for (const entry of resolved) {
if (entry.family === 4 && isPrivateIPv4(entry.address)) {
throw new SubscriberCallbackBlockedError(
`callbackUrl host ${host} resolves to private IPv4 ${entry.address}.`,
);
}
if (entry.family === 6 && isPrivateIPv6(entry.address)) {
throw new SubscriberCallbackBlockedError(
`callbackUrl host ${host} resolves to private IPv6 ${entry.address}.`,
);
}
}
Comment on lines +81 to +98

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

DNS rebinding SSRF mitigation Node.js undici fetch pinned IP

💡 Result:

To mitigate DNS rebinding attacks in SSRF scenarios when using Node.js undici fetch, create a custom Agent with a safe DNS lookup function in the connect options. This intercepts DNS resolution after the initial lookup, allowing you to validate the resolved IP address against private ranges, loopback, etc., before connecting. This effectively pins and checks the IP, preventing rebinding where a domain resolves to a safe IP first then rebinds to internal IPs.[1][2] Use a library like ipaddr.js for robust IP range classification.[1] Example code: import { Agent } from 'undici'; import { lookup } from 'dns'; import ipaddr from 'ipaddr.js'; const safeLookup = (hostname, options, callback) => { lookup(hostname, options, (err, address, family) => { if (err) { return callback(err, address, family); } if (!ipaddr.isValid(address)) { return callback(new Error('Invalid address'), address, family); } const ipObj = ipaddr.parse(address); if (ipObj.range!== 'unicast') { // Blocks private, loopback, link-local, etc. return callback(new Error('Not Allowed'), address, family); } return callback(null, address, family); }); }; const safeAgent = new Agent({ connect: { lookup: safeLookup, }, }); // Usage with fetch const response = await fetch('http://example.com', { dispatcher: safeAgent }); This approach is recommended in undici discussions for SSRF protection and directly counters DNS rebinding by validating every resolution.[1][3] Undici also has a DNS interceptor (added in recent versions via PR #3490, merged 2024) for caching/round-robin, but custom lookup in connect is the standard for security validation.[4][5] OWASP emphasizes resolving hostname, classifying IP, and rejecting unsafe ranges, which this implements.[2] For known/trusted hosts only, resolve once upfront, extract IP, and use a Client with that fixed IP as origin (bypassing DNS entirely).[6]

Citations:


🏁 Script executed:

# First, find the file and understand its full context
cat -n src/api/functions/subscriberCallback.ts | head -150

Repository: acm-uiuc/core

Length of output: 5343


🏁 Script executed:

# See the complete fetch call and its options
sed -n '145,160p' src/api/functions/subscriberCallback.ts

Repository: acm-uiuc/core

Length of output: 455


DNS rebinding TOCTOU vulnerability: validated host may resolve to a different IP at fetch time.

assertCallbackUrlIsExternal resolves the hostname via lookup() and validates the resolved addresses, but fetch (Node's undici) performs its own independent DNS resolution. An attacker controlling DNS for the callback host can return a public IP for the validation lookup and a private IP (with TTL=0) for the subsequent fetch — bypassing the entire IP allow-list.

Repro pattern: attacker registers a payment link with callbackUrl=https://evil.example/. When the webhook fires, evil.example returns 203.0.113.10 for the first resolution and 127.0.0.1 (or 169.254.169.254 for cloud metadata) for the second.

Recommended mitigation: pin the validated IP and direct fetch to it explicitly, preserving the Host header via SNI. With undici this looks like:

import { Agent } from "undici";

// after lookup() + validation, pick one address (e.g., resolved[0])
const dispatcher = new Agent({
  connect: { lookup: (_host, opts, cb) => cb(null, validatedIp, validatedFamily) },
});
await fetch(callbackUrl, { /* ... */, dispatcher });

Alternatively, use undici.request and supply the resolved IP directly while setting the Host header to the original hostname.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/functions/subscriberCallback.ts` around lines 81 - 98,
assertCallbackUrlIsExternal currently validates DNS via lookup() but allows
undici's fetch to perform a separate DNS resolution (TOCTOU); after validating
the resolved addresses (resolved and isPrivateIPv4/isPrivateIPv6 checks), pick a
single validated IP and family (e.g., resolved[0]) and force fetch to use that
IP by creating an undici Agent/dispatcher that overrides connect.lookup to
return the pinned IP/family, then pass that dispatcher to fetch (or use
undici.request with the IP and set the Host header to the original host) so the
Host/SNI remain the hostname while the TCP connection goes to the validated IP;
update the fetch call in the code that follows assertCallbackUrlIsExternal to
use this dispatcher and ensure any error paths still throw
SubscriberCallbackBlockedError as before.

};

export const signCallbackBody = ({
body,
signingSecret,
timestamp,
}: {
body: string;
signingSecret: string;
timestamp: number;
}): string => {
const hmac = createHmac("sha256", signingSecret);
hmac.update(`${timestamp}.${body}`);
return hmac.digest("hex");
};

export type DeliverSubscriberCallbackParams = {
callbackUrl: string;
signingSecret: string;
body: object;
eventId: string;
logger: ValidLoggers;
timeoutMs?: number;
};

export const deliverSubscriberCallback = async ({
callbackUrl,
signingSecret,
body,
eventId,
logger,
timeoutMs = 5000,
}: DeliverSubscriberCallbackParams): Promise<void> => {
await assertCallbackUrlIsExternal(callbackUrl);
const serialized = JSON.stringify(body);
const timestamp = Math.floor(Date.now() / 1000);
const signature = signCallbackBody({
body: serialized,
signingSecret,
timestamp,
});

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
let response: Response;
try {
response = await fetch(callbackUrl, {
method: "POST",
body: serialized,
headers: {
"Content-Type": "application/json",
"X-ACM-Signature": `t=${timestamp},v1=${signature}`,
"X-ACM-Event-Id": eventId,
},
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}

if (!response.ok) {
const truncated = await response
.text()
.then((t) => t.slice(0, 256))
.catch(() => "");
logger.warn(
{ status: response.status, callbackUrl, eventId, body: truncated },
"Subscriber callback returned non-2xx; will retry.",
);
throw new Error(
`Subscriber callback returned ${response.status} from ${callbackUrl}`,
);
}
logger.info(
{ status: response.status, callbackUrl, eventId },
"Subscriber callback delivered.",
);
};
Loading