better
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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 }>();
|
||||||
|
|||||||
Reference in New Issue
Block a user