configurable limits, security fixes

AuthorKonata <konata@posteo.jp>
Date
Commit08f36f98b294d525bc10af0132f230076f519558
Parentf55e964
11 files changed, 332 insertions(+), 67 deletions(-)
MREADME.md
@@ -2,5 +2,18 @@
22 Pastebin with encryption, syntax highlighting and media previews, optionally without JS enabled in the browser. Also supports easy interaction via curl
33 ## Setup
44 A `Containerfile` and `compose.yml` is provided in this repo, just run `docker-compose up` or `podman-compose up` to start the server at port 3000. For manual setup without containers just follow the setup done in `Containerfile`
5+## Configuration
6+ZBin is configured through environment variables (all optional):
7+
8+| Variable | Default | Description |
9+| --- | --- | --- |
10+| `MAX_UPLOAD_BYTES` | `104857600` (100 MiB) | Hard cap on a single upload. |
11+| `MAX_AGE_MINUTES` | unlimited | Maximum retention. When set, every upload is deleted after at most this many minutes (a longer requested `delete_in_minutes` is clamped down). |
12+| `UPLOAD_COOLDOWN_SECONDS` | `0` (off) | Minimum seconds between uploads from the same client IP. |
13+| `BEHIND_PROXY` | `false` | Set to `true` (or `1`) when running behind a trusted TLS-terminating reverse proxy (the usual production setup). Reads `X-Forwarded-For` / `X-Real-IP` for the client IP **and** adds the `Secure` flag to the password cookie. Leave off for direct/local HTTP. |
14+
15+> Passwords for server-side decryption travel in a cookie, so ZBin should always be
16+> served over HTTPS behind a reverse proxy (with `BEHIND_PROXY=true`) in any real deployment.
17+
518 ## Screenshot
619 ![screenshot](screenshot.png)
Massets/default.css
@@ -62,6 +62,18 @@ img {
6262 margin-bottom: 0;
6363 }
6464
65+#server-limits {
66+ align-self: flex-start;
67+
68+ ul {
69+ margin: 0.25rem 0 1rem;
70+ }
71+
72+ li {
73+ margin: 0;
74+ }
75+}
76+
6577 .help-icon {
6678 width: 2rem;
6779 height: 2rem;
Massets/encrypt.py
@@ -1,7 +1,6 @@
11 from cryptography.hazmat.primitives import hashes
22 from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
3-from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
4-from cryptography.hazmat.primitives import padding
3+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
54 from cryptography.hazmat.backends import default_backend
65 import os
76 import sys
@@ -10,16 +9,19 @@ def encrypt(content: bytes, password: str) -> bytes:
109 """
1110 Encrypts the given content using the provided password.
1211
12+ Uses AES-256-GCM with a PBKDF2-HMAC-SHA512 derived key, matching the
13+ server/browser implementation in src/crypto.ts.
14+
1315 Args:
1416 content (bytes): The content to be encrypted.
1517 password (str): The password used for encryption.
1618
1719 Returns:
18- bytes: The encrypted content.
20+ bytes: salt[16] | iv[12] | ciphertext+tag
1921 """
2022
2123 # Generate a random initialization vector (IV) and salt
22- iv = os.urandom(16)
24+ iv = os.urandom(12)
2325 salt = os.urandom(16)
2426
2527 # Derive a key from the password using PBKDF2
@@ -27,21 +29,13 @@ def encrypt(content: bytes, password: str) -> bytes:
2729 algorithm=hashes.SHA512(),
2830 length=32,
2931 salt=salt,
30- iterations=100000,
32+ iterations=210000,
3133 backend=default_backend()
3234 )
3335 key = kdf.derive(password.encode())
3436
35- # Create a cipher object with AES-256-CBC
36- cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
37- encryptor = cipher.encryptor()
38-
39- # Pad the content to a multiple of the block length
40- padder = padding.PKCS7(128).padder()
41- padded_content = padder.update(content) + padder.finalize()
42-
43- # Encrypt the padded content
44- encrypted_content = encryptor.update(padded_content) + encryptor.finalize()
37+ # Encrypt with AES-256-GCM (the authentication tag is appended to the ciphertext)
38+ encrypted_content = AESGCM(key).encrypt(iv, content, None)
4539
4640 # Return the salt, IV, and encrypted content concatenated
4741 return salt + iv + encrypted_content
@@ -51,4 +45,3 @@ if __name__ == "__main__":
5145 sys.exit(1)
5246 data = encrypt(open(sys.argv[1], "rb").read(), sys.argv[2])
5347 open(sys.argv[3], "wb").write(data)
54-
Mcompose.yaml
@@ -6,3 +6,9 @@ services:
66 - ./db:/app/db
77 ports:
88 - 3000:3000
9+ environment:
10+ # See README for details. All optional.
11+ # MAX_UPLOAD_BYTES: "104857600" # 100 MiB
12+ # MAX_AGE_MINUTES: "10080" # auto-delete after 7 days
13+ # UPLOAD_COOLDOWN_SECONDS: "10" # per-IP upload cooldown
14+ # BEHIND_PROXY: "true" # trust forwarded headers + Secure cookies (TLS reverse proxy)
Mpackage.json
@@ -6,7 +6,8 @@
66 "dev": "bun run build && bun run --watch src/index.ts",
77 "prod": "bun run build && bun run src/index.ts",
88 "format": "biome format --write",
9- "lint": "biome lint"
9+ "lint": "biome lint",
10+ "test": "bun test"
1011 },
1112 "dependencies": {
1213 "@chneau/elysia-compression": "^1.0.11",
Asrc/components.test.ts
@@ -0,0 +1,33 @@
1+import { expect, test } from "bun:test";
2+import { humanReadableTime, ShowFile } from "./components";
3+
4+const UUID = "00000000-0000-0000-0000-000000000000";
5+
6+test("escapes filename in the file view (no stored XSS)", async () => {
7+ const evil = "<img src=x onerror=alert(1)>";
8+ const html = await ShowFile(
9+ evil,
10+ UUID,
11+ new TextEncoder().encode("hi"),
12+ "none",
13+ null,
14+ );
15+ expect(html).not.toContain(evil);
16+ expect(html).toContain("&lt;img");
17+});
18+
19+test("escapes filename in the encrypted overlay (no stored XSS)", async () => {
20+ const evil = "<script>alert(1)</script>";
21+ const html = await ShowFile(evil, UUID, null, "none", null);
22+ expect(html).not.toContain(evil);
23+ expect(html).toContain("&lt;script&gt;");
24+});
25+
26+test("humanReadableTime breaks a total down (no spurious 'expired')", () => {
27+ expect(humanReadableTime(4320)).toBe("3 days"); // exact multiple of a day
28+ expect(humanReadableTime(120)).toBe("2 hours"); // exact multiple of an hour
29+ expect(humanReadableTime(1)).toBe("1 minute");
30+ expect(humanReadableTime(1501)).toBe("1 day 1 hour 1 minute");
31+ expect(humanReadableTime(0)).toBe("expired");
32+ expect(humanReadableTime(-5)).toBe("expired");
33+});
Msrc/components.tsx
@@ -4,6 +4,7 @@ import type { PropsWithChildren } from "@kitajs/html";
44 import { escapeHTML } from "bun";
55 import { fileTypeFromBuffer } from "file-type";
66 import hljs from "highlight.js";
7+import { config } from "./config";
78 import { humanFileSize, isValidUTF8 } from "./shared";
89
910 export const filetypes = ["none", "blob"].concat(hljs.listLanguages().sort());
@@ -38,10 +39,46 @@ export function NotFound() {
3839 );
3940 }
4041
42+// Surfaces the server's configured abuse limits so users know the rules before
43+// uploading. Each line only shows when the corresponding limit is actually set.
44+function ServerLimits() {
45+ return (
46+ <div id="server-limits">
47+ <small>Server limits:</small>
48+ <ul>
49+ <li>
50+ <small>Max upload size: {humanFileSize(config.maxUploadBytes)}</small>
51+ </li>
52+ {config.maxAgeMinutes !== null ? (
53+ <li>
54+ <small>
55+ Files are kept for at most{" "}
56+ {humanReadableTime(config.maxAgeMinutes)} before being deleted
57+ </small>
58+ </li>
59+ ) : (
60+ ""
61+ )}
62+ {config.uploadCooldownSeconds > 0 ? (
63+ <li>
64+ <small>
65+ You can upload once every {config.uploadCooldownSeconds} second
66+ {config.uploadCooldownSeconds > 1 ? "s" : ""} from the same address
67+ </small>
68+ </li>
69+ ) : (
70+ ""
71+ )}
72+ </ul>
73+ </div>
74+ );
75+}
76+
4177 export function Index(hostname: string) {
4278 return (
4379 <Template css="/default.css">
4480 <h1>⚡ZBin⚡</h1>
81+ <ServerLimits />
4582 <dialog id="upload-dialog">File is being uploaded, please wait</dialog>
4683 <form
4784 action="/upload"
@@ -180,7 +217,10 @@ export function WrongPassword() {
180217 );
181218 }
182219
183-function humanReadableTime(minutes: number) {
220+export function humanReadableTime(minutes: number) {
221+ // Guard on the total before it is broken into units below; an already-expired
222+ // (or sub-minute) duration has no meaningful breakdown.
223+ if (minutes <= 0) return "expired";
184224 const years = Math.floor(minutes / 525600);
185225 minutes %= 525600;
186226 const days = Math.floor(minutes / 1440);
@@ -227,15 +267,19 @@ export async function ShowFile(
227267 );
228268 }
229269 } else {
230- const filetype = await fileTypeFromBuffer(content);
231- if (filetype) {
232- const base64Content = `data:${filetype.mime};base64,${Buffer.from(content).toString("base64")}`;
233- if (filetype.mime.startsWith("audio/")) {
234- preview = <audio controls="" src={base64Content} />;
235- } else if (filetype.mime.startsWith("video/")) {
236- preview = <video controls src={base64Content} />;
237- } else if (filetype.mime.startsWith("image/")) {
238- preview = <img src={base64Content} alt={filename} />;
270+ // Point media previews at /raw/:uuid rather than inlining the whole
271+ // file as a base64 data URI (which would balloon the HTML and server
272+ // memory for large files). /raw serves a safe content-type and, for
273+ // encrypted files, decrypts using the path-scoped password cookie.
274+ const detected = await fileTypeFromBuffer(content);
275+ const rawUrl = `/raw/${uuid}`;
276+ if (detected) {
277+ if (detected.mime.startsWith("audio/")) {
278+ preview = <audio controls="" src={rawUrl} />;
279+ } else if (detected.mime.startsWith("video/")) {
280+ preview = <video controls src={rawUrl} />;
281+ } else if (detected.mime.startsWith("image/")) {
282+ preview = <img src={rawUrl} alt={filename} />;
239283 }
240284 }
241285 }
@@ -247,7 +291,7 @@ export async function ShowFile(
247291 ""
248292 ) : (
249293 <div id="decrypt-overlay">
250- <h1>Encrypted file: {filename}</h1>
294+ <h1 safe>Encrypted file: {filename}</h1>
251295 <form
252296 id="decrypt-form"
253297 action={`/set-cookie/${uuid}`}
@@ -288,7 +332,9 @@ export async function ShowFile(
288332 </div>
289333 )}
290334 <div id="content">
291- <div id="filename">{filename}</div>
335+ <div id="filename" safe>
336+ {filename}
337+ </div>
292338 <div id="mediabox">{preview}</div>
293339 </div>
294340 <div id="sidebar">
@@ -321,7 +367,10 @@ export async function ShowFile(
321367 export function SetCookie(uuid: string) {
322368 return (
323369 <Template css="/default.css">
324- Redirecting you back to <a href={`/show/${uuid}`}>/show/{uuid}</a>
370+ Redirecting you back to{" "}
371+ <a safe href={`/show/${uuid}`}>
372+ /show/{uuid}
373+ </a>
325374 </Template>
326375 );
327376 }
Asrc/config.ts
@@ -0,0 +1,35 @@
1+// Runtime configuration, read once from the environment at startup.
2+// Defaults are chosen to be secure-but-non-breaking; tighten them in production
3+// (see README for the full list and the "run behind TLS" note).
4+
5+const env = process.env
6+
7+/** parseInt with a default that distinguishes "unset" from "explicit 0". */
8+function intEnv(value: string | undefined, defaultValue: number): number {
9+ if (value === undefined || value === "") return defaultValue;
10+ const parsed = parseInt(value, 10);
11+ return Number.isFinite(parsed) ? parsed : defaultValue;
12+}
13+
14+/** Truthy only for "true"/"1"; anything else (incl. "false", "0", unset) is off. */
15+function boolEnv(value: string | undefined): boolean {
16+ return value === "true" || value === "1";
17+}
18+
19+export const config = {
20+ // Hard cap on a single upload, in bytes (default 100 MiB).
21+ maxUploadBytes: intEnv(Bun.env.MAX_UPLOAD_BYTES, 100 * 1024 * 1024),
22+ // Maximum retention in minutes. null = unlimited; when set, every upload's
23+ // deletion time is clamped to at most now + maxAgeMinutes.
24+ maxAgeMinutes: Bun.env.MAX_AGE_MINUTES
25+ ? intEnv(Bun.env.MAX_AGE_MINUTES, 0) || null
26+ : null,
27+ // Minimum seconds between uploads from the same client IP. 0 = disabled.
28+ uploadCooldownSeconds: intEnv(Bun.env.UPLOAD_COOLDOWN_SECONDS, 0),
29+ // Set when running behind a trusted TLS-terminating reverse proxy (the usual
30+ // production setup). Enables reading X-Forwarded-For / X-Real-IP for the
31+ // client IP and adds the Secure flag to the password cookie. Leave off for
32+ // direct/local HTTP, otherwise clients can spoof the cooldown key and the
33+ // Secure cookie won't be sent over plain HTTP.
34+ behindProxy: boolEnv(env.BEHIND_PROXY),
35+};
Asrc/crypto.test.ts
@@ -0,0 +1,28 @@
1+import { describe, expect, test } from "bun:test";
2+import { decrypt, encrypt } from "./crypto";
3+
4+describe("crypto (AES-256-GCM)", () => {
5+ test("round-trips content with the correct password", async () => {
6+ const data = new TextEncoder().encode("hello zbin 🔐 multi-byte");
7+ const enc = await encrypt(data, "correct horse battery staple");
8+ const dec = await decrypt(enc, "correct horse battery staple");
9+ expect(new TextDecoder().decode(dec)).toBe("hello zbin 🔐 multi-byte");
10+ });
11+
12+ test("wire format is salt[16] | iv[12] | ciphertext+tag", async () => {
13+ const enc = await encrypt(new Uint8Array([1, 2, 3]), "pw");
14+ // 16 (salt) + 12 (iv) + 3 (plaintext) + 16 (GCM tag)
15+ expect(enc.length).toBe(16 + 12 + 3 + 16);
16+ });
17+
18+ test("rejects a wrong password (authenticated)", async () => {
19+ const enc = await encrypt(new Uint8Array([1, 2, 3]), "right");
20+ await expect(decrypt(enc, "wrong")).rejects.toThrow();
21+ });
22+
23+ test("rejects tampered ciphertext", async () => {
24+ const enc = await encrypt(new Uint8Array([9, 9, 9]), "pw");
25+ enc[enc.length - 1] ^= 0xff; // flip a tag byte
26+ await expect(decrypt(enc, "pw")).rejects.toThrow();
27+ });
28+});
Msrc/crypto.ts
@@ -1,7 +1,9 @@
1-// AES-256-CBC encryption with a PBKDF2-derived key, using the WebCrypto API
2-// (crypto.subtle) which is available both in Bun (server) and the browser
3-// (client), so encryption/decryption is defined once for both sides.
4-// Wire format: salt[16] | iv[16] | ciphertext.
1+// AES-256-GCM (authenticated) encryption with a PBKDF2-derived key, using the
2+// WebCrypto API (crypto.subtle) which is available both in Bun (server) and the
3+// browser (client), so encryption/decryption is defined once for both sides.
4+// GCM gives us integrity/authentication for free (the tag is appended to the
5+// ciphertext by WebCrypto), so tampering and padding-oracle attacks don't apply.
6+// Wire format: salt[16] | iv[12] | ciphertext+tag.
57
68 async function deriveKey(
79 password: string,
@@ -16,9 +18,9 @@ async function deriveKey(
1618 ["deriveKey"],
1719 );
1820 return crypto.subtle.deriveKey(
19- { name: "PBKDF2", salt, iterations: 100000, hash: "SHA-512" },
21+ { name: "PBKDF2", salt, iterations: 210000, hash: "SHA-512" },
2022 material,
21- { name: "AES-CBC", length: 256 },
23+ { name: "AES-GCM", length: 256 },
2224 false,
2325 usage,
2426 );
@@ -28,11 +30,11 @@ export async function encrypt(
2830 content: Uint8Array,
2931 password: string,
3032 ): Promise<Uint8Array> {
31- const iv = crypto.getRandomValues(new Uint8Array(16));
33+ const iv = crypto.getRandomValues(new Uint8Array(12));
3234 const salt = crypto.getRandomValues(new Uint8Array(16));
3335 const key = await deriveKey(password, salt, ["encrypt"]);
3436 const ciphertext = await crypto.subtle.encrypt(
35- { name: "AES-CBC", iv },
37+ { name: "AES-GCM", iv },
3638 key,
3739 content,
3840 );
@@ -44,12 +46,12 @@ export async function decrypt(
4446 password: string,
4547 ): Promise<Uint8Array> {
4648 const salt = data.slice(0, 16);
47- const iv = data.slice(16, 32);
49+ const iv = data.slice(16, 28);
4850 const key = await deriveKey(password, salt, ["decrypt"]);
4951 const plaintext = await crypto.subtle.decrypt(
50- { name: "AES-CBC", iv },
52+ { name: "AES-GCM", iv },
5153 key,
52- data.slice(32),
54+ data.slice(28),
5355 );
5456 return new Uint8Array(plaintext);
5557 }
Msrc/index.ts
@@ -4,6 +4,7 @@ import { html } from "@elysiajs/html";
44 import staticPlugin from "@elysiajs/static";
55 import { randomUUIDv7 } from "bun";
66 import { Elysia, StatusMap, t } from "elysia";
7+import { fileTypeFromBuffer } from "file-type";
78 import {
89 filetypes,
910 Index,
@@ -12,16 +13,43 @@ import {
1213 ShowFile,
1314 WrongPassword,
1415 } from "./components";
16+import { config } from "./config";
1517 import { decrypt, encrypt } from "./crypto";
1618
1719 const db = new Database("./db/db.sqlite");
1820 db.run("PRAGMA foreign_keys = ON");
19-db.run("PRAGMA journal_mode = WAL2");
21+db.run("PRAGMA journal_mode = WAL");
2022 db.run(
2123 "CREATE TABLE IF NOT EXISTS files (uuid TEXT PRIMARY KEY, filename TEXT NOT NULL, content BLOB NOT NULL, filetype TEXT NOT NULL, encrypted INTEGER NOT NULL, delete_at INTEGER) STRICT",
2224 );
2325 db.run("PRAGMA optimize");
2426
27+// uuid route params are constrained to this shape so they can't be used to
28+// inject CRLF/extra directives into the Set-Cookie Path or content-disposition.
29+const UUID_PATTERN = "^[0-9a-fA-F-]{36}$";
30+
31+// Per-IP timestamp of the last accepted upload, used for the upload cooldown.
32+// Pruned by the cron below so it can't grow without bound.
33+const lastUpload = new Map<string, number>();
34+
35+type MinimalServer = {
36+ requestIP(req: Request): { address: string } | null;
37+} | null;
38+
39+function clientIp(
40+ server: MinimalServer,
41+ request: Request,
42+ headers: Record<string, string | undefined>,
43+): string {
44+ if (config.behindProxy) {
45+ const xff = headers["x-forwarded-for"]?.split(",")[0]?.trim();
46+ if (xff) return xff;
47+ const real = headers["x-real-ip"];
48+ if (real) return real;
49+ }
50+ return server?.requestIP(request)?.address ?? "unknown";
51+}
52+
2553 function stringArrayToEnum<T extends string>(
2654 arr: readonly T[],
2755 ): { [K in T]: K } {
@@ -33,7 +61,7 @@ function stringArrayToEnum<T extends string>(
3361
3462 const app = new Elysia({
3563 serve: {
36- maxRequestBodySize: 1024 * 1024 * 1024, // 1GB
64+ maxRequestBodySize: config.maxUploadBytes,
3765 },
3866 })
3967 .use(staticPlugin({ assets: "./assets", prefix: "/" }))
@@ -44,21 +72,49 @@ const app = new Elysia({
4472 pattern: "*/5 * * * * *",
4573 run() {
4674 db.exec("DELETE FROM files WHERE delete_at < strftime('%s', 'now')");
75+ if (config.uploadCooldownSeconds > 0) {
76+ const cutoff = Date.now() - config.uploadCooldownSeconds * 1000;
77+ for (const [ip, ts] of lastUpload) {
78+ if (ts < cutoff) lastUpload.delete(ip);
79+ }
80+ }
4781 },
4882 }),
4983 )
5084 .get("/", ({ server }) => Index(server?.url.toString() ?? ""))
5185 .post(
5286 "/upload",
53- async ({ set, body }) => {
87+ async ({ set, body, server, request, headers }) => {
88+ const ip = clientIp(server, request, headers);
89+ const now = Date.now();
90+ if (config.uploadCooldownSeconds > 0) {
91+ const last = lastUpload.get(ip) ?? 0;
92+ if (now - last < config.uploadCooldownSeconds * 1000) {
93+ set.status = 429; // Too Many Requests
94+ return "Upload cooldown active, please wait before uploading again";
95+ }
96+ }
97+ if (body.file.size > config.maxUploadBytes) {
98+ set.status = 413; // Payload Too Large
99+ return `File exceeds the maximum upload size of ${config.maxUploadBytes} bytes`;
100+ }
101+
54102 const uuid = randomUUIDv7();
55103 let content: Uint8Array = Buffer.from(await body.file.bytes());
56104 let encrypted = false;
57- let delete_at: number | null = null;
58- if (body.delete_in_minutes) {
59- delete_at =
60- Math.floor(Date.now() / 1000) + Number(body.delete_in_minutes) * 60;
105+
106+ // Retention: take the requested minutes (if any) and clamp it to the
107+ // configured maximum age, so storage is time-bounded when MAX_AGE_MINUTES is set.
108+ let minutes: number | null =
109+ body.delete_in_minutes && Number(body.delete_in_minutes) > 0
110+ ? Number(body.delete_in_minutes)
111+ : null;
112+ if (config.maxAgeMinutes !== null) {
113+ minutes = Math.min(minutes ?? config.maxAgeMinutes, config.maxAgeMinutes);
61114 }
115+ const delete_at =
116+ minutes !== null ? Math.floor(now / 1000) + minutes * 60 : null;
117+
62118 if (body.encrypted === "on") {
63119 encrypted = true;
64120 } else if (body.password) {
@@ -76,6 +132,7 @@ const app = new Elysia({
76132 delete_at,
77133 ],
78134 );
135+ if (config.uploadCooldownSeconds > 0) lastUpload.set(ip, now);
79136 set.status = StatusMap["See Other"];
80137 set.headers.location = `/show/${uuid}`;
81138 return `Created with id: ${uuid}`;
@@ -84,15 +141,15 @@ const app = new Elysia({
84141 body: t.Object({
85142 file: t.File(),
86143 filename: t.Optional(t.String()),
87- //filetype: t.String()/*todo:verifiy via t.Enum*/,
88144 filetype: t.Enum(stringArrayToEnum(filetypes)),
89145 password: t.Optional(t.String()),
90146 encrypted: t.Optional(t.String()),
91- encrypt_mode: t.Optional(t.String()),
92- delete_in_minutes: t.String({
93- format: "regex",
94- pattern: "(^$|^[0-9]+$)",
95- }),
147+ delete_in_minutes: t.Optional(
148+ t.String({
149+ format: "regex",
150+ pattern: "(^$|^[0-9]+$)",
151+ }),
152+ ),
96153 }),
97154 },
98155 )
@@ -108,7 +165,7 @@ const app = new Elysia({
108165 filename: string;
109166 content: Uint8Array;
110167 filetype: string;
111- encrypted: boolean;
168+ encrypted: number;
112169 delete_at: number | null;
113170 }) || null;
114171 if (!result) {
@@ -129,10 +186,11 @@ const app = new Elysia({
129186 try {
130187 result.content = await decrypt(result.content, password);
131188 } catch (_e) {
189+ const secure = config.behindProxy ? "; Secure" : "";
132190 set.status = StatusMap.Forbidden;
133191 set.headers["set-cookie"] = [
134- `password=; Path=/show/${params.uuid}; SameSite=lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
135- `password=; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
192+ `password=; Path=/show/${params.uuid}; SameSite=lax; HttpOnly${secure}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
193+ `password=; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly${secure}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
136194 ];
137195 return WrongPassword();
138196 }
@@ -147,16 +205,22 @@ const app = new Elysia({
147205 );
148206 },
149207 {
150- params: t.Object({ uuid: t.String() }),
208+ params: t.Object({
209+ uuid: t.String({ format: "regex", pattern: UUID_PATTERN }),
210+ }),
151211 cookie: t.Object({ password: t.Optional(t.String()) }),
152212 },
153213 )
154214 .post(
155215 "/set-cookie/:uuid",
156216 ({ set, body, params }) => {
217+ // encodeURIComponent keeps ';', CR/LF and other separators out of the
218+ // cookie value; Elysia URL-decodes the value again when it reads it back.
219+ const value = encodeURIComponent(body.password);
220+ const secure = config.behindProxy ? "; Secure" : "";
157221 set.headers["set-cookie"] = [
158- `password=${body.password}; Path=/show/${params.uuid}; SameSite=lax; HttpOnly`,
159- `password=${body.password}; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly`,
222+ `password=${value}; Path=/show/${params.uuid}; SameSite=lax; HttpOnly${secure}`,
223+ `password=${value}; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly${secure}`,
160224 ];
161225 set.headers.location = `/show/${params.uuid}`;
162226 set.status = StatusMap["See Other"];
@@ -165,9 +229,10 @@ const app = new Elysia({
165229 {
166230 body: t.Object({
167231 password: t.String(),
168- decrypt_mode: t.Optional(t.String()),
169232 }),
170- params: t.Object({ uuid: t.String() }),
233+ params: t.Object({
234+ uuid: t.String({ format: "regex", pattern: UUID_PATTERN }),
235+ }),
171236 },
172237 )
173238 .get(
@@ -181,14 +246,16 @@ const app = new Elysia({
181246 .get(params.uuid) as {
182247 content: Uint8Array;
183248 filename: string;
184- encrypted: boolean;
249+ encrypted: number;
185250 filetype: string;
186251 }) || null;
187252 if (!result) {
188253 set.status = StatusMap["Not Found"];
189254 return "File not found";
190255 }
191- if (result.encrypted && query.ignore_password !== "true") {
256+ const servingEncrypted =
257+ result.encrypted && query.ignore_password === "true";
258+ if (result.encrypted && !servingEncrypted) {
192259 if (!cookie.password.value) {
193260 set.status = StatusMap.Unauthorized;
194261 return 'This file is encrypted, set the cookie "password" with the correct password to allow the server to decrypt it';
@@ -200,15 +267,41 @@ const app = new Elysia({
200267 return "Incorrect password";
201268 }
202269 }
270+
271+ // Never let the browser sniff stored content into an executable type
272+ // (e.g. HTML/SVG running as same-origin script). Only whitelisted,
273+ // non-scriptable media is served inline with its real type; everything
274+ // else (incl. still-encrypted bytes) is an octet-stream attachment.
275+ let mime = "application/octet-stream";
276+ let disposition = "attachment";
277+ if (!servingEncrypted) {
278+ const detected = await fileTypeFromBuffer(result.content);
279+ if (
280+ detected &&
281+ detected.mime !== "image/svg+xml" &&
282+ (detected.mime.startsWith("image/") ||
283+ detected.mime.startsWith("audio/") ||
284+ detected.mime.startsWith("video/"))
285+ ) {
286+ mime = detected.mime;
287+ disposition = "inline";
288+ }
289+ }
290+
291+ const safeName = encodeURIComponent(result.filename);
292+ set.headers["x-content-type-options"] = "nosniff";
293+ set.headers["content-type"] = mime;
203294 set.headers.encrypted = result.encrypted ? "true" : "false";
204295 set.headers.filetype = result.filetype;
205- set.headers.filename = result.filename;
296+ set.headers.filename = safeName;
206297 set.headers["content-disposition"] =
207- `inline; filename=${result.filename}`;
298+ `${disposition}; filename*=UTF-8''${safeName}`;
208299 return result.content;
209300 },
210301 {
211- params: t.Object({ uuid: t.String() }),
302+ params: t.Object({
303+ uuid: t.String({ format: "regex", pattern: UUID_PATTERN }),
304+ }),
212305 cookie: t.Object({ password: t.Optional(t.String()) }),
213306 query: t.Object({ ignore_password: t.Optional(t.String()) }),
214307 },