configurable limits, security fixes
MREADME.md
| @@ -2,5 +2,18 @@ | |||
|---|---|---|---|
| 2 | 2 | Pastebin with encryption, syntax highlighting and media previews, optionally without JS enabled in the browser. Also supports easy interaction via curl | |
| 3 | 3 | ## Setup | |
| 4 | 4 | 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 | + | ||
| 5 | 18 | ## Screenshot | |
| 6 | 19 |  | |
Massets/default.css
| @@ -62,6 +62,18 @@ img { | |||
|---|---|---|---|
| 62 | 62 | margin-bottom: 0; | |
| 63 | 63 | } | |
| 64 | 64 | ||
| 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 | + | ||
| 65 | 77 | .help-icon { | |
| 66 | 78 | width: 2rem; | |
| 67 | 79 | height: 2rem; | |
Massets/encrypt.py
| @@ -1,7 +1,6 @@ | |||
|---|---|---|---|
| 1 | 1 | from cryptography.hazmat.primitives import hashes | |
| 2 | 2 | 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 | |
| 5 | 4 | from cryptography.hazmat.backends import default_backend | |
| 6 | 5 | import os | |
| 7 | 6 | import sys | |
| @@ -10,16 +9,19 @@ def encrypt(content: bytes, password: str) -> bytes: | |||
|---|---|---|---|
| 10 | 9 | """ | |
| 11 | 10 | Encrypts the given content using the provided password. | |
| 12 | 11 | ||
| 12 | + | Uses AES-256-GCM with a PBKDF2-HMAC-SHA512 derived key, matching the | |
| 13 | + | server/browser implementation in src/crypto.ts. | |
| 14 | + | ||
| 13 | 15 | Args: | |
| 14 | 16 | content (bytes): The content to be encrypted. | |
| 15 | 17 | password (str): The password used for encryption. | |
| 16 | 18 | ||
| 17 | 19 | Returns: | |
| 18 | - | bytes: The encrypted content. | |
| 20 | + | bytes: salt[16] | iv[12] | ciphertext+tag | |
| 19 | 21 | """ | |
| 20 | 22 | ||
| 21 | 23 | # Generate a random initialization vector (IV) and salt | |
| 22 | - | iv = os.urandom(16) | |
| 24 | + | iv = os.urandom(12) | |
| 23 | 25 | salt = os.urandom(16) | |
| 24 | 26 | ||
| 25 | 27 | # Derive a key from the password using PBKDF2 | |
| @@ -27,21 +29,13 @@ def encrypt(content: bytes, password: str) -> bytes: | |||
|---|---|---|---|
| 27 | 29 | algorithm=hashes.SHA512(), | |
| 28 | 30 | length=32, | |
| 29 | 31 | salt=salt, | |
| 30 | - | iterations=100000, | |
| 32 | + | iterations=210000, | |
| 31 | 33 | backend=default_backend() | |
| 32 | 34 | ) | |
| 33 | 35 | key = kdf.derive(password.encode()) | |
| 34 | 36 | ||
| 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) | |
| 45 | 39 | ||
| 46 | 40 | # Return the salt, IV, and encrypted content concatenated | |
| 47 | 41 | return salt + iv + encrypted_content | |
| @@ -51,4 +45,3 @@ if __name__ == "__main__": | |||
|---|---|---|---|
| 51 | 45 | sys.exit(1) | |
| 52 | 46 | data = encrypt(open(sys.argv[1], "rb").read(), sys.argv[2]) | |
| 53 | 47 | open(sys.argv[3], "wb").write(data) | |
| 54 | - | ||
Mcompose.yaml
| @@ -6,3 +6,9 @@ services: | |||
|---|---|---|---|
| 6 | 6 | - ./db:/app/db | |
| 7 | 7 | ports: | |
| 8 | 8 | - 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 @@ | |||
|---|---|---|---|
| 6 | 6 | "dev": "bun run build && bun run --watch src/index.ts", | |
| 7 | 7 | "prod": "bun run build && bun run src/index.ts", | |
| 8 | 8 | "format": "biome format --write", | |
| 9 | - | "lint": "biome lint" | |
| 9 | + | "lint": "biome lint", | |
| 10 | + | "test": "bun test" | |
| 10 | 11 | }, | |
| 11 | 12 | "dependencies": { | |
| 12 | 13 | "@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("<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("<script>"); | |
| 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"; | |||
|---|---|---|---|
| 4 | 4 | import { escapeHTML } from "bun"; | |
| 5 | 5 | import { fileTypeFromBuffer } from "file-type"; | |
| 6 | 6 | import hljs from "highlight.js"; | |
| 7 | + | import { config } from "./config"; | |
| 7 | 8 | import { humanFileSize, isValidUTF8 } from "./shared"; | |
| 8 | 9 | ||
| 9 | 10 | export const filetypes = ["none", "blob"].concat(hljs.listLanguages().sort()); | |
| @@ -38,10 +39,46 @@ export function NotFound() { | |||
|---|---|---|---|
| 38 | 39 | ); | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 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 | + | ||
| 41 | 77 | export function Index(hostname: string) { | |
| 42 | 78 | return ( | |
| 43 | 79 | <Template css="/default.css"> | |
| 44 | 80 | <h1>⚡ZBin⚡</h1> | |
| 81 | + | <ServerLimits /> | |
| 45 | 82 | <dialog id="upload-dialog">File is being uploaded, please wait</dialog> | |
| 46 | 83 | <form | |
| 47 | 84 | action="/upload" | |
| @@ -180,7 +217,10 @@ export function WrongPassword() { | |||
|---|---|---|---|
| 180 | 217 | ); | |
| 181 | 218 | } | |
| 182 | 219 | ||
| 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"; | |
| 184 | 224 | const years = Math.floor(minutes / 525600); | |
| 185 | 225 | minutes %= 525600; | |
| 186 | 226 | const days = Math.floor(minutes / 1440); | |
| @@ -227,15 +267,19 @@ export async function ShowFile( | |||
|---|---|---|---|
| 227 | 267 | ); | |
| 228 | 268 | } | |
| 229 | 269 | } 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} />; | |
| 239 | 283 | } | |
| 240 | 284 | } | |
| 241 | 285 | } | |
| @@ -247,7 +291,7 @@ export async function ShowFile( | |||
|---|---|---|---|
| 247 | 291 | "" | |
| 248 | 292 | ) : ( | |
| 249 | 293 | <div id="decrypt-overlay"> | |
| 250 | - | <h1>Encrypted file: {filename}</h1> | |
| 294 | + | <h1 safe>Encrypted file: {filename}</h1> | |
| 251 | 295 | <form | |
| 252 | 296 | id="decrypt-form" | |
| 253 | 297 | action={`/set-cookie/${uuid}`} | |
| @@ -288,7 +332,9 @@ export async function ShowFile( | |||
|---|---|---|---|
| 288 | 332 | </div> | |
| 289 | 333 | )} | |
| 290 | 334 | <div id="content"> | |
| 291 | - | <div id="filename">{filename}</div> | |
| 335 | + | <div id="filename" safe> | |
| 336 | + | {filename} | |
| 337 | + | </div> | |
| 292 | 338 | <div id="mediabox">{preview}</div> | |
| 293 | 339 | </div> | |
| 294 | 340 | <div id="sidebar"> | |
| @@ -321,7 +367,10 @@ export async function ShowFile( | |||
|---|---|---|---|
| 321 | 367 | export function SetCookie(uuid: string) { | |
| 322 | 368 | return ( | |
| 323 | 369 | <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> | |
| 325 | 374 | </Template> | |
| 326 | 375 | ); | |
| 327 | 376 | } | |
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. | |
| 5 | 7 | ||
| 6 | 8 | async function deriveKey( | |
| 7 | 9 | password: string, | |
| @@ -16,9 +18,9 @@ async function deriveKey( | |||
|---|---|---|---|
| 16 | 18 | ["deriveKey"], | |
| 17 | 19 | ); | |
| 18 | 20 | return crypto.subtle.deriveKey( | |
| 19 | - | { name: "PBKDF2", salt, iterations: 100000, hash: "SHA-512" }, | |
| 21 | + | { name: "PBKDF2", salt, iterations: 210000, hash: "SHA-512" }, | |
| 20 | 22 | material, | |
| 21 | - | { name: "AES-CBC", length: 256 }, | |
| 23 | + | { name: "AES-GCM", length: 256 }, | |
| 22 | 24 | false, | |
| 23 | 25 | usage, | |
| 24 | 26 | ); | |
| @@ -28,11 +30,11 @@ export async function encrypt( | |||
|---|---|---|---|
| 28 | 30 | content: Uint8Array, | |
| 29 | 31 | password: string, | |
| 30 | 32 | ): Promise<Uint8Array> { | |
| 31 | - | const iv = crypto.getRandomValues(new Uint8Array(16)); | |
| 33 | + | const iv = crypto.getRandomValues(new Uint8Array(12)); | |
| 32 | 34 | const salt = crypto.getRandomValues(new Uint8Array(16)); | |
| 33 | 35 | const key = await deriveKey(password, salt, ["encrypt"]); | |
| 34 | 36 | const ciphertext = await crypto.subtle.encrypt( | |
| 35 | - | { name: "AES-CBC", iv }, | |
| 37 | + | { name: "AES-GCM", iv }, | |
| 36 | 38 | key, | |
| 37 | 39 | content, | |
| 38 | 40 | ); | |
| @@ -44,12 +46,12 @@ export async function decrypt( | |||
|---|---|---|---|
| 44 | 46 | password: string, | |
| 45 | 47 | ): Promise<Uint8Array> { | |
| 46 | 48 | const salt = data.slice(0, 16); | |
| 47 | - | const iv = data.slice(16, 32); | |
| 49 | + | const iv = data.slice(16, 28); | |
| 48 | 50 | const key = await deriveKey(password, salt, ["decrypt"]); | |
| 49 | 51 | const plaintext = await crypto.subtle.decrypt( | |
| 50 | - | { name: "AES-CBC", iv }, | |
| 52 | + | { name: "AES-GCM", iv }, | |
| 51 | 53 | key, | |
| 52 | - | data.slice(32), | |
| 54 | + | data.slice(28), | |
| 53 | 55 | ); | |
| 54 | 56 | return new Uint8Array(plaintext); | |
| 55 | 57 | } | |
Msrc/index.ts
| @@ -4,6 +4,7 @@ import { html } from "@elysiajs/html"; | |||
|---|---|---|---|
| 4 | 4 | import staticPlugin from "@elysiajs/static"; | |
| 5 | 5 | import { randomUUIDv7 } from "bun"; | |
| 6 | 6 | import { Elysia, StatusMap, t } from "elysia"; | |
| 7 | + | import { fileTypeFromBuffer } from "file-type"; | |
| 7 | 8 | import { | |
| 8 | 9 | filetypes, | |
| 9 | 10 | Index, | |
| @@ -12,16 +13,43 @@ import { | |||
|---|---|---|---|
| 12 | 13 | ShowFile, | |
| 13 | 14 | WrongPassword, | |
| 14 | 15 | } from "./components"; | |
| 16 | + | import { config } from "./config"; | |
| 15 | 17 | import { decrypt, encrypt } from "./crypto"; | |
| 16 | 18 | ||
| 17 | 19 | const db = new Database("./db/db.sqlite"); | |
| 18 | 20 | db.run("PRAGMA foreign_keys = ON"); | |
| 19 | - | db.run("PRAGMA journal_mode = WAL2"); | |
| 21 | + | db.run("PRAGMA journal_mode = WAL"); | |
| 20 | 22 | db.run( | |
| 21 | 23 | "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", | |
| 22 | 24 | ); | |
| 23 | 25 | db.run("PRAGMA optimize"); | |
| 24 | 26 | ||
| 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 | + | ||
| 25 | 53 | function stringArrayToEnum<T extends string>( | |
| 26 | 54 | arr: readonly T[], | |
| 27 | 55 | ): { [K in T]: K } { | |
| @@ -33,7 +61,7 @@ function stringArrayToEnum<T extends string>( | |||
|---|---|---|---|
| 33 | 61 | ||
| 34 | 62 | const app = new Elysia({ | |
| 35 | 63 | serve: { | |
| 36 | - | maxRequestBodySize: 1024 * 1024 * 1024, // 1GB | |
| 64 | + | maxRequestBodySize: config.maxUploadBytes, | |
| 37 | 65 | }, | |
| 38 | 66 | }) | |
| 39 | 67 | .use(staticPlugin({ assets: "./assets", prefix: "/" })) | |
| @@ -44,21 +72,49 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 44 | 72 | pattern: "*/5 * * * * *", | |
| 45 | 73 | run() { | |
| 46 | 74 | 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 | + | } | |
| 47 | 81 | }, | |
| 48 | 82 | }), | |
| 49 | 83 | ) | |
| 50 | 84 | .get("/", ({ server }) => Index(server?.url.toString() ?? "")) | |
| 51 | 85 | .post( | |
| 52 | 86 | "/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 | + | ||
| 54 | 102 | const uuid = randomUUIDv7(); | |
| 55 | 103 | let content: Uint8Array = Buffer.from(await body.file.bytes()); | |
| 56 | 104 | 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); | |
| 61 | 114 | } | |
| 115 | + | const delete_at = | |
| 116 | + | minutes !== null ? Math.floor(now / 1000) + minutes * 60 : null; | |
| 117 | + | ||
| 62 | 118 | if (body.encrypted === "on") { | |
| 63 | 119 | encrypted = true; | |
| 64 | 120 | } else if (body.password) { | |
| @@ -76,6 +132,7 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 76 | 132 | delete_at, | |
| 77 | 133 | ], | |
| 78 | 134 | ); | |
| 135 | + | if (config.uploadCooldownSeconds > 0) lastUpload.set(ip, now); | |
| 79 | 136 | set.status = StatusMap["See Other"]; | |
| 80 | 137 | set.headers.location = `/show/${uuid}`; | |
| 81 | 138 | return `Created with id: ${uuid}`; | |
| @@ -84,15 +141,15 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 84 | 141 | body: t.Object({ | |
| 85 | 142 | file: t.File(), | |
| 86 | 143 | filename: t.Optional(t.String()), | |
| 87 | - | //filetype: t.String()/*todo:verifiy via t.Enum*/, | |
| 88 | 144 | filetype: t.Enum(stringArrayToEnum(filetypes)), | |
| 89 | 145 | password: t.Optional(t.String()), | |
| 90 | 146 | 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 | + | ), | |
| 96 | 153 | }), | |
| 97 | 154 | }, | |
| 98 | 155 | ) | |
| @@ -108,7 +165,7 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 108 | 165 | filename: string; | |
| 109 | 166 | content: Uint8Array; | |
| 110 | 167 | filetype: string; | |
| 111 | - | encrypted: boolean; | |
| 168 | + | encrypted: number; | |
| 112 | 169 | delete_at: number | null; | |
| 113 | 170 | }) || null; | |
| 114 | 171 | if (!result) { | |
| @@ -129,10 +186,11 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 129 | 186 | try { | |
| 130 | 187 | result.content = await decrypt(result.content, password); | |
| 131 | 188 | } catch (_e) { | |
| 189 | + | const secure = config.behindProxy ? "; Secure" : ""; | |
| 132 | 190 | set.status = StatusMap.Forbidden; | |
| 133 | 191 | 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`, | |
| 136 | 194 | ]; | |
| 137 | 195 | return WrongPassword(); | |
| 138 | 196 | } | |
| @@ -147,16 +205,22 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 147 | 205 | ); | |
| 148 | 206 | }, | |
| 149 | 207 | { | |
| 150 | - | params: t.Object({ uuid: t.String() }), | |
| 208 | + | params: t.Object({ | |
| 209 | + | uuid: t.String({ format: "regex", pattern: UUID_PATTERN }), | |
| 210 | + | }), | |
| 151 | 211 | cookie: t.Object({ password: t.Optional(t.String()) }), | |
| 152 | 212 | }, | |
| 153 | 213 | ) | |
| 154 | 214 | .post( | |
| 155 | 215 | "/set-cookie/:uuid", | |
| 156 | 216 | ({ 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" : ""; | |
| 157 | 221 | 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}`, | |
| 160 | 224 | ]; | |
| 161 | 225 | set.headers.location = `/show/${params.uuid}`; | |
| 162 | 226 | set.status = StatusMap["See Other"]; | |
| @@ -165,9 +229,10 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 165 | 229 | { | |
| 166 | 230 | body: t.Object({ | |
| 167 | 231 | password: t.String(), | |
| 168 | - | decrypt_mode: t.Optional(t.String()), | |
| 169 | 232 | }), | |
| 170 | - | params: t.Object({ uuid: t.String() }), | |
| 233 | + | params: t.Object({ | |
| 234 | + | uuid: t.String({ format: "regex", pattern: UUID_PATTERN }), | |
| 235 | + | }), | |
| 171 | 236 | }, | |
| 172 | 237 | ) | |
| 173 | 238 | .get( | |
| @@ -181,14 +246,16 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 181 | 246 | .get(params.uuid) as { | |
| 182 | 247 | content: Uint8Array; | |
| 183 | 248 | filename: string; | |
| 184 | - | encrypted: boolean; | |
| 249 | + | encrypted: number; | |
| 185 | 250 | filetype: string; | |
| 186 | 251 | }) || null; | |
| 187 | 252 | if (!result) { | |
| 188 | 253 | set.status = StatusMap["Not Found"]; | |
| 189 | 254 | return "File not found"; | |
| 190 | 255 | } | |
| 191 | - | if (result.encrypted && query.ignore_password !== "true") { | |
| 256 | + | const servingEncrypted = | |
| 257 | + | result.encrypted && query.ignore_password === "true"; | |
| 258 | + | if (result.encrypted && !servingEncrypted) { | |
| 192 | 259 | if (!cookie.password.value) { | |
| 193 | 260 | set.status = StatusMap.Unauthorized; | |
| 194 | 261 | 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({ | |||
|---|---|---|---|
| 200 | 267 | return "Incorrect password"; | |
| 201 | 268 | } | |
| 202 | 269 | } | |
| 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; | |
| 203 | 294 | set.headers.encrypted = result.encrypted ? "true" : "false"; | |
| 204 | 295 | set.headers.filetype = result.filetype; | |
| 205 | - | set.headers.filename = result.filename; | |
| 296 | + | set.headers.filename = safeName; | |
| 206 | 297 | set.headers["content-disposition"] = | |
| 207 | - | `inline; filename=${result.filename}`; | |
| 298 | + | `${disposition}; filename*=UTF-8''${safeName}`; | |
| 208 | 299 | return result.content; | |
| 209 | 300 | }, | |
| 210 | 301 | { | |
| 211 | - | params: t.Object({ uuid: t.String() }), | |
| 302 | + | params: t.Object({ | |
| 303 | + | uuid: t.String({ format: "regex", pattern: UUID_PATTERN }), | |
| 304 | + | }), | |
| 212 | 305 | cookie: t.Object({ password: t.Optional(t.String()) }), | |
| 213 | 306 | query: t.Object({ ignore_password: t.Optional(t.String()) }), | |
| 214 | 307 | }, | |