diff --git a/packages/backend/src/adapters/adapters.service.ts b/packages/backend/src/adapters/adapters.service.ts index 6c74792b..0e418417 100644 --- a/packages/backend/src/adapters/adapters.service.ts +++ b/packages/backend/src/adapters/adapters.service.ts @@ -465,7 +465,8 @@ export type ImportProbeResult = /** Set, and not a `{{VAR}}` placeholder left over from the template. */ function hasUsableValue(value: unknown): boolean { - return typeof value === 'string' && value.trim() !== '' && !/\{\{[^}]+\}\}/.test(value); + // [^{}] rather than [^}]: linear on a run of '{' (#788). + return typeof value === 'string' && value.trim() !== '' && !/\{\{[^{}]+\}\}/.test(value); } /** A short, printable slice of the probe's response for the install form. */ diff --git a/packages/backend/src/common/env-interpolation.util.spec.ts b/packages/backend/src/common/env-interpolation.util.spec.ts index 127bc27b..b898b200 100644 --- a/packages/backend/src/common/env-interpolation.util.spec.ts +++ b/packages/backend/src/common/env-interpolation.util.spec.ts @@ -33,10 +33,14 @@ describe('EnvInterpolation', () => { }); it('should return a non-string template unchanged (no throw)', () => { - // Static tools omit `path`; interpolating undefined must not crash. expect(interpolateString(undefined as unknown as string, envVars)) .toBeUndefined(); }); + + it('should handle {{ VAR }} with spaces', () => { + expect(interpolateString('{{ API_BASE }}/users', envVars)) + .toBe('https://api.example.com/users'); + }); }); describe('interpolateDeep', () => { @@ -91,9 +95,6 @@ describe('EnvInterpolation', () => { }); it('should not throw for a static tool with no path', () => { - // Regression: a `static` tool endpointMapping has no `path`; with a - // connector that HAS env vars, interpolation used to crash on - // interpolateString(undefined). const config = { baseUrl: 'https://v3.football.api-sports.io' }; const mapping = { method: 'static', @@ -104,4 +105,22 @@ describe('EnvInterpolation', () => { expect(result.config.baseUrl).toBe('https://v3.football.api-sports.io'); }); }); + + // ── ReDoS regression ────────────────────────────────────────────────────── + + describe('ReDoS regression', () => { + it('should handle a long brace run in under 100ms', () => { + const hostile = 'https://api.example.com/' + '{{'.repeat(50000); + const start = Date.now(); + interpolateString(hostile, envVars); + expect(Date.now() - start).toBeLessThan(250); + }); + + it('should handle {{ followed by a long whitespace run in under 100ms', () => { + const hostile = 'https://api.example.com/{{' + ' '.repeat(50000); + const start = Date.now(); + interpolateString(hostile, envVars); + expect(Date.now() - start).toBeLessThan(250); + }); + }); }); diff --git a/packages/backend/src/common/env-interpolation.util.ts b/packages/backend/src/common/env-interpolation.util.ts index 2ff9a64e..be47f1c3 100644 --- a/packages/backend/src/common/env-interpolation.util.ts +++ b/packages/backend/src/common/env-interpolation.util.ts @@ -10,7 +10,7 @@ * interpolate('{{BASE_URL}}/v1/users', envVars) → 'https://api.example.com/v1/users' */ -const VAR_PATTERN = /\{\{([^}]+)\}\}/g; +const VAR_PATTERN = /\{\{([^{}]+)\}\}/g; export interface InterpolateOptions { /** diff --git a/packages/backend/src/common/unresolved-placeholders.util.ts b/packages/backend/src/common/unresolved-placeholders.util.ts index 6dfacb8f..5cd76dfd 100644 --- a/packages/backend/src/common/unresolved-placeholders.util.ts +++ b/packages/backend/src/common/unresolved-placeholders.util.ts @@ -17,7 +17,7 @@ * the caller's business, not a missing credential. */ -const VAR_PATTERN = /\{\{([^}]+)\}\}/g; +const VAR_PATTERN = /\{\{([^{}]+)\}\}/g; /** Every `{{VAR}}` name still present anywhere in the value, deduplicated. */ export function findUnresolvedPlaceholders(value: unknown): string[] { diff --git a/packages/backend/src/connectors/catalog-env-rebuild.util.ts b/packages/backend/src/connectors/catalog-env-rebuild.util.ts index 9e6238fc..919a9c58 100644 --- a/packages/backend/src/connectors/catalog-env-rebuild.util.ts +++ b/packages/backend/src/connectors/catalog-env-rebuild.util.ts @@ -29,7 +29,7 @@ import { CALLER_CONTEXT_PREFIX } from '../common/caller-context.util'; * name when the new one is not set. */ -const VAR_PATTERN = /\{\{([^}]+)\}\}/g; +const VAR_PATTERN = /\{\{([^{}]+)\}\}/g; export interface CatalogTemplate { connector: { diff --git a/packages/backend/src/connectors/connector-secrets.util.ts b/packages/backend/src/connectors/connector-secrets.util.ts index 1978d8a7..36872f0d 100644 --- a/packages/backend/src/connectors/connector-secrets.util.ts +++ b/packages/backend/src/connectors/connector-secrets.util.ts @@ -107,7 +107,7 @@ export function isSecretValue(value: string): boolean { */ const SECRET_SLOT = /secret|passw|token|key|credential|private|signature|assertion|authorization|cookie|session|jwt|bearer/i; const NOT_A_SLOT = /(url|uri|endpoint|path)$/i; -const VAR_PATTERN = /\{\{([^}]+)\}\}/g; +const VAR_PATTERN = /\{\{([^{}]+)\}\}/g; function collectSlotVars(node: unknown, key: string, out: Set): void { if (typeof node === 'string') { diff --git a/packages/backend/src/connectors/parsers/curl.parser.spec.ts b/packages/backend/src/connectors/parsers/curl.parser.spec.ts index 2af2d646..9b774dbe 100644 --- a/packages/backend/src/connectors/parsers/curl.parser.spec.ts +++ b/packages/backend/src/connectors/parsers/curl.parser.spec.ts @@ -431,4 +431,29 @@ curl -X DELETE https://api.example.com/users/1`; expect(params.required).toContain('org_id'); expect(params.required).toContain('project_id'); }); + + // ── ReDoS regression ────────────────────────────────────────────────────── + + describe('ReDoS regression', () => { + it('should handle a long brace run in the URL in under 100ms', () => { + const hostile = `curl https://api.example.com/${'{{'.repeat(50000)}`; + const start = Date.now(); + parser.parse(hostile); + expect(Date.now() - start).toBeLessThan(250); + }); + + it('should handle {{ followed by a long whitespace run in under 100ms', () => { + const hostile = `curl https://api.example.com/{{${' '.repeat(50000)}`; + const start = Date.now(); + parser.parse(hostile); + expect(Date.now() - start).toBeLessThan(250); + }); + + it('should handle a long brace run in the body in under 100ms', () => { + const hostile = `curl -X POST https://api.example.com/ -d '${'{{'.repeat(50000)}'`; + const start = Date.now(); + parser.parse(hostile); + expect(Date.now() - start).toBeLessThan(250); + }); + }); }); diff --git a/packages/backend/src/connectors/parsers/curl.parser.ts b/packages/backend/src/connectors/parsers/curl.parser.ts index 35578402..396bf3c3 100644 --- a/packages/backend/src/connectors/parsers/curl.parser.ts +++ b/packages/backend/src/connectors/parsers/curl.parser.ts @@ -105,9 +105,9 @@ export class CurlParser { const headerMapping: Record = {}; // Extract variables from URL path - const pathVars = path.match(/\{\{([^}]+)\}\}/g) || []; + const pathVars = path.match(/\{\{([^{}]+)\}\}/g) || []; for (const match of pathVars) { - const varName = match.replace(/\{\{|\}\}/g, ''); + const varName = match.replace(/\{\{|\}\}/g, '').trim(); properties[varName] = { type: 'string', description: `Path variable: ${varName}` }; required.push(varName); } @@ -138,7 +138,7 @@ export class CurlParser { if (lowerKey === 'authorization') continue; // Handle separately if (value.includes('{{')) { - const varName = value.replace(/.*\{\{([^}]+)\}\}.*/, '$1'); + const varName = value.match(/\{\{([^{}]+)\}\}/)?.[1]?.trim() ?? value; properties[varName] = { type: 'string', description: `Header value for ${key}` }; headerMapping[key] = `$${varName}`; } else { @@ -152,8 +152,8 @@ export class CurlParser { // Try JSON parse — replace {{var}} placeholders with sentinel values. // Handle "{{var}}" (quoted) first to avoid producing ""__var_var__"". const cleanBody = dataBody - .replace(/"\{\{([^}]+)\}\}"/g, '"__var_$1__"') // "{{var}}" → "__var_var__" - .replace(/\{\{([^}]+)\}\}/g, '"__var_$1__"'); // remaining bare {{var}} + .replace(/"\{\{([^{}]+)\}\}"/g, '"__var_$1__"') // "{{var}}" → "__var_var__" + .replace(/\{\{([^{}]+)\}\}/g, '"__var_$1__"'); // remaining bare {{var}} const parsed = JSON.parse(cleanBody); // If parsed result is not an object (e.g. bare "{{var}}" parses as string), treat as raw body @@ -180,7 +180,7 @@ export class CurlParser { } catch { // Not JSON — treat as raw body parameter if (dataBody.includes('{{')) { - const varMatches = [...dataBody.matchAll(/\{\{([^}]+)\}\}/g)]; + const varMatches = [...dataBody.matchAll(/\{\{([^{}]+)\}\}/g)]; if (varMatches.length === 1) { const varName = varMatches[0][1]; properties[varName] = { type: 'string', description: 'Request body' }; @@ -222,7 +222,7 @@ export class CurlParser { } // Normalize path: replace {{var}} with {var} - const normalizedPath = path.replace(/\{\{([^}]+)\}\}/g, '{$1}'); + const normalizedPath = path.replace(/\{\{([^{}]+)\}\}/g, '{$1}'); const endpointMapping: ParsedTool['endpointMapping'] = { method, @@ -277,7 +277,7 @@ export class CurlParser { const queryParams: Record = {}; // Handle {{variable}} in URL by temporary replacement - const safeUrl = url.replace(/\{\{([^}]+)\}\}/g, 'PLACEHOLDER_$1'); + const safeUrl = url.replace(/\{\{([^{}]+)\}\}/g, 'PLACEHOLDER_$1'); try { const parsed = new URL(safeUrl); @@ -317,8 +317,8 @@ export class CurlParser { private generateToolName(method: string, path: string): string { const cleanPath = path - .replace(/\{[^}]+\}/g, '') - .replace(/\{\{[^}]+\}\}/g, '') + .replace(/\{\{[^{}]+\}\}/g, '') + .replace(/\{[^{}]+\}/g, '') .replace(/[^a-zA-Z0-9]/g, '_') .replace(/_+/g, '_') .replace(/^_|_$/g, ''); diff --git a/packages/backend/src/connectors/parsers/postman.parser.spec.ts b/packages/backend/src/connectors/parsers/postman.parser.spec.ts index dfa6d182..38c4b620 100644 --- a/packages/backend/src/connectors/parsers/postman.parser.spec.ts +++ b/packages/backend/src/connectors/parsers/postman.parser.spec.ts @@ -649,4 +649,54 @@ describe('PostmanParser', () => { expect(tools[0].outputSchema).toBeUndefined(); }); + // ── ReDoS regression ────────────────────────────────────────────────────── + + describe('ReDoS regression', () => { + it('should handle a long brace run in the URL in under 100ms', async () => { + const hostile = 'https://api.example.com/' + '{{'.repeat(50000); + const collection = { + info: { name: 'c', schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' }, + item: [ + { name: 'X', request: { method: 'GET', url: { raw: hostile, path: ['x'] } } }, + ], + }; + const start = Date.now(); + await parser.parse(collection); + expect(Date.now() - start).toBeLessThan(250); + }); + + it('should handle {{ followed by a long whitespace run in under 100ms', async () => { + const hostile = 'https://api.example.com/{{' + ' '.repeat(50000); + const collection = { + info: { name: 'c', schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' }, + item: [ + { name: 'X', request: { method: 'GET', url: { raw: hostile, path: ['x'] } } }, + ], + }; + const start = Date.now(); + await parser.parse(collection); + expect(Date.now() - start).toBeLessThan(250); + }); + + it('should handle a long brace run in the body in under 100ms', async () => { + const hostile = '{{'.repeat(50000); + const collection = { + info: { name: 'c', schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' }, + item: [ + { + name: 'X', + request: { + method: 'POST', + url: { raw: 'https://api.example.com/x', path: ['x'] }, + body: { mode: 'raw', raw: hostile }, + }, + }, + ], + }; + const start = Date.now(); + await parser.parse(collection); + expect(Date.now() - start).toBeLessThan(250); + }); + }); + }); diff --git a/packages/backend/src/connectors/parsers/postman.parser.ts b/packages/backend/src/connectors/parsers/postman.parser.ts index 0597fd36..6c0cfc5b 100644 --- a/packages/backend/src/connectors/parsers/postman.parser.ts +++ b/packages/backend/src/connectors/parsers/postman.parser.ts @@ -167,9 +167,9 @@ export class PostmanParser { const headerMapping: Record = {}; // Path parameters (from {{param}} in URL path) - const pathVarMatches = url.path.match(/\{\{([^}]+)\}\}/g) || []; + const pathVarMatches = url.path.match(/\{\{([^{}]+)\}\}/g) || []; for (const match of pathVarMatches) { - const varName = match.replace(/\{\{|\}\}/g, ''); + const varName = match.replace(/\{\{|\}\}/g, '').trim(); if (!variables[varName]) { // It's a dynamic path parameter, not a static env var properties[varName] = { type: 'string', description: `Path variable: ${varName}` }; @@ -178,8 +178,8 @@ export class PostmanParser { } // Also detect {param} style path params (but not {{param}} which are handled above) - const pathWithoutDoubles = url.path.replace(/\{\{[^}]+\}\}/g, ''); - const pathParamMatches = pathWithoutDoubles.match(/\{([^}]+)\}/g) || []; + const pathWithoutDoubles = url.path.replace(/\{\{[^{}]+\}\}/g, ''); + const pathParamMatches = pathWithoutDoubles.match(/\{([^{}]+)\}/g) || []; for (const match of pathParamMatches) { const varName = match.replace(/[{}]/g, ''); properties[varName] = { type: 'string', description: `Path parameter: ${varName}` }; @@ -239,7 +239,7 @@ export class PostmanParser { } // Normalize path: replace {{var}} with {var} for engine interpolation - const normalizedPath = url.path.replace(/\{\{([^}]+)\}\}/g, '{$1}'); + const normalizedPath = url.path.replace(/\{\{([^{}]+)\}\}/g, '{$1}'); const endpointMapping: ParsedTool['endpointMapping'] = { method, @@ -287,7 +287,7 @@ export class PostmanParser { if (typeof url === 'string') { try { - const parsed = new URL(url.replace(/\{\{[^}]+\}\}/g, 'placeholder')); + const parsed = new URL(url.replace(/\{\{[^{}]+\}\}/g, 'placeholder')); return { raw: url, path: url.replace(/^https?:\/\/[^/]+/, ''), @@ -322,8 +322,8 @@ export class PostmanParser { // Replace {{var}} with sentinel values for JSON parsing. // Handle "{{var}}" (quoted) first to avoid producing ""var_placeholder"". const cleanBody = body.raw - .replace(/"\{\{([^}]+)\}\}"/g, '"__var_$1__"') // "{{var}}" → "__var_var__" - .replace(/\{\{([^}]+)\}\}/g, '"__var_$1__"'); // remaining bare {{var}} + .replace(/"\{\{([^{}]+)\}\}"/g, '"__var_$1__"') // "{{var}}" → "__var_var__" + .replace(/\{\{([^{}]+)\}\}/g, '"__var_$1__"'); // remaining bare {{var}} const parsed = JSON.parse(cleanBody); // If parsed result is not an object, treat as raw body @@ -405,7 +405,7 @@ export class PostmanParser { // Fallback: method + path const cleanPath = path - .replace(/\{[^}]+\}/g, '') + .replace(/\{[^{}]+\}/g, '') .replace(/[^a-zA-Z0-9]/g, '_') .replace(/_+/g, '_') .replace(/^_|_$/g, '');