diff --git a/extensions/bluebubbles/src/media-send.ts b/extensions/bluebubbles/src/media-send.ts index ab7572105674f..08f3d5ec43cd0 100644 --- a/extensions/bluebubbles/src/media-send.ts +++ b/extensions/bluebubbles/src/media-send.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import fs from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/plugin-sdk"; import { sendBlueBubblesAttachment } from "./attachments.js"; @@ -9,6 +10,108 @@ import { sendMessageBlueBubbles } from "./send.js"; const HTTP_URL_RE = /^https?:\/\//i; const MB = 1024 * 1024; +/** + * Securely reads a local media file with path traversal protection. + * Uses built-in path validation to prevent access outside allowed directories. + * + * @param localPath - Local file path to read + * @returns File content as Buffer + * @throws Error if path is unsafe or file cannot be read + */ +async function secureReadLocalMediaFile(localPath: string): Promise { + // Normalize the path to prevent traversal attacks + const normalizedPath = path.normalize(localPath); + + // Basic path traversal protection - reject paths with traversal sequences + if (normalizedPath.includes('../') || normalizedPath.includes('..\\') || + normalizedPath.includes('/..') || normalizedPath.includes('\\..')) { + console.warn(`[Security] Path traversal attempt blocked: ${localPath}`); + throw new Error("Invalid file path - path traversal is not allowed"); + } + + // Check if path tries to access absolute paths outside expected directories + if (path.isAbsolute(normalizedPath)) { + // For absolute paths, we need to ensure they're within a safe directory + // Get user's home directory and only allow access to reasonable locations + const os = await import("node:os"); + const homeDir = os.homedir(); + const allowedPaths = [ + path.join(homeDir, ".openclaw"), + path.join(homeDir, "Downloads"), + path.join(homeDir, "Documents"), + path.join(homeDir, "Pictures"), + path.join(homeDir, "Desktop"), + ]; + + const isInAllowedPath = allowedPaths.some(allowedPath => { + try { + const resolvedPath = path.resolve(normalizedPath); + const resolvedAllowed = path.resolve(allowedPath); + return resolvedPath.startsWith(resolvedAllowed + path.sep) || resolvedPath === resolvedAllowed; + } catch { + return false; + } + }); + + if (!isInAllowedPath) { + console.warn(`[Security] Absolute path outside allowed directories blocked: ${localPath}`); + throw new Error("Invalid file path - access outside allowed directories is not permitted"); + } + } + + // Additional validation: ensure resolved path doesn't escape via symlinks + let realPath: string; + try { + realPath = await fs.realpath(normalizedPath); + } catch (err: any) { + if (err.code === 'ENOENT') { + throw new Error(`Media file not found: ${path.basename(localPath)}`); + } + throw new Error(`Cannot access file: ${err.message}`); + } + + // Re-check the real path after symlink resolution + if (path.isAbsolute(realPath)) { + const os = await import("node:os"); + const homeDir = os.homedir(); + const allowedPaths = [ + path.join(homeDir, ".openclaw"), + path.join(homeDir, "Downloads"), + path.join(homeDir, "Documents"), + path.join(homeDir, "Pictures"), + path.join(homeDir, "Desktop"), + ]; + + const isRealPathSafe = allowedPaths.some(allowedPath => { + try { + const resolvedAllowed = path.resolve(allowedPath); + return realPath.startsWith(resolvedAllowed + path.sep) || realPath === resolvedAllowed; + } catch { + return false; + } + }); + + if (!isRealPathSafe) { + console.warn(`[Security] Symlink escape attempt blocked: ${localPath} -> ${realPath}`); + throw new Error("Invalid file path - symlink points outside allowed directories"); + } + } + + // Read and return the file + try { + const data = await fs.readFile(realPath); + return data; + } catch (err: any) { + if (err.code === 'ENOENT') { + throw new Error(`Media file not found: ${path.basename(localPath)}`); + } + if (err.code === 'EACCES' || err.code === 'EPERM') { + throw new Error(`Permission denied accessing file: ${path.basename(localPath)}`); + } + throw new Error(`Failed to read file: ${err.message}`); + } +} + function assertMediaWithinLimit(sizeBytes: number, maxBytes?: number): void { if (typeof maxBytes !== "number" || maxBytes <= 0) { return; @@ -122,14 +225,14 @@ export async function sendBlueBubblesMedia(params: { resolvedFilename = resolvedFilename ?? fetched.fileName; } else { const localPath = resolveLocalMediaPath(source); - const fs = await import("node:fs/promises"); - if (typeof maxBytes === "number" && maxBytes > 0) { - const stats = await fs.stat(localPath); - assertMediaWithinLimit(stats.size, maxBytes); - } - const data = await fs.readFile(localPath); + + // Use secure file reading with path traversal protection + const data = await secureReadLocalMediaFile(localPath); + + // Check file size limits after secure read assertMediaWithinLimit(data.byteLength, maxBytes); buffer = new Uint8Array(data); + if (!resolvedContentType) { const detected = await core.media.detectMime({ buffer: data,