Skip to content
Open
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
115 changes: 109 additions & 6 deletions extensions/bluebubbles/src/media-send.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<Buffer> {
// 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;
Expand Down Expand Up @@ -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,
Expand Down