fix(new-agent): add path traversal containment check
Extracts validateAgentName() with path.resolve containment guard after regex validation; returns clean ephemeral Discord reply on violation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
390661eea9
commit
b4cc00e93c
2 changed files with 55 additions and 0 deletions
|
|
@ -12,6 +12,21 @@ import { setupAgentWorkspace } from "../agent/identity";
|
||||||
// Allowed characters for agent names: lowercase letters, numbers, hyphens
|
// Allowed characters for agent names: lowercase letters, numbers, hyphens
|
||||||
const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$/;
|
const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that an agent name is safe and does not escape the workspaces root
|
||||||
|
* via path traversal. Throws an Error if the resolved path would lie outside root.
|
||||||
|
*/
|
||||||
|
export function validateAgentName(name: string, workspacesRoot: string): void {
|
||||||
|
if (!name) {
|
||||||
|
throw new Error(`Ungültiger Agent-Name: "${name}"`);
|
||||||
|
}
|
||||||
|
const root = path.resolve(workspacesRoot);
|
||||||
|
const wsPath = path.resolve(root, name);
|
||||||
|
if (!wsPath.startsWith(root + path.sep)) {
|
||||||
|
throw new Error(`Ungültiger Agent-Name: "${name}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const newAgentCommand = new SlashCommandBuilder()
|
export const newAgentCommand = new SlashCommandBuilder()
|
||||||
.setName("new-agent")
|
.setName("new-agent")
|
||||||
.setDescription("Erstellt einen neuen Agenten mit eigenem Kanal und Workspace")
|
.setDescription("Erstellt einen neuen Agenten mit eigenem Kanal und Workspace")
|
||||||
|
|
@ -48,6 +63,15 @@ export async function handleNewAgent(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path-traversal containment check
|
||||||
|
try {
|
||||||
|
validateAgentName(name, config.workspaces_root);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Ungültiger Agent-Name";
|
||||||
|
await interaction.reply({ content: msg, ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check uniqueness
|
// Check uniqueness
|
||||||
if (db.getWorkspaceByName(name)) {
|
if (db.getWorkspaceByName(name)) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
|
|
|
||||||
31
tests/unit/path-traversal.test.ts
Normal file
31
tests/unit/path-traversal.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { validateAgentName } from "../../src/commands/new-agent";
|
||||||
|
|
||||||
|
const ROOT = "/tmp/ws";
|
||||||
|
|
||||||
|
describe("validateAgentName – path traversal containment", () => {
|
||||||
|
it('throws for "../etc"', () => {
|
||||||
|
expect(() => validateAgentName("../etc", ROOT)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for ".."', () => {
|
||||||
|
expect(() => validateAgentName("..", ROOT)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw for "a/../b" (resolves to /tmp/ws/b, stays inside root)', () => {
|
||||||
|
// path.resolve("/tmp/ws", "a/../b") => "/tmp/ws/b" -- still inside root
|
||||||
|
expect(() => validateAgentName("a/../b", ROOT)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for empty string ""', () => {
|
||||||
|
expect(() => validateAgentName("", ROOT)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows "my-agent"', () => {
|
||||||
|
expect(() => validateAgentName("my-agent", ROOT)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows "agent-123"', () => {
|
||||||
|
expect(() => validateAgentName("agent-123", ROOT)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue