Peekaboo/src/utils/image-cli-args.ts
Peter Steinberger 17dea6ad79 fix: Prevent security vulnerability from malformed app targets
Addresses critical edge case where malformed app targets with multiple leading colons
(e.g., "::::::::::::::::Finder") created empty app names that would match ALL system
processes. This could potentially expose sensitive information or cause unintended
system-wide captures.

Key improvements:
- Enhanced app target parsing to validate non-empty app names
- Added fallback logic to extract valid app names from malformed inputs
- Default to screen mode when all parts are empty (security-first approach)
- Comprehensive test coverage for edge cases
- Improved backward compatibility with hidden path parameters

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-08 08:16:39 +01:00

167 lines
No EOL
5.9 KiB
TypeScript

import { ImageInput } from "../types/index.js";
import { Logger } from "pino";
import * as fs from "fs/promises";
import * as path from "path";
import * as os from "os";
export interface ResolvedImagePath {
effectivePath: string | undefined;
tempDirUsed: string | undefined;
}
export async function resolveImagePath(
input: ImageInput,
logger: Logger,
): Promise<ResolvedImagePath> {
// If input.path is provided, use it directly
if (input.path) {
return { effectivePath: input.path, tempDirUsed: undefined };
}
// Check if a temporary directory is required
// A temp dir is needed if:
// 1. A question is present
// 2. Format is explicitly set to 'data'
const needsTempDir = input.question || input.format === "data";
if (needsTempDir) {
// Create a temporary directory
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "peekaboo-img-"));
logger.debug({ tempPath: tempDir }, "Created temporary directory for capture");
return { effectivePath: tempDir, tempDirUsed: tempDir };
}
// Check for PEEKABOO_DEFAULT_SAVE_PATH environment variable
const defaultSavePath = process.env.PEEKABOO_DEFAULT_SAVE_PATH;
if (defaultSavePath) {
return { effectivePath: defaultSavePath, tempDirUsed: undefined };
}
// Final fallback: create a temporary directory
// This happens when: no path, no question, no explicit 'data' format, no env var
const fallbackTempDir = await fs.mkdtemp(path.join(os.tmpdir(), "peekaboo-img-"));
logger.debug({ tempPath: fallbackTempDir }, "Created fallback temporary directory for capture");
return { effectivePath: fallbackTempDir, tempDirUsed: fallbackTempDir };
}
export function buildSwiftCliArgs(
input: ImageInput,
effectivePath: string | undefined,
swiftFormat?: string,
logger?: Logger,
): string[] {
const args = ["image"];
// Use provided format or derive from input
// Format validation is already handled by the schema preprocessor
const inputFormat = input.format || "png";
const actualFormat = swiftFormat || (inputFormat === "data" ? "png" : inputFormat);
// Create a logger if not provided (for backward compatibility)
const log = logger || {
warn: (_msg: unknown) => {},
error: (_msg: unknown) => {},
debug: (_msg: unknown) => {},
};
// Parse app_target to determine Swift CLI arguments
if (!input.app_target || input.app_target === "") {
// Omitted/empty: All screens
args.push("--mode", "screen");
} else if (input.app_target.startsWith("screen:")) {
// 'screen:INDEX': Specific display
const screenIndexStr = input.app_target.substring(7);
const screenIndex = parseInt(screenIndexStr, 10);
if (isNaN(screenIndex) || screenIndex < 0) {
log.warn(
{ screenIndex: screenIndexStr },
`Invalid screen index '${screenIndexStr}' in app_target, capturing all screens.`,
);
args.push("--mode", "screen");
} else {
args.push("--mode", "screen", "--screen-index", screenIndex.toString());
}
} else if (input.app_target.toLowerCase() === "frontmost") {
// 'frontmost': All windows of the frontmost app
log.warn(
"'frontmost' target requires determining current frontmost app, defaulting to screen mode",
);
args.push("--mode", "screen");
} else if (input.app_target.includes(":")) {
// 'AppName:WINDOW_TITLE:Title' or 'AppName:WINDOW_INDEX:Index'
const parts = input.app_target.split(":");
if (parts.length >= 3) {
const appName = parts[0].trim();
const specifierType = parts[1].trim();
const specifierValue = parts.slice(2).join(":"); // Handle colons in window titles
// Validate that we have a non-empty app name
if (!appName) {
log.warn(
{ app_target: input.app_target },
"Empty app name detected in app_target, treating as malformed",
);
// Try to find the first non-empty part as the app name
const nonEmptyParts = parts.filter(part => part.trim());
if (nonEmptyParts.length > 0) {
args.push("--app", nonEmptyParts[0].trim());
args.push("--mode", "multi");
} else {
// All parts are empty, default to screen mode
log.warn("All parts of app_target are empty, defaulting to screen mode");
args.push("--mode", "screen");
}
} else {
args.push("--app", appName);
args.push("--mode", "window");
if (specifierType.toUpperCase() === "WINDOW_TITLE") {
args.push("--window-title", specifierValue);
} else if (specifierType.toUpperCase() === "WINDOW_INDEX") {
args.push("--window-index", specifierValue);
} else {
log.warn(
{ specifierType },
"Unknown window specifier type, defaulting to main window",
);
}
}
} else {
// Malformed: treat as app name, but validate it's not empty
const cleanAppTarget = input.app_target.trim();
if (!cleanAppTarget || cleanAppTarget === ":".repeat(cleanAppTarget.length)) {
log.warn(
{ app_target: input.app_target },
"Malformed app_target with only colons or empty, defaulting to screen mode",
);
args.push("--mode", "screen");
} else {
log.warn(
{ app_target: input.app_target },
"Malformed window specifier, treating as app name",
);
// Remove trailing colons from app name
const appName = cleanAppTarget.replace(/:+$/, "");
args.push("--app", appName);
args.push("--mode", "multi");
}
}
} else {
// 'AppName': All windows of that app
args.push("--app", input.app_target.trim());
args.push("--mode", "multi");
}
// Add path if it was provided
if (effectivePath) {
args.push("--path", effectivePath);
}
// Add format
args.push("--format", actualFormat);
// Add capture focus
args.push("--capture-focus", input.capture_focus || "background");
return args;
}