This commit is contained in:
Anaïs Saraiva
2026-08-15 10:46:10 +02:00
parent 6e76189e46
commit 4867cf57b6
7 changed files with 83 additions and 26 deletions
+11 -11
View File
@@ -93,7 +93,7 @@ Example metadata record (`storage/metadata/<file-id>.json`):
```json ```json
{ {
"id": "YOWJyhDXxhe3-L8MpEFBbA", "id": "nkj2p0ulkijp",
"originalName": "example.zip", "originalName": "example.zip",
"size": 123456, "size": 123456,
"mimeType": "application/zip", "mimeType": "application/zip",
@@ -137,9 +137,9 @@ curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/uploa
} }
``` ```
`id` and `key` are the same 12-character base64url token (~72 bits of entropy); `id` and `key` are the same 12-character lowercase alphanumeric token (~62 bits
the short length keeps share URLs tidy, and an optional password can add a of entropy); the short length keeps share URLs tidy, and an optional password
second factor of protection. can add a second factor of protection.
### `GET /:key` (also `HEAD`) ### `GET /:key` (also `HEAD`)
@@ -177,7 +177,7 @@ Permanently deletes the file and its metadata. The correct download key must be
sent in the `X-Delete-Key` header — proving you hold the secret. sent in the `X-Delete-Key` header — proving you hold the secret.
```bash ```bash
curl -X DELETE -H "X-Delete-Key: YOWJyh...7OQ" http://localhost:3000/api/files/YOWJyhDXxhe3-L8MpEFBbA curl -X DELETE -H "X-Delete-Key: nkj2p0ulkijp" http://localhost:3000/api/files/nkj2p0ulkijp
``` ```
### `GET /api/health` ### `GET /api/health`
@@ -194,18 +194,18 @@ paths or stack traces.
## Security model ## Security model
- **Secrets.** Each file gets a fresh 72-bit (9 random bytes) base64url key via - **Secrets.** Each file gets a fresh 12-character lowercase alphanumeric key
`crypto.randomBytes`, used as both the share id and the download key. Keys are (`crypto.randomBytes` mapped onto `a-z0-9`, ~62 bits of entropy), used as both
encoded base64url; guessing one is infeasible at scale, and an optional the share id and the download key. Guessing one is infeasible at scale, and an
password can be an added second factor. No timestamps, incrementing ids, optional password can be an added second factor. No timestamps, incrementing
`Math.random()`, or filenames are ever used as secrets. ids, `Math.random()`, or filenames are ever used as secrets.
- **At-rest.** Only a SHA-256 hash of the key is persisted. Comparisons use - **At-rest.** Only a SHA-256 hash of the key is persisted. Comparisons use
constant-time `timingSafeEqual`. constant-time `timingSafeEqual`.
- **Passwords.** A file's password (if any) is stored only as an **scrypt** hash - **Passwords.** A file's password (if any) is stored only as an **scrypt** hash
(salted), never in plaintext. Successful entry mints a short-lived HMAC-signed (salted), never in plaintext. Successful entry mints a short-lived HMAC-signed
cookie, so access is granted by the server and cannot be forged. cookie, so access is granted by the server and cannot be forged.
- **Path safety.** Filesystem paths are built only from server-generated, - **Path safety.** Filesystem paths are built only from server-generated,
regex-validated ids (`^[A-Za-z0-9_-]{22}$`). The original filename is metadata regex-validated ids (`^[a-z0-9]{12}$`). The original filename is metadata
only and is sanitized (basename, control/null bytes stripped) before being only and is sanitized (basename, control/null bytes stripped) before being
stored or emitted in `Content-Disposition`. Path traversal, `../`, encoded stored or emitted in `Content-Disposition`. Path traversal, `../`, encoded
traversal, null bytes, and symlink injection via user input are not possible. traversal, null bytes, and symlink injection via user input are not possible.
+39
View File
@@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<defs>
<linearGradient id="uploadGradient" x1="200" y1="336" x2="312" y2="196" gradientUnits="userSpaceOnUse">
<stop stop-color="#4F46E5"/>
<stop offset="1" stop-color="#3B82F6"/>
</linearGradient>
<filter id="tileShadow" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="10" stdDeviation="16" flood-opacity=".14"/>
</filter>
</defs>
<!-- Tuile blanche façon macOS, parfaitement centrée sur le canvas -->
<rect x="48" y="48" width="416" height="416" rx="94"
fill="#FFFFFF" stroke="#ECEFF5" stroke-width="2"
filter="url(#tileShadow)"/>
<!-- Document (bbox 168–344 horiz. / 144–368 vert. -> centré en 256,256) -->
<path
d="M184 144H306l38 38V352a16 16 0 0 1-16 16H184a16 16 0 0 1-16-16V160a16 16 0 0 1 16-16Z"
fill="#fff"
stroke="#E7ECF5"
stroke-width="4"
/>
<!-- Coin plié -->
<path d="M306 144v31c0 6 5 11 11 11h27" fill="#E8EEF9"/>
<path d="M306 144l38 38h-27c-6 0-11-5-11-11V144Z" fill="#DDE6F5"/>
<!-- Flèche d'upload, centrée sur x=256 comme le document -->
<path
d="M256 300V196
M222 230l34-34 34 34
M200 318v14c0 9 7 16 16 16h80c9 0 16-7 16-16v-14"
stroke="url(#uploadGradient)"
stroke-width="30"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+14
View File
@@ -60,6 +60,7 @@ export function renderPasswordForm(
width: 100%; max-width: 380px; background: #171a21; border: 1px solid #2a2f3a; width: 100%; max-width: 380px; background: #171a21; border: 1px solid #2a2f3a;
border-radius: 14px; padding: 26px; box-shadow: 0 10px 40px rgba(0,0,0,.35); border-radius: 14px; padding: 26px; box-shadow: 0 10px 40px rgba(0,0,0,.35);
} }
.brand-logo { display: block; width: 64px; height: 64px; margin: 0 auto 12px; }
h1 { margin: 0 0 4px; font-size: 19px; } h1 { margin: 0 0 4px; font-size: 19px; }
p { margin: 6px 0 16px; color: #9aa3b2; font-size: 14px; word-break: break-all; } p { margin: 6px 0 16px; color: #9aa3b2; font-size: 14px; word-break: break-all; }
.err { margin: 0 0 14px; color: #e05252; background: rgba(224,82,82,.08); .err { margin: 0 0 14px; color: #e05252; background: rgba(224,82,82,.08);
@@ -75,6 +76,19 @@ export function renderPasswordForm(
</head> </head>
<body> <body>
<main class="card"> <main class="card">
<svg class="brand-logo" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<linearGradient id="uG" x1="200" y1="336" x2="312" y2="196" gradientUnits="userSpaceOnUse">
<stop stop-color="#4F46E5"/>
<stop offset="1" stop-color="#3B82F6"/>
</linearGradient>
</defs>
<rect x="48" y="48" width="416" height="416" rx="94" fill="#FFFFFF" stroke="#ECEFF5" stroke-width="2"/>
<path d="M184 144H306l38 38V352a16 16 0 0 1-16 16H184a16 16 0 0 1-16-16V160a16 16 0 0 1 16-16Z" fill="#fff" stroke="#E7ECF5" stroke-width="4"/>
<path d="M306 144v31c0 6 5 11 11 11h27" fill="#E8EEF9"/>
<path d="M306 144l38 38h-27c-6 0-11-5-11-11V144Z" fill="#DDE6F5"/>
<path d="M256 300V196 M222 230l34-34 34 34 M200 318v14c0 9 7 16 16 16h80c9 0 16-7 16-16v-14" stroke="url(#uG)" stroke-width="30" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<h1>Protected file</h1> <h1>Protected file</h1>
<p>Enter the password to download <strong>${escapeHtml(filename)}</strong>.</p> <p>Enter the password to download <strong>${escapeHtml(filename)}</strong>.</p>
${message} ${message}
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<link rel="icon" href="data:," /> <link rel="icon" type="image/svg+xml" href="/logo.svg" />
<title>File Drop</title> <title>File Drop</title>
<link rel="stylesheet" href="/style.css" /> <link rel="stylesheet" href="/style.css" />
</head> </head>
@@ -12,7 +12,7 @@
<div class="app"> <div class="app">
<header class="topbar"> <header class="topbar">
<div class="brand"> <div class="brand">
<div class="logo" aria-hidden="true">FD</div> <img class="logo" src="/logo.svg" width="40" height="40" alt="File Drop" />
<div class="brand-text"> <div class="brand-text">
<h1>File Drop</h1> <h1>File Drop</h1>
<span class="sub">Private file hosting</span> <span class="sub">Private file hosting</span>
+1 -7
View File
@@ -59,13 +59,7 @@ button {
.logo { .logo {
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: var(--radius); display: block;
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #fff;
font-weight: 700;
display: grid;
place-items: center;
letter-spacing: 0.5px;
} }
.brand-text h1 { .brand-text h1 {
+15 -6
View File
@@ -1,18 +1,27 @@
import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
export const KEY_BYTES = 9; // 9 bytes base64url -> 12 chars
export const KEY_LENGTH = 12; export const KEY_LENGTH = 12;
export const KEY_PATTERN = /^[A-Za-z0-9_-]{12}$/; // Keys are lowercase alphanumeric only (a-z0-9), 12 characters.
export const KEY_PATTERN = /^[a-z0-9]{12}$/;
// The id and the download key are the same 12-char token. // The id and the download key are the same 12-char token.
export const ID_PATTERN = KEY_PATTERN; export const ID_PATTERN = KEY_PATTERN;
export function randomToken(byteCount: number): string { const KEY_CHARSET = "abcdefghijklmnopqrstuvwxyz0123456789";
return randomBytes(byteCount).toString("base64url"); const CHARSET_SIZE = KEY_CHARSET.length;
} const MAX_BYTE = Math.floor(256 / CHARSET_SIZE) * CHARSET_SIZE;
export function newFileId(): string { export function newFileId(): string {
return randomToken(KEY_BYTES); let out = "";
const bytes = randomBytes(KEY_LENGTH * 2);
let i = 0;
while (out.length < KEY_LENGTH) {
const b = bytes[i++];
if (b === undefined) throw new Error("random source exhausted");
if (b >= MAX_BYTE) continue;
out += KEY_CHARSET[b % CHARSET_SIZE];
}
return out;
} }
export function hashSecret(value: string): string { export function hashSecret(value: string): string {
+1
View File
@@ -42,6 +42,7 @@ const STATIC_ROUTES: Record<string, { file: string; type: string }> = {
"/index.html": { file: "./public/index.html", type: "text/html; charset=utf-8" }, "/index.html": { file: "./public/index.html", type: "text/html; charset=utf-8" },
"/style.css": { file: "./public/style.css", type: "text/css; charset=utf-8" }, "/style.css": { file: "./public/style.css", type: "text/css; charset=utf-8" },
"/app.js": { file: "./public/app.js", type: "text/javascript; charset=utf-8" }, "/app.js": { file: "./public/app.js", type: "text/javascript; charset=utf-8" },
"/logo.svg": { file: "../assets/logo.svg", type: "image/svg+xml" },
}; };
const staticContent = new Map<string, { body: string; type: string }>(); const staticContent = new Map<string, { body: string; type: string }>();