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
{
"id": "YOWJyhDXxhe3-L8MpEFBbA",
"id": "nkj2p0ulkijp",
"originalName": "example.zip",
"size": 123456,
"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);
the short length keeps share URLs tidy, and an optional password can add a
second factor of protection.
`id` and `key` are the same 12-character lowercase alphanumeric token (~62 bits
of entropy); the short length keeps share URLs tidy, and an optional password
can add a second factor of protection.
### `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.
```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`
@@ -194,18 +194,18 @@ paths or stack traces.
## Security model
- **Secrets.** Each file gets a fresh 72-bit (9 random bytes) base64url key via
`crypto.randomBytes`, used as both the share id and the download key. Keys are
encoded base64url; guessing one is infeasible at scale, and an optional
password can be an added second factor. No timestamps, incrementing ids,
`Math.random()`, or filenames are ever used as secrets.
- **Secrets.** Each file gets a fresh 12-character lowercase alphanumeric key
(`crypto.randomBytes` mapped onto `a-z0-9`, ~62 bits of entropy), used as both
the share id and the download key. Guessing one is infeasible at scale, and an
optional password can be an added second factor. No timestamps, incrementing
ids, `Math.random()`, or filenames are ever used as secrets.
- **At-rest.** Only a SHA-256 hash of the key is persisted. Comparisons use
constant-time `timingSafeEqual`.
- **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
cookie, so access is granted by the server and cannot be forged.
- **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
stored or emitted in `Content-Disposition`. Path traversal, `../`, encoded
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;
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; }
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);
@@ -75,6 +76,19 @@ export function renderPasswordForm(
</head>
<body>
<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>
<p>Enter the password to download <strong>${escapeHtml(filename)}</strong>.</p>
${message}
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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>
<link rel="stylesheet" href="/style.css" />
</head>
@@ -12,7 +12,7 @@
<div class="app">
<header class="topbar">
<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">
<h1>File Drop</h1>
<span class="sub">Private file hosting</span>
+1 -7
View File
@@ -59,13 +59,7 @@ button {
.logo {
width: 40px;
height: 40px;
border-radius: var(--radius);
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #fff;
font-weight: 700;
display: grid;
place-items: center;
letter-spacing: 0.5px;
display: block;
}
.brand-text h1 {
+15 -6
View File
@@ -1,18 +1,27 @@
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_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.
export const ID_PATTERN = KEY_PATTERN;
export function randomToken(byteCount: number): string {
return randomBytes(byteCount).toString("base64url");
}
const KEY_CHARSET = "abcdefghijklmnopqrstuvwxyz0123456789";
const CHARSET_SIZE = KEY_CHARSET.length;
const MAX_BYTE = Math.floor(256 / CHARSET_SIZE) * CHARSET_SIZE;
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 {
+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" },
"/style.css": { file: "./public/style.css", type: "text/css; 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 }>();