Files
puter/src/backend/drivers/util/fileInput.ts
T
Daniel Salazar 7e21c1f888
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
Notify HeyPuter / notify (push) Has been cancelled
release-please / release-please (push) Has been cancelled
fix: fs metadata sanitation (#3257)
2026-06-12 22:56:06 -07:00

259 lines
9.1 KiB
TypeScript

/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { posix as pathPosix } from 'node:path';
import type { Actor } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { FSService } from '../../services/fs/FSService.js';
import { expandTildePath, resolveNode } from '../../services/fs/resolveNode.js';
import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js';
import type { S3ObjectStore } from '../../stores/fs/S3ObjectStore.js';
import { mimeFromName } from '../../util/fileSigning.js';
import { secureFetch } from '../../util/secureHttp.js';
/**
* Resolve a file-like input sent through the drivers API into a Buffer.
*
* puter-js sends driver args as plain JSON (no multipart). `audio`, `source`,
* and similar file fields arrive as one of:
* • a data URL string (`data:image/png;base64,...`)
* • a web URL string (`https://example.com/image.png`)
* • a plain path string (`/alice/music/sample.mp3`)
* • an object with `{ path?, uid?, uuid? }`
*
* This helper collapses those shapes into `{ buffer, filename, mimeType }`.
*/
export interface LoadedFile {
buffer: Buffer;
filename: string;
mimeType: string | null;
// When the input was an FS reference (path/uid), carries the entry back
// so drivers can do FS-specific things (e.g. S3 CopyObject for OCR) — null
// for data-URL inputs.
fsEntry: {
uuid: string;
path: string;
bucket: string | null;
bucketRegion: string | null;
size: number | null;
sqlId: number | null; // null in case of base64 URL or a future dynamodb FS.
} | null;
}
const DATA_URL_PATTERN = /^data:([^;,]+)?(?:;([^,]*))?,(.*)$/s;
export async function loadFileInput(
stores: { fsEntry: FSEntryStore; s3Object: S3ObjectStore },
fsService: FSService,
actor: Actor,
input: unknown,
options: { maxBytes?: number; acceptWebInput?: true } = {},
): Promise<LoadedFile> {
if (!input) {
throw new HttpError(400, 'Missing file input', {
legacyCode: 'bad_request',
});
}
if (!Number.isFinite(Number(actor?.user?.id ?? NaN))) {
throw new HttpError(401, 'Unauthorized', {
legacyCode: 'unauthorized',
});
}
// Data URL — decode base64/plain inline.
if (typeof input === 'string' && input.startsWith('data:')) {
const match = DATA_URL_PATTERN.exec(input);
if (!match)
throw new HttpError(400, 'Invalid data URL', {
legacyCode: 'bad_request',
});
const mime = match[1] ?? 'application/octet-stream';
const encoding = (match[2] ?? '').trim();
const payload = match[3] ?? '';
const buffer =
encoding.toLowerCase() === 'base64'
? Buffer.from(payload, 'base64')
: Buffer.from(decodeURIComponent(payload));
assertMax(buffer, options.maxBytes);
return {
buffer,
filename: filenameFromMime(mime),
mimeType: mime,
fsEntry: null,
};
}
// Web URL — fetch via SSRF-guarded secureFetch.
if (
typeof input === 'string' &&
(input.startsWith('https://') || input.startsWith('http://')) &&
options.acceptWebInput
) {
const response = await secureFetch(input);
if (!response.ok) {
throw new HttpError(
400,
`Failed to fetch URL (status ${response.status})`,
{ legacyCode: 'bad_request' },
);
}
const arrayBuf = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuf);
assertMax(buffer, options.maxBytes);
const contentType = response.headers.get('content-type');
const mime =
contentType?.split(';')[0]?.trim() ||
mimeFromName(input) ||
'application/octet-stream';
return {
buffer,
filename: inferFilenameFromUrlOrPath(input),
mimeType: mime,
fsEntry: null,
};
}
// Path string or object reference → resolve into FSEntry, then S3 read.
const username = actor?.user?.username;
const expandPath = (path: string | undefined) =>
path !== undefined ? expandTildePath(path, username) : undefined;
const ref: { path?: string; uid?: string; uuid?: string } =
typeof input === 'string'
? { path: expandPath(input) }
: (() => {
const record = input as Record<string, unknown>;
return {
path: expandPath(
typeof record.path === 'string'
? record.path
: undefined,
),
uid:
typeof record.uid === 'string'
? record.uid
: undefined,
uuid:
typeof record.uuid === 'string'
? record.uuid
: undefined,
};
})();
const entry = await resolveNode(stores.fsEntry, ref, { required: true });
if (!entry)
throw new HttpError(404, 'File not found', { legacyCode: 'not_found' });
if (entry.isDir)
throw new HttpError(400, 'Expected a file, got a directory', {
legacyCode: 'bad_request',
});
if (entry.isShortcut || entry.isSymlink) {
throw new HttpError(
400,
'Cannot load content of a symlink or shortcut directly',
{ legacyCode: 'shortcut_target_not_found' },
);
}
// ACL gate: resolveNode does global UID/UUID/ID/path lookups, no
// namespace check. Without this check, an attacker controlling
// `path`/`uid`/`uuid` (e.g. AI chat `puter_path` content parts) could
// exfiltrate any user's file. Must run before the S3 read below.
await fsService.checkFSAccess(entry, actor, 'read');
const objectKey = entry.uuid;
const { body, contentType, contentLength } =
await stores.s3Object.getObjectStream(
{
bucket: stores.s3Object.resolveBucket(entry.bucket),
objectKey,
},
stores.s3Object.resolveRegion(entry.bucketRegion),
);
if (contentLength && options.maxBytes && contentLength > options.maxBytes) {
body.destroy();
throw new HttpError(
413,
`File exceeds max size (${options.maxBytes} bytes)`,
{ legacyCode: 'storage_limit_reached' },
);
}
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of body) {
const buf = Buffer.isBuffer(chunk)
? chunk
: Buffer.from(chunk as Uint8Array);
total += buf.byteLength;
if (options.maxBytes && total > options.maxBytes) {
body.destroy();
throw new HttpError(
413,
`File exceeds max size (${options.maxBytes} bytes)`,
{ legacyCode: 'storage_limit_reached' },
);
}
chunks.push(buf);
}
const buffer = Buffer.concat(chunks, total);
const resolvedMime =
contentType ?? mimeFromName(entry.name) ?? 'application/octet-stream';
return {
buffer,
filename: entry.name,
mimeType: resolvedMime,
fsEntry: {
uuid: entry.uuid,
path: entry.path,
bucket: entry.bucket,
bucketRegion: entry.bucketRegion,
size: entry.size,
sqlId: entry.id,
},
};
}
function assertMax(buffer: Buffer, maxBytes?: number): void {
if (maxBytes && buffer.byteLength > maxBytes) {
throw new HttpError(413, `Input exceeds max size (${maxBytes} bytes)`, {
legacyCode: 'storage_limit_reached',
});
}
}
function filenameFromMime(mime: string): string {
const ext = mime.split('/')[1]?.split('+')[0] ?? 'bin';
return `input.${ext}`;
}
export function inferFilenameFromUrlOrPath(
value: string,
fallback = 'input',
): string {
try {
const url = new URL(value);
const basename = pathPosix.basename(url.pathname);
if (basename) return basename;
} catch {
// Not a URL; try treating as a file path.
}
const basename = pathPosix.basename(value);
return basename || fallback;
}