fix: use 0o644 for inbound media files to allow sandbox read access (#17943)

* fix: use 0o644 for inbound media files to allow sandbox read access

Inbound media files were saved with 0o600 permissions, making them
unreadable from Docker sandbox containers running as different users.

Change to 0o644 (world-readable) so sandboxed agents can access
downloaded attachments.

Fixes #17941

Co-Authored-By: Claude <noreply@anthropic.com>

* test(media): assert URL-sourced inbound files use 0o644

* test(media): make redirect file-mode assertion platform-aware

* docs(media): clarify 0o644 is for sandbox UID compatibility

---------

Co-authored-by: zerone0x <zerone0x@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
zerone0x
2026-03-02 14:14:39 +08:00
committed by GitHub
parent 366374b4ff
commit 376a52a5ba
2 changed files with 9 additions and 3 deletions

View File

@@ -89,6 +89,9 @@ describe("media store redirects", () => {
expect(saved.contentType).toBe("text/plain");
expect(path.extname(saved.path)).toBe(".txt");
expect(await fs.readFile(saved.path, "utf8")).toBe("redirected");
const stat = await fs.stat(saved.path);
const expectedMode = process.platform === "win32" ? 0o666 : 0o644;
expect(stat.mode & 0o777).toBe(expectedMode);
});
it("fails when redirect response omits location header", async () => {

View File

@@ -14,6 +14,9 @@ const resolveMediaDir = () => path.join(resolveConfigDir(), "media");
export const MEDIA_MAX_BYTES = 5 * 1024 * 1024; // 5MB default
const MAX_BYTES = MEDIA_MAX_BYTES;
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
// Files are intentionally readable by non-owner UIDs so Docker sandbox containers can access
// inbound media. The containing state/media directories remain 0o700, which is the trust boundary.
const MEDIA_FILE_MODE = 0o644;
type RequestImpl = typeof httpRequest;
type ResolvePinnedHostnameImpl = typeof resolvePinnedHostname;
@@ -170,7 +173,7 @@ async function downloadToFile(
let total = 0;
const sniffChunks: Buffer[] = [];
let sniffLen = 0;
const out = createWriteStream(dest, { mode: 0o600 });
const out = createWriteStream(dest, { mode: MEDIA_FILE_MODE });
res.on("data", (chunk) => {
total += chunk.length;
if (sniffLen < 16384) {
@@ -284,7 +287,7 @@ export async function saveMediaSource(
const ext = extensionForMime(mime) ?? path.extname(source);
const id = ext ? `${baseId}${ext}` : baseId;
const dest = path.join(dir, id);
await fs.writeFile(dest, buffer, { mode: 0o600 });
await fs.writeFile(dest, buffer, { mode: MEDIA_FILE_MODE });
return { id, path: dest, size: stat.size, contentType: mime };
} catch (err) {
if (err instanceof SafeOpenError) {
@@ -323,6 +326,6 @@ export async function saveMediaBuffer(
}
const dest = path.join(dir, id);
await fs.writeFile(dest, buffer, { mode: 0o600 });
await fs.writeFile(dest, buffer, { mode: MEDIA_FILE_MODE });
return { id, path: dest, size: buffer.byteLength, contentType: mime };
}