Skip to content

perf: Parallelize payment API checks in GetImportConfig usecase#1173

Merged
chavda-bhavik merged 3 commits intonextfrom
claude/kind-tharp
Mar 12, 2026
Merged

perf: Parallelize payment API checks in GetImportConfig usecase#1173
chavda-bhavik merged 3 commits intonextfrom
claude/kind-tharp

Conversation

@chavda-bhavik
Copy link
Copy Markdown
Member

@chavda-bhavik chavda-bhavik commented Mar 11, 2026

Summary

  • The `GetImportConfig` usecase was making 18 sequential external payment API calls (`checkEvent`) when the import widget opened, causing significant latency
  • Replaced the sequential `for-of + await` loop with `Promise.allSettled` to run all 18 checks concurrently
  • Also moved the template DB lookup to run in parallel with the user email lookup via `Promise.all`

Performance impact: Widget open latency reduced from ~18× API_latency to ~1× API_latency (the slowest single call)

Test plan

  • Open import widget and verify it loads significantly faster
  • Verify all feature flags (branding, image import, etc.) still work correctly
  • Verify template not found error still throws when invalid templateId is passed

Replace sequential for-of loop with Promise.allSettled to fire all 18
paymentAPIService.checkEvent calls concurrently. Also run the user email
lookup and template fetch in parallel via Promise.all.

This reduces widget open latency from ~18x(API_latency) to ~1x(API_latency).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@nx-cloud
Copy link
Copy Markdown

nx-cloud Bot commented Mar 11, 2026

View your CI Pipeline Execution ↗ for commit 5a73028

Command Status Duration Result
nx run-many --target=build --all ✅ Succeeded 18s View ↗

☁️ Nx Cloud last updated this comment at 2026-03-12 17:56:21 UTC

Comment on lines +126 to +131
const response = await axios.get(url, {
timeout: 15000,
maxRedirects: 3,
headers,
responseType: 'stream',
});

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.

Copilot Autofix

AI about 2 months ago

To fix the problem, the URL used in the outgoing axios requests must be constrained so that untrusted clients cannot force connections to internal or otherwise sensitive endpoints. In general, this is achieved by validating the destination host/IP against a strict allow-list and/or doing robust SSRF-safe checks on the resolved IP addresses (not just the string hostname), including IPv4 and IPv6, and rejecting loopback, link-local, and private ranges. Where arbitrary hosts must be allowed, the validation must at least resolve DNS and exclude internal networks.

The best way to fix this code with minimal behavioral change is to strengthen isUrlSafe and ensure getMimeType uses it consistently. We can: (1) add a small helper that resolves the hostname (via dns.promises.lookup) to an IP, (2) add functions that detect private/loopback/link-local ranges using ipaddr.js, a well-known library designed for this purpose, and (3) update isUrlSafe to use these helpers so that even if an attacker supplies a hostname that resolves to an internal IP (e.g., via DNS rebinding or internal DNS names), the request is rejected. We will continue to allow http and https schemes and public IPs/hostnames, preserving intended functionality as much as possible.

Concretely, in apps/api/src/app/shared/helpers/common.helper.ts we will:

  • Add an import for ipaddr.js and Node’s dns promises.
  • Introduce an internal async helper resolveAndCheckHost(parsedUrl: URL): Promise<boolean> that DNS-resolves the host, parses the IP with ipaddr.js, and returns false if it is loopback, private, link-local, or otherwise non-globally-routable.
  • Change isUrlSafe from synchronous to async and implement it using this helper: keep the existing scheme checks and localhost literal checks, and add IP-classification using ipaddr.js. Because isUrlSafe becomes async, we will update getMimeType to await isUrlSafe(url) before proceeding.
  • Keep the rest of getMimeType unchanged.

No changes are required in the controller or use case snippets for this specific SSRF fix.

Suggested changeset 2
apps/api/src/app/shared/helpers/common.helper.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/api/src/app/shared/helpers/common.helper.ts b/apps/api/src/app/shared/helpers/common.helper.ts
--- a/apps/api/src/app/shared/helpers/common.helper.ts
+++ b/apps/api/src/app/shared/helpers/common.helper.ts
@@ -3,6 +3,8 @@
 import { BadRequestException } from '@nestjs/common';
 import { APIMessages } from '../constants';
 import { PaginationResult, Defaults, FileMimeTypesEnum } from '@impler/shared';
+import * as ipaddr from 'ipaddr.js';
+import { promises as dns } from 'dns';
 
 export function validateNotFound(data: any, entityName: 'upload' | 'template'): boolean {
   if (data) return true;
@@ -98,7 +100,8 @@
 }
 
 export const getMimeType = async (url: string): Promise<string | null> => {
-  if (!isUrlSafe(url)) {
+  const isSafe = await isUrlSafe(url);
+  if (!isSafe) {
     throw new BadRequestException('Invalid URL');
   }
 
@@ -145,7 +148,7 @@
   }
 };
 
-export function isUrlSafe(url: string): boolean {
+async function isUrlSafe(url: string): Promise<boolean> {
   try {
     const parsedUrl = new URL(url);
 
@@ -154,27 +157,82 @@
       return false;
     }
 
-    // Block localhost and private IPs (except in development)
-    const hostname = parsedUrl.hostname.toLowerCase();
+    const hostname = parsedUrl.hostname;
+    const lowerHostname = hostname.toLowerCase();
     const isDevelopment = process.env.NODE_ENV === 'dev';
 
-    if (!isDevelopment && ['localhost', '127.0.0.1', '::1'].includes(hostname)) {
+    // Block obvious localhost names and literals (except in development)
+    if (
+      !isDevelopment &&
+      (lowerHostname === 'localhost' ||
+        lowerHostname === '127.0.0.1' ||
+        lowerHostname === '::1')
+    ) {
       return false;
     }
 
-    if (
-      hostname.startsWith('10.') ||
-      hostname.startsWith('192.168.') ||
-      hostname.startsWith('169.254.') ||
-      /^172\.(1[6-9]|2[0-9]|3[01])\./.test(hostname)
-    ) {
+    // If hostname is already an IP literal, check it directly
+    const isIpLiteral = ipaddr.isValid(hostname);
+    if (isIpLiteral) {
+      const addr = ipaddr.parse(hostname);
+      if (isPrivateAddress(addr)) {
+        return false;
+      }
+
+      return true;
+    }
+
+    // Resolve DNS and validate the resulting address
+   try {
+      const lookupResult = await dns.lookup(hostname);
+      if (!lookupResult || !lookupResult.address) {
+        return false;
+      }
+
+      if (!ipaddr.isValid(lookupResult.address)) {
+        return false;
+      }
+
+      const resolvedAddr = ipaddr.parse(lookupResult.address);
+      if (isPrivateAddress(resolvedAddr)) {
+        return false;
+      }
+
+      return true;
+    } catch {
+      // If DNS fails, treat as unsafe
       return false;
     }
+  } catch {
+    return false;
+  }
+}
 
+function isPrivateAddress(addr: ipaddr.IPv4 | ipaddr.IPv6): boolean {
+  if (addr.range() === 'loopback' || addr.range() === 'linkLocal') {
     return true;
-  } catch (error) {
-    return false;
   }
+
+  const type = addr.kind();
+  if (type === 'ipv4') {
+    const ipv4 = addr as ipaddr.IPv4;
+    const range = ipv4.range();
+    if (range === 'private' || range === 'linkLocal') {
+      return true;
+    }
+  } else if (type === 'ipv6') {
+    const ipv6 = addr as ipaddr.IPv6;
+    const range = ipv6.range();
+    if (
+      range === 'uniqueLocal' ||
+      range === 'linkLocal' ||
+      range === 'loopback'
+    ) {
+      return true;
+    }
+  }
+
+  return false;
 }
 
 export default { getMimeType };
EOF
apps/api/package.json
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/api/package.json b/apps/api/package.json
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -74,7 +74,8 @@
     "uuid": "^9.0.0",
     "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.1/xlsx-0.20.1.tgz",
     "xlsx-populate": "^1.21.0",
-    "xml2js": "^0.6.2"
+    "xml2js": "^0.6.2",
+    "ipaddr.js": "^2.3.0"
   },
   "devDependencies": {
     "@nestjs/cli": "^10.4.5",
EOF
@@ -74,7 +74,8 @@
"uuid": "^9.0.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.1/xlsx-0.20.1.tgz",
"xlsx-populate": "^1.21.0",
"xml2js": "^0.6.2"
"xml2js": "^0.6.2",
"ipaddr.js": "^2.3.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
This fix introduces these dependencies
Package Version Security advisories
ipaddr.js (npm) 2.3.0 None
Copilot is powered by AI and may make mistakes. Always verify output.
- base-repository: Remove JSON round-trip (JSON.parse/stringify) in
  mapEntity/mapEntities; lean() already returns plain objects so the
  extra serialization was pure CPU waste on every query result
- base-repository: Run paginate() data fetch and count in parallel via
  Promise.all instead of sequentially (saves one full round-trip per page)
- do-review, start-process: Replace forEach(async) fire-and-forget with
  Promise.allSettled so limit-exceeded emails are actually awaited and
  sent concurrently instead of being silently dropped
- upload.repository: Deduplicate getUserEmailFromUploadId /
  getUserIdFromUploadId into a single private helper to avoid executing
  identical DB queries twice when both are needed
- auto-import-jobs-schedular: Process all scheduled jobs in parallel
  with Promise.allSettled instead of a sequential for-of loop
- invite.usecase: Batch-check existing invitations with a single $in
  query; create all invitations in parallel; send emails + payment
  events in parallel — reduces N*4 sequential DB calls to O(1) queries
- bubble-io.service: Convert bubbleIoDefaultColumns from Array to Set
  so column-key exclusion checks are O(1) instead of O(n)
- project-invitation.schema: Add indexes on invitationToEmail, invitedBy,
  and _projectId (previously unindexed, scanned on every invite check)
- user-job.schema: Add indexes on _templateId, nextRun, and status to
  speed up scheduler lookups and $in joins

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@chavda-bhavik chavda-bhavik self-assigned this Mar 12, 2026
@chavda-bhavik chavda-bhavik merged commit 9289adc into next Mar 12, 2026
3 checks passed
@chavda-bhavik chavda-bhavik deleted the claude/kind-tharp branch March 12, 2026 17:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants