Skip to content
Merged
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
3 changes: 2 additions & 1 deletion packages/backend/src/adapters/adapters.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
27 changes: 23 additions & 4 deletions packages/backend/src/common/env-interpolation.util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
Expand All @@ -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);
});
});
});
2 changes: 1 addition & 1 deletion packages/backend/src/common/env-interpolation.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>): void {
if (typeof node === 'string') {
Expand Down
25 changes: 25 additions & 0 deletions packages/backend/src/connectors/parsers/curl.parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
20 changes: 10 additions & 10 deletions packages/backend/src/connectors/parsers/curl.parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
}

// Parse the URL
const { basePath, path, queryParams: urlQueryParams } = this.parseUrl(url);

Check warning on line 98 in packages/backend/src/connectors/parsers/curl.parser.ts

View workflow job for this annotation

GitHub Actions / Backend (lint, typecheck, test, build)

'basePath' is assigned a value but never used. Allowed unused vars must match /^_/u

// Build parameters and mappings
const properties: Record<string, any> = {};
Expand All @@ -105,9 +105,9 @@
const headerMapping: Record<string, string> = {};

// 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);
}
Expand Down Expand Up @@ -138,7 +138,7 @@
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 {
Expand All @@ -152,8 +152,8 @@
// 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
Expand All @@ -180,7 +180,7 @@
} 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' };
Expand All @@ -205,7 +205,7 @@
}

// Auth from Authorization header
const authHeader = headers['Authorization'] || headers['authorization'];

Check warning on line 208 in packages/backend/src/connectors/parsers/curl.parser.ts

View workflow job for this annotation

GitHub Actions / Backend (lint, typecheck, test, build)

'authHeader' is assigned a value but never used. Allowed unused vars must match /^_/u

// Generate tool name from path
const name = this.generateToolName(method, path);
Expand All @@ -222,7 +222,7 @@
}

// Normalize path: replace {{var}} with {var}
const normalizedPath = path.replace(/\{\{([^}]+)\}\}/g, '{$1}');
const normalizedPath = path.replace(/\{\{([^{}]+)\}\}/g, '{$1}');

const endpointMapping: ParsedTool['endpointMapping'] = {
method,
Expand Down Expand Up @@ -277,7 +277,7 @@
const queryParams: Record<string, string> = {};

// 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);
Expand Down Expand Up @@ -317,8 +317,8 @@

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, '');
Expand Down
50 changes: 50 additions & 0 deletions packages/backend/src/connectors/parsers/postman.parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

});
18 changes: 9 additions & 9 deletions packages/backend/src/connectors/parsers/postman.parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,9 @@
const headerMapping: Record<string, string> = {};

// 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}` };
Expand All @@ -178,8 +178,8 @@
}

// 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}` };
Expand Down Expand Up @@ -239,7 +239,7 @@
}

// 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,
Expand Down Expand Up @@ -287,7 +287,7 @@

if (typeof url === 'string') {
try {
const parsed = new URL(url.replace(/\{\{[^}]+\}\}/g, 'placeholder'));
const parsed = new URL(url.replace(/\{\{[^{}]+\}\}/g, 'placeholder'));

Check warning on line 290 in packages/backend/src/connectors/parsers/postman.parser.ts

View workflow job for this annotation

GitHub Actions / Backend (lint, typecheck, test, build)

'parsed' is assigned a value but never used. Allowed unused vars must match /^_/u
return {
raw: url,
path: url.replace(/^https?:\/\/[^/]+/, ''),
Expand Down Expand Up @@ -322,8 +322,8 @@
// 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
Expand Down Expand Up @@ -405,7 +405,7 @@

// Fallback: method + path
const cleanPath = path
.replace(/\{[^}]+\}/g, '')
.replace(/\{[^{}]+\}/g, '')
.replace(/[^a-zA-Z0-9]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '');
Expand Down
Loading