🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add more blobs security

juprodh 2ffc2607 d2d04c81

+28 -6
+4
src/lib/limits.ts
··· 34 34 image: { 35 35 bytes: 10 * 1024 * 1024, 36 36 }, 37 + blobProxy: { 38 + timeoutMs: 10_000, 39 + maxBytes: 10 * 1024 * 1024, 40 + }, 37 41 } as const;
+21 -4
src/server/routes/blob.ts
··· 8 8 import { formatError } from "../../lib/errors.ts"; 9 9 import { resolvePdsEndpoint } from "../../lib/identity.ts"; 10 10 import { processImage } from "../../lib/image.ts"; 11 + import { LIMITS } from "../../lib/limits.ts"; 11 12 12 13 const LOCAL_BLOB_DIR = "data/blobs"; 13 14 ··· 91 92 .get("/blob/local/:filename", ({ params }) => { 92 93 const { filename } = params; 93 94 94 - // Path traversal guard 95 - if (filename.includes("..") || filename.includes("/")) { 95 + // Path traversal guard: resolve first, then verify the result is inside the allowed dir 96 + const filepath = resolve(LOCAL_BLOB_DIR, filename); 97 + const allowedDir = resolve(LOCAL_BLOB_DIR); 98 + if (!filepath.startsWith(`${allowedDir}/`)) { 96 99 return new Response("Invalid filename", { status: 400 }); 97 100 } 98 101 99 - const filepath = resolve(LOCAL_BLOB_DIR, filename); 100 102 const file = Bun.file(filepath); 101 103 102 104 return new Response(file, { ··· 122 124 return new Response("Failed to resolve DID", { status: 502 }); 123 125 } 124 126 127 + // SSRF guard: only allow HTTPS PDS endpoints in production 128 + const pdsUrl = new URL(pdsEndpoint); 129 + const isLocal = 130 + pdsUrl.hostname === "localhost" || pdsUrl.hostname === "127.0.0.1"; 131 + if (pdsUrl.protocol !== "https:" && !isLocal) { 132 + return new Response("PDS endpoint must be HTTPS", { status: 502 }); 133 + } 134 + 125 135 const blobUrl = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 126 136 127 137 let upstream: Response; 128 138 try { 129 - upstream = await fetch(blobUrl); 139 + upstream = await fetch(blobUrl, { 140 + signal: AbortSignal.timeout(LIMITS.blobProxy.timeoutMs), 141 + }); 130 142 } catch { 131 143 return new Response("Failed to fetch blob from PDS", { 132 144 status: 502, ··· 135 147 136 148 if (!upstream.ok) { 137 149 return new Response("Blob not found", { status: upstream.status }); 150 + } 151 + 152 + const contentLength = upstream.headers.get("content-length"); 153 + if (contentLength && Number(contentLength) > LIMITS.blobProxy.maxBytes) { 154 + return new Response("Blob too large", { status: 413 }); 138 155 } 139 156 140 157 const contentType =
+3 -2
tests/server/routes/blob.test.ts
··· 41 41 expect(res.status).toBe(400); 42 42 }); 43 43 44 - test("GET /blob/local/ rejects filename with double dots", async () => { 44 + test("GET /blob/local/ allows filename starting with dots (not a traversal)", async () => { 45 45 const res = await app.handle( 46 46 new Request("http://localhost/blob/local/..secret.jpg"), 47 47 ); 48 - expect(res.status).toBe(400); 48 + // ..secret.jpg resolves inside the allowed dir, so it's not a traversal 49 + expect(res.status).not.toBe(400); 49 50 }); 50 51 });