fix(security): remove quadratic trailing-slash trim in Alibaba endpoint normalization

CodeQL js/polynomial-redos (alerts #765/#766).

`/\/+$/` has no left anchor, so the engine retries the match at every
start offset and each attempt re-walks the whole slash run before failing
`$` — O(n^2) on a connection baseUrl made of many slashes. Measured on
the vulnerable code: 10k slashes = 102ms, 30k = 968ms, 60k = 4028ms
(clean quadratic); through the public resolvers the same input took 22s.

providerSpecificData.baseUrl is operator-supplied config and reaches the
trim via isFamilyPresetUrl() -> normalizeEndpoint() and both resolve*Url()
helpers, so the input is reachable.

Replaces all three identical occurrences with a linear charCodeAt scan.
CodeQL only flagged two of them; the third (resolveAlibabaProviderModelsUrl)
carries the same defect and is fixed here rather than left behind.

Behavior is unchanged: every trailing slash is still removed (not a bounded
subset), interior slashes are preserved, and an all-slash string still
collapses to empty — asserted by the new behavior test.
This commit is contained in:
diegosouzapw
2026-07-23 16:56:55 -03:00
parent c525a0f452
commit db1f435f55
2 changed files with 77 additions and 10 deletions

View File

@@ -52,10 +52,24 @@ function canonicalProviderFamily(providerId: string): AlibabaProviderFamily | nu
return null;
}
const SLASH_CHAR_CODE = 47;
/**
* Strip every trailing "/" in linear time.
*
* Deliberately not a regex: `/\/+$/` has no left anchor, so the engine retries at
* every start offset and each attempt re-walks the slash run before failing `$` —
* quadratic on an endpoint made of many slashes (CodeQL js/polynomial-redos).
* Connection base URLs are operator-supplied, so they reach this trim directly.
*/
function stripTrailingSlashes(value: string): string {
let end = value.length;
while (end > 0 && value.charCodeAt(end - 1) === SLASH_CHAR_CODE) end--;
return end === value.length ? value : value.slice(0, end);
}
function normalizeEndpoint(value: string): string {
return value
.trim()
.replace(/\/+$/, "")
return stripTrailingSlashes(value.trim())
.replace(/\/(?:chat\/completions|messages)$/i, "")
.toLowerCase();
}
@@ -138,10 +152,9 @@ export function resolveAlibabaProviderModelsUrl(
providerSpecificData?: unknown,
fallback = ""
): string {
const baseUrl = resolveAlibabaProviderBaseUrl(providerId, providerSpecificData, fallback)
.trim()
.replace(/\/+$/, "")
.replace(/\/(?:chat\/completions|messages|models)$/i, "");
const baseUrl = stripTrailingSlashes(
resolveAlibabaProviderBaseUrl(providerId, providerSpecificData, fallback).trim()
).replace(/\/(?:chat\/completions|messages|models)$/i, "");
return baseUrl ? `${baseUrl}/models` : "";
}
@@ -154,9 +167,9 @@ export function resolveAlibabaProviderMediaBaseUrl(
providerSpecificData?: unknown,
fallback = ""
): string {
return resolveAlibabaProviderBaseUrl(providerId, providerSpecificData, fallback)
.trim()
.replace(/\/+$/, "")
return stripTrailingSlashes(
resolveAlibabaProviderBaseUrl(providerId, providerSpecificData, fallback).trim()
)
.replace(/\/compatible-mode\/v1(?:\/(?:chat\/completions|models))?$/i, "/api/v1")
.replace(/\/apps\/anthropic(?:\/v1)?(?:\/messages)?$/i, "/api/v1");
}

View File

@@ -12,6 +12,7 @@ import {
isAlibabaRegionalProvider,
normalizeAlibabaProviderRegion,
resolveAlibabaProviderBaseUrl,
resolveAlibabaProviderMediaBaseUrl,
resolveAlibabaProviderModelsUrl,
} from "../../src/shared/constants/alibabaProviderRegions.ts";
import { APIKEY_PROVIDERS } from "../../src/shared/constants/providers.ts";
@@ -302,3 +303,56 @@ test("dashboard folds legacy China connections into the unified Alibaba card", (
assert.equal(isAlibabaRegionalProvider(providerId), true);
}
});
/**
* Regression guard for CodeQL `js/polynomial-redos` (alerts #765/#766).
*
* The trailing-slash trim used to be `/\/+$/`. That regex has no left anchor, so
* the engine retries the match at every start offset and each attempt walks the
* whole run of slashes before failing `$` — O(n^2) on a connection baseUrl made
* of many slashes. Measured on the vulnerable version: 10k slashes = 102ms,
* 30k = 968ms, 60k = 4028ms (clean quadratic). The linear strip runs in <1ms.
*
* `providerSpecificData.baseUrl` is operator-supplied config, so this input
* reaches the trim through isFamilyPresetUrl() -> normalizeEndpoint() and
* through both resolve*Url() helpers.
*/
test("trailing-slash normalization is linear on pathological slash runs (ReDoS guard)", () => {
const pathological = { baseUrl: `${"/".repeat(60_000)}x` };
const budgetMs = 500;
const started = performance.now();
resolveAlibabaProviderModelsUrl("alibaba", pathological);
resolveAlibabaProviderMediaBaseUrl("alibaba", pathological);
resolveAlibabaProviderBaseUrl("alibaba", pathological);
const elapsed = performance.now() - started;
assert.ok(
elapsed < budgetMs,
`trailing-slash trim must stay linear; took ${elapsed.toFixed(0)}ms (budget ${budgetMs}ms). ` +
"A quadratic trim (/\\/+$/) takes seconds on this input."
);
});
test("trailing-slash normalization keeps its exact behavior", () => {
// Every trailing slash is removed (not a bounded subset), and only trailing ones.
const withSlashes = { baseUrl: "https://example.test/v1////" };
assert.equal(
resolveAlibabaProviderModelsUrl("alibaba", withSlashes),
"https://example.test/v1/models"
);
// Interior slashes are preserved.
const interior = { baseUrl: "https://example.test//deep//path/" };
assert.equal(
resolveAlibabaProviderBaseUrl("alibaba", interior),
"https://example.test//deep//path/"
);
assert.equal(
resolveAlibabaProviderModelsUrl("alibaba", interior),
"https://example.test//deep//path/models"
);
// A string that is nothing but slashes collapses to empty.
assert.equal(resolveAlibabaProviderModelsUrl("alibaba", { baseUrl: "////" }), "");
});