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
2 changes: 1 addition & 1 deletion src/core/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const TOOL_ARG_ALLOWLIST: Record<string, string[]> = {
"cloudPod",
"envVarKeys",
],
"localstack-extensions": ["action", "name", "source"],
"localstack-extensions": ["action", "name"],
"localstack-iam-policy-analyzer": ["action", "mode"],
"localstack-logs-analysis": ["analysisType", "lines", "service", "operation", "filter"],
"localstack-management": ["action", "service", "envVars"],
Expand Down
56 changes: 3 additions & 53 deletions src/lib/localstack/extensions.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,68 +162,18 @@ describe("validateExtensionTarget", () => {
}
});

test("accepts documented git+https sources on known hosts", () => {
test("rejects git URLs, which LocalStack no longer installs", () => {
expect(
validateExtensionTarget({
source:
"git+https://github.com/localstack/localstack-extensions.git#egg=localstack-keycloak&subdirectory=keycloak",
name: "git+https://github.com/localstack/localstack-extensions.git#egg=localstack-keycloak&subdirectory=keycloak",
})
).toBeNull();
expect(
validateExtensionTarget({ source: "git+https://gitlab.com/org/repo.git@v1.2.3" })
).toBeNull();
});

test("rejects non-https, unknown-host, and non-git sources", () => {
for (const source of [
"git+ssh://git@github.com/org/repo.git",
"git+https://evil.example/org/repo.git",
"https://github.com/org/repo.git",
"file:///etc/passwd",
"git+https://github.com/org/repo.git --index-url=x",
]) {
expect(validateExtensionTarget({ source })).toMatch(/not a supported extension source/);
}
});

test("rejects host-confusion sources (suffix / userinfo tricks)", () => {
for (const source of [
"git+https://github.com.evil.com/org/repo.git",
"git+https://github.com@evil.com/org/repo.git",
"git+https://evil.com@github.com/org/repo.git",
"git+https://github.comX/org/repo.git",
]) {
expect(validateExtensionTarget({ source })).toMatch(/not a supported extension source/);
}
});

test("rejects '..' path traversal in the source fragment (subdirectory=)", () => {
expect(
validateExtensionTarget({
source: "git+https://github.com/org/repo#subdirectory=../../../../opt/code",
})
).toMatch(/'\.\.'/);
expect(
validateExtensionTarget({
source: "git+https://github.com/org/repo#egg=x&subdirectory=../../etc",
})
).toMatch(/'\.\.'/);
});

test("treats an empty name as absent so a source-only install is accepted", () => {
expect(
validateExtensionTarget({
name: "",
source: "git+https://github.com/org/repo.git",
})
).toBeNull();
).toMatch(/not a valid extension package name/);
});

test("validates in linear time (no catastrophic backtracking)", () => {
const evil = "a".repeat(100000);
const start = Date.now();
validateExtensionTarget({ name: `${evil} ` }); // fails the regex
validateExtensionTarget({ source: `git+https://github.com/${evil}` });
expect(Date.now() - start).toBeLessThan(1000);
});
});
24 changes: 1 addition & 23 deletions src/lib/localstack/extensions.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,33 +43,11 @@ export const EXTENSIONS_VENV_REPAIR_SCRIPT = [
export const EXTENSION_NAME_RE =
/^[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9,._-]+\])?(==[A-Za-z0-9.*+!-]+)?$/;

/** Git sources are limited to https on well-known hosts (matches the documented usage). */
export const EXTENSION_GIT_SOURCE_RE =
/^git\+https:\/\/(github\.com|gitlab\.com|bitbucket\.org)\/[A-Za-z0-9._/-]+(@[A-Za-z0-9._/-]+)?(#[A-Za-z0-9._&=/-]+)?$/;

/** Returns an error message for an invalid install/uninstall target, or null when fine. */
export function validateExtensionTarget({
name,
source,
}: {
name?: string;
source?: string;
}): string | null {
// Treat an empty name as "not provided" so a source-only install isn't rejected
// for a name it never supplied.
export function validateExtensionTarget({ name }: { name?: string }): string | null {
if (name && !EXTENSION_NAME_RE.test(name)) {
return `"${name}" is not a valid extension package name. Expected a PyPI-style name like localstack-extension-typedb, optionally with extras or a ==version pin.`;
}
if (source) {
if (!EXTENSION_GIT_SOURCE_RE.test(source)) {
return `"${source}" is not a supported extension source. Use a git+https:// URL on github.com, gitlab.com, or bitbucket.org (e.g. git+https://github.com/org/repo.git#egg=my-extension).`;
}
// Defense-in-depth: reject `..` path segments in the fragment (pip's subdirectory=
// could otherwise point its build root outside the clone).
if (/(^|[?#&/])\.\.(?=$|[/&])/.test(source)) {
return `"${source}" is not a supported extension source: '..' path segments are not allowed.`;
}
}
return null;
}

Expand Down
32 changes: 10 additions & 22 deletions src/tools/localstack-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,6 @@ export const schema = {
.describe(
"Extension package name (e.g. 'localstack-extension-typedb' or 'localstack-extension-typedb==1.0.0'). Required for install and uninstall actions."
),
source: z
.string()
.optional()
.describe(
"Git URL to install from (e.g. 'git+https://github.com/org/repo.git'). Use this instead of name when installing from a repository."
),
};

export const metadata: ToolMetadata = {
Expand All @@ -61,12 +55,8 @@ export const metadata: ToolMetadata = {
},
};

export default async function localstackExtensions({
action,
name,
source,
}: InferSchema<typeof schema>) {
return withToolAnalytics("localstack-extensions", { action, name, source }, async () => {
export default async function localstackExtensions({ action, name }: InferSchema<typeof schema>) {
return withToolAnalytics("localstack-extensions", { action, name }, async () => {
// `available` only needs the platform API; the container-touching actions need a
// running LocalStack (the extension manager runs inside it) + the Docker daemon.
const checks =
Expand All @@ -86,7 +76,7 @@ export default async function localstackExtensions({
case "list":
return await handleList();
case "install":
return await handleInstall(name, source);
return await handleInstall(name);
case "uninstall":
return await handleUninstall(name);
case "available":
Expand Down Expand Up @@ -183,22 +173,20 @@ async function handleList() {
return ResponseBuilder.markdown(formatInstalledExtensions(extensions));
}

async function handleInstall(name?: string, source?: string) {
const hasName = !!name;
const hasSource = !!source;
if ((hasName && hasSource) || (!hasName && !hasSource)) {
async function handleInstall(name?: string) {
if (!name) {
return ResponseBuilder.error(
"Invalid Parameters",
"Provide either `name` or `source` for install, but not both."
"Missing Required Parameter",
"The `install` action requires the `name` parameter to be specified."
);
}

const validationError = validateExtensionTarget({ name, source });
const validationError = validateExtensionTarget({ name });
if (validationError) {
return ResponseBuilder.error("Invalid Extension Target", validationError);
}

const target = source || name!;
const target = name;
let result;
try {
result = await runExtensionManager(["install", target], { repairVenv: true });
Expand All @@ -216,7 +204,7 @@ async function handleInstall(name?: string, source?: string) {
if (outcome.kind === "not-found") {
return ResponseBuilder.error(
"Extension Not Found",
`Could not resolve the extension package '${target}'. Please verify it exists on PyPI, or provide a git repository URL using the source parameter.`
`Could not resolve the extension package '${target}'. Please verify it exists on PyPI.`
);
}
if (!outcome.success) {
Expand Down
Loading