security
This commit is contained in:
@@ -241,6 +241,8 @@ Files in `storage/` are untouched by updates and keep working.
|
||||
|
||||
- The app binds to loopback; only the proxy is exposed.
|
||||
- Download/deletion require the unguessable key; everything else is `404`.
|
||||
- Optional per-file passwords are stored only as scrypt hashes; entry mints a
|
||||
short-lived HMAC-signed cookie (server secret in `storage/.secret`).
|
||||
- Rate limiting protects the keyed endpoints; `TRUST_PROXY=1` must only be set
|
||||
when a proxy really sets `X-Forwarded-For`/`CF-Connecting-IP`.
|
||||
- Files never expire. Deletion requires possession of the key
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# File Drop
|
||||
# File Drop (VYBECODED)
|
||||
|
||||
A minimal, self-hosted private file hosting service built with **Bun** and
|
||||
**TypeScript**. Upload a file, get a secret, unguessable download link. Files are
|
||||
@@ -10,6 +10,7 @@ database, no object storage, no queue, and no automatic cleanup.
|
||||
## Features
|
||||
|
||||
- Drag & drop / file-picker uploads with progress, straight from the browser.
|
||||
- **Optional per-file password**: protect a download link with a password.
|
||||
- Permanent storage on the local filesystem; files survive restarts.
|
||||
- A cryptographically secure, unguessable download key for every file.
|
||||
- Streaming uploads and downloads — no full buffering in RAM, large files work.
|
||||
@@ -68,7 +69,7 @@ Then open http://localhost:3000.
|
||||
3. The server stores it permanently and returns a URL like
|
||||
`https://files.example.com/d/8fJ...random-secret...`.
|
||||
4. Copy the URL. Anyone possessing it can download the file; nobody without the
|
||||
secret can.
|
||||
secret can. If a password was set, the downloader must also enter it.
|
||||
5. The file stays available indefinitely. It is removed only by explicit
|
||||
deletion.
|
||||
|
||||
@@ -76,6 +77,7 @@ Then open http://localhost:3000.
|
||||
|
||||
```
|
||||
storage/
|
||||
├── .secret # server signing secret (0600), auto-generated
|
||||
├── files/
|
||||
│ ├── <file-id> # the raw uploaded bytes
|
||||
│ └── <file-id>
|
||||
@@ -98,7 +100,10 @@ Example metadata record (`storage/metadata/<file-id>.json`):
|
||||
```
|
||||
|
||||
Only a SHA-256 **hash** of the download key is stored — the plaintext key exists
|
||||
only inside the URL shown to the uploader.
|
||||
only inside the URL shown to the uploader. If a download password is set, only
|
||||
its scrypt hash is stored (see [password protection](#password-protection)).
|
||||
The server signing secret for download cookies lives in `storage/.secret`
|
||||
(`0600`), created on first start and reused across restarts.
|
||||
|
||||
Uploads are first streamed to a `<file-id>.part` temp file and atomically renamed
|
||||
into place on completion. At startup the server sweeps for orphans (a file
|
||||
@@ -110,10 +115,12 @@ touched.
|
||||
|
||||
### `POST /api/upload`
|
||||
|
||||
Multipart/form-data with a `file` field (this is what the web UI uses).
|
||||
Multipart/form-data with a `file` field (this is what the web UI uses). An
|
||||
optional `password` field protects the download with a password.
|
||||
|
||||
```bash
|
||||
curl -F "[email protected]" http://localhost:3000/api/upload
|
||||
curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/upload
|
||||
```
|
||||
|
||||
```json
|
||||
@@ -122,19 +129,39 @@ curl -F "[email protected]" http://localhost:3000/api/upload
|
||||
"key": "YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ",
|
||||
"url": "/d/YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ",
|
||||
"originalName": "example.zip",
|
||||
"size": 123456
|
||||
"size": 123456,
|
||||
"protected": false
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /d/:key` (also `HEAD`)
|
||||
|
||||
Streams the file. Requires the full secret key; any wrong or non-existent key
|
||||
returns a generic `404`.
|
||||
returns a generic `404`. If the file has a password, the server first shows a
|
||||
password prompt (see below).
|
||||
|
||||
```bash
|
||||
curl -O http://localhost:3000/d/YOWJyh...7OQ
|
||||
```
|
||||
|
||||
### Password protection
|
||||
|
||||
For files uploaded with a `password` field:
|
||||
|
||||
1. `GET /d/:key` returns a small HTML password form.
|
||||
2. The browser submits it to `POST /api/auth/:key` with `password=...`.
|
||||
3. On success the server sets a short-lived (30 min), HttpOnly, SameSite=Lax,
|
||||
signed cookie and redirects back to `/d/:key`, which then streams the file.
|
||||
|
||||
```bash
|
||||
curl -c cookies.txt -X POST -d "password=hunter2" http://localhost:3000/api/auth/:key
|
||||
curl -b cookies.txt -O http://localhost:3000/d/:key
|
||||
```
|
||||
|
||||
The cookie is signed with a server secret (`storage/.secret`) so it cannot be
|
||||
forged, and it survives server restarts. Without it, a protected file is never
|
||||
downloadable — not even by the uploader.
|
||||
|
||||
### `DELETE /api/files/:id`
|
||||
|
||||
Permanently deletes the file and its metadata. The correct download key must be
|
||||
@@ -164,6 +191,9 @@ paths or stack traces.
|
||||
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.** An optional per-file password 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
|
||||
only and is sanitized (basename, control/null bytes stripped) before being
|
||||
@@ -314,4 +344,4 @@ src/
|
||||
```
|
||||
|
||||
Intentionally small: no framework, no DI, no ORM, no repositories. Bun +
|
||||
TypeScript + filesystem + a simple frontend + secret URLs.
|
||||
TypeScript + filesystem + a simple frontend + secret URLs.
|
||||
|
||||
@@ -73,6 +73,18 @@ check "delete with key succeeds" \
|
||||
check "file gone after delete" test \
|
||||
"$(curl -sS -o /dev/null -w '%{http_code}' "$BASE$URL")" = "404"
|
||||
|
||||
echo
|
||||
echo "-- password protection --"
|
||||
check "upload with password" curl -sS -f -F "password=hunter2" -F "file=@$TMP/plain.txt" "$BASE/api/upload" > "$TMP/upP.json"
|
||||
KP="$(get "$TMP/upP.json" key)"
|
||||
check "protected file serves a password form" test \
|
||||
"$(curl -sS -o /dev/null -w '%{content_type}' "$BASE/d/$KP")" = "text/html; charset=utf-8"
|
||||
check "wrong password is rejected" test \
|
||||
"$(curl -sS -o /dev/null -w '%{http_code}' -X POST -d 'password=nope' "$BASE/api/auth/$KP")" = "302"
|
||||
check "correct password returns redirect" curl -sS -c "$TMP/cj.txt" -X POST -d "password=hunter2" "$BASE/api/auth/$KP" >/dev/null
|
||||
curl -sS -b "$TMP/cj.txt" -o "$TMP/downP.txt" "$BASE/d/$KP"
|
||||
check "download with cookie matches" cmp "$TMP/plain.txt" "$TMP/downP.txt"
|
||||
|
||||
echo
|
||||
echo "passed: $pass, failed: $fail"
|
||||
[ "$fail" -eq 0 ] || exit 1
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { makeAuthToken, parseAuthToken } from "./security";
|
||||
|
||||
export const AUTH_COOKIE = "fh_auth";
|
||||
export const AUTH_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
export function parseCookie(header: string | null, name: string): string | null {
|
||||
if (!header) return null;
|
||||
for (const part of header.split(";")) {
|
||||
const idx = part.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
if (part.slice(0, idx).trim() === name) return part.slice(idx + 1).trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasValidAuth(req: Request, secret: string, key: string): boolean {
|
||||
const token = parseCookie(req.headers.get("cookie"), AUTH_COOKIE);
|
||||
if (!token) return false;
|
||||
const parsed = parseAuthToken(token, secret);
|
||||
return parsed !== null && parsed.key === key;
|
||||
}
|
||||
|
||||
export function authCookieValue(key: string, secret: string): string {
|
||||
const token = makeAuthToken(key, secret, AUTH_TTL_MS);
|
||||
return `${AUTH_COOKIE}=${token}`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function renderPasswordForm(
|
||||
key: string,
|
||||
filename: string,
|
||||
error: boolean,
|
||||
): Response {
|
||||
const message = error
|
||||
? '<p class="err">Incorrect password. Please try again.</p>'
|
||||
: "";
|
||||
const html = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title>Protected file</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0f1115; color: #e6e8ee; min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||
}
|
||||
.card {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
border: 1px solid rgba(224,82,82,.3); border-radius: 8px; padding: 9px 12px; }
|
||||
input[type="password"] {
|
||||
width: 100%; background: #0f1115; border: 1px solid #2a2f3a; color: #e6e8ee;
|
||||
border-radius: 8px; padding: 11px 12px; font-size: 14px; margin-bottom: 12px;
|
||||
}
|
||||
button { width: 100%; font: inherit; cursor: pointer; border: none; border-radius: 8px;
|
||||
padding: 11px 14px; font-size: 14px; background: #4f8cff; color: #fff; }
|
||||
button:hover { background: #3a74e0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<h1>Protected file</h1>
|
||||
<p>Enter the password to download <strong>${escapeHtml(filename)}</strong>.</p>
|
||||
${message}
|
||||
<form method="post" action="/api/auth/${key}">
|
||||
<input type="password" name="password" placeholder="Password" autofocus autocomplete="off" />
|
||||
<button type="submit">Download</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"cache-control": "private, no-store",
|
||||
"x-content-type-options": "nosniff",
|
||||
"referrer-policy": "no-referrer",
|
||||
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
||||
},
|
||||
});
|
||||
}
|
||||
+11
-14
@@ -2,8 +2,9 @@ const CR = 0x0d;
|
||||
const LF = 0x0a;
|
||||
const MAX_HEADER_LINE_BYTES = 64 * 1024;
|
||||
|
||||
export interface MultipartFilePart {
|
||||
filename: string;
|
||||
export interface MultipartPart {
|
||||
name: string;
|
||||
filename: string | null;
|
||||
contentType: string;
|
||||
chunks: AsyncIterable<Uint8Array>;
|
||||
}
|
||||
@@ -36,7 +37,7 @@ async function* streamChunks(stream: ReadableStream<Uint8Array>): AsyncGenerator
|
||||
export async function* multipartParts(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
boundary: string,
|
||||
): AsyncGenerator<MultipartFilePart> {
|
||||
): AsyncGenerator<MultipartPart> {
|
||||
const DELIMITER = Buffer.from(`--${boundary}`);
|
||||
const CRLF_DELIMITER = Buffer.concat([Buffer.from("\r\n"), DELIMITER]);
|
||||
const FINAL_MARKER = `--${boundary}--`;
|
||||
@@ -128,18 +129,14 @@ export async function* multipartParts(
|
||||
const quoted = /filename\s*=\s*"([^"]*)"/i.exec(disposition);
|
||||
filename = quoted?.[1];
|
||||
}
|
||||
const name = /name\s*=\s*"([^"]*)"/i.exec(disposition)?.[1] ?? "";
|
||||
|
||||
if (!filename) {
|
||||
for await (const _ of contentChunks()) {
|
||||
void _;
|
||||
}
|
||||
} else {
|
||||
yield {
|
||||
filename,
|
||||
contentType: headers.get("content-type") ?? "application/octet-stream",
|
||||
chunks: contentChunks(),
|
||||
};
|
||||
}
|
||||
yield {
|
||||
name,
|
||||
filename: filename ?? null,
|
||||
contentType: headers.get("content-type") ?? "application/octet-stream",
|
||||
chunks: contentChunks(),
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const marker = await readLine();
|
||||
|
||||
+14
-1
@@ -6,6 +6,7 @@ const STORAGE_KEY = "filehost.uploads";
|
||||
|
||||
const dropzone = $("dropzone");
|
||||
const fileInput = $("fileInput");
|
||||
const pwInput = $("pwInput");
|
||||
const progress = $("progress");
|
||||
const progressBar = $("progressBar");
|
||||
const progressPct = $("progressPct");
|
||||
@@ -14,6 +15,7 @@ const result = $("result");
|
||||
const resultUrl = $("resultUrl");
|
||||
const resultName = $("resultName");
|
||||
const resultSize = $("resultSize");
|
||||
const resultLock = $("resultLock");
|
||||
const copyBtn = $("copyBtn");
|
||||
const deleteBtn = $("deleteBtn");
|
||||
const againBtn = $("againBtn");
|
||||
@@ -57,10 +59,12 @@ function reset() {
|
||||
uploading = false;
|
||||
current = null;
|
||||
result.classList.add("hidden");
|
||||
resultLock.classList.add("hidden");
|
||||
progress.classList.add("hidden");
|
||||
progressBar.style.width = "0%";
|
||||
progressPct.textContent = "0%";
|
||||
fileInput.value = "";
|
||||
pwInput.value = "";
|
||||
clearNotice();
|
||||
}
|
||||
|
||||
@@ -160,6 +164,13 @@ function renderList() {
|
||||
name.textContent = item.originalName;
|
||||
name.title = item.originalName;
|
||||
|
||||
if (item.protected) {
|
||||
const lock = document.createElement("span");
|
||||
lock.className = "lock";
|
||||
lock.textContent = "Password protected";
|
||||
name.appendChild(lock);
|
||||
}
|
||||
|
||||
const sub = document.createElement("span");
|
||||
sub.className = "file-item-sub";
|
||||
let subText = formatBytes(item.size || 0);
|
||||
@@ -182,7 +193,6 @@ function renderList() {
|
||||
dl.className = "btn-link";
|
||||
dl.href = downloadUrl(item);
|
||||
dl.textContent = "Download";
|
||||
dl.setAttribute("download", "");
|
||||
dl.title = "Open download link";
|
||||
|
||||
const copy = document.createElement("button");
|
||||
@@ -243,6 +253,7 @@ function showResult(data) {
|
||||
resultName.textContent = data.originalName;
|
||||
resultSize.textContent = formatBytes(data.size);
|
||||
resultUrl.value = downloadUrl(data);
|
||||
resultLock.classList.toggle("hidden", !data.protected);
|
||||
current = data;
|
||||
addUpload({
|
||||
id: data.id,
|
||||
@@ -250,6 +261,7 @@ function showResult(data) {
|
||||
url: data.url,
|
||||
originalName: data.originalName,
|
||||
size: data.size,
|
||||
protected: !!data.protected,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -264,6 +276,7 @@ function startUpload(file) {
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
const form = new FormData();
|
||||
if (pwInput.value) form.append("password", pwInput.value);
|
||||
form.append("file", file);
|
||||
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
|
||||
@@ -19,6 +19,16 @@
|
||||
<p class="dz-sub">or click to choose</p>
|
||||
</div>
|
||||
|
||||
<div class="pw-row">
|
||||
<input
|
||||
type="password"
|
||||
id="pwInput"
|
||||
placeholder="Optional password (protects the download)"
|
||||
autocomplete="off"
|
||||
aria-label="Optional download password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="progress" class="progress hidden">
|
||||
<div class="progress-label">
|
||||
<span id="progressName"></span>
|
||||
@@ -33,6 +43,7 @@
|
||||
<div class="result-meta">
|
||||
<strong id="resultName"></strong>
|
||||
<span id="resultSize"></span>
|
||||
<span id="resultLock" class="lock hidden">Password protected</span>
|
||||
</div>
|
||||
<div class="url-row">
|
||||
<input id="resultUrl" type="text" readonly aria-label="Download URL" />
|
||||
|
||||
@@ -75,6 +75,38 @@ h1 {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pw-row {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pw-row input {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pw-row input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.lock {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
background: rgba(79, 140, 255, 0.12);
|
||||
border: 1px solid rgba(79, 140, 255, 0.3);
|
||||
border-radius: 99px;
|
||||
padding: 2px 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.progress {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { AUTH_COOKIE, AUTH_TTL_MS } from "../auth";
|
||||
import { notFound } from "../http";
|
||||
import { log } from "../log";
|
||||
import { fileIdFromKey, KEY_PATTERN, makeAuthToken, passwordMatches } from "../security";
|
||||
import type { Storage } from "../storage";
|
||||
|
||||
export async function handleAuth(
|
||||
req: Request,
|
||||
storage: Storage,
|
||||
rawKey: string,
|
||||
secret: string,
|
||||
secure: boolean,
|
||||
): Promise<Response> {
|
||||
if (!KEY_PATTERN.test(rawKey)) return notFound();
|
||||
const id = fileIdFromKey(rawKey);
|
||||
if (!id) return notFound();
|
||||
|
||||
const meta = await storage.readMetadata(id);
|
||||
if (!meta) return notFound();
|
||||
|
||||
if (!meta.passwordHash) {
|
||||
return new Response(null, { status: 302, headers: { location: `/d/${rawKey}` } });
|
||||
}
|
||||
|
||||
const password = await req.formData().then(
|
||||
(form) => {
|
||||
const value = form.get("password");
|
||||
return typeof value === "string" ? value : "";
|
||||
},
|
||||
() => "",
|
||||
);
|
||||
|
||||
if (!passwordMatches(meta.passwordHash, password)) {
|
||||
log("warn", `auth failed id=${id}`);
|
||||
return new Response(null, { status: 302, headers: { location: `/d/${rawKey}?e=1` } });
|
||||
}
|
||||
|
||||
const token = makeAuthToken(rawKey, secret, AUTH_TTL_MS);
|
||||
const cookie = `${AUTH_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax${
|
||||
secure ? "; Secure" : ""
|
||||
}; Max-Age=${Math.floor(AUTH_TTL_MS / 1000)}`;
|
||||
|
||||
log("info", `auth ok id=${id}`);
|
||||
return new Response(null, { status: 302, headers: { location: `/d/${rawKey}`, "set-cookie": cookie } });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { lstat } from "node:fs/promises";
|
||||
import { hasValidAuth, renderPasswordForm } from "../auth";
|
||||
import { notFound } from "../http";
|
||||
import { log } from "../log";
|
||||
import { contentDisposition, fileIdFromKey, KEY_PATTERN, secretMatches } from "../security";
|
||||
@@ -8,6 +9,7 @@ export async function handleDownload(
|
||||
req: Request,
|
||||
storage: Storage,
|
||||
rawKey: string,
|
||||
secret: string,
|
||||
): Promise<Response> {
|
||||
if (!KEY_PATTERN.test(rawKey)) return notFound();
|
||||
const id = fileIdFromKey(rawKey);
|
||||
@@ -17,6 +19,11 @@ export async function handleDownload(
|
||||
if (!meta) return notFound();
|
||||
if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound();
|
||||
|
||||
if (meta.passwordHash && !hasValidAuth(req, secret, rawKey)) {
|
||||
const url = new URL(req.url);
|
||||
return renderPasswordForm(rawKey, meta.originalName, url.searchParams.has("e"));
|
||||
}
|
||||
|
||||
const filePath = storage.filePath(id);
|
||||
let fileStat;
|
||||
try {
|
||||
|
||||
+45
-13
@@ -1,9 +1,27 @@
|
||||
import { json, jsonError } from "../http";
|
||||
import { log } from "../log";
|
||||
import { getBoundary, MultipartError, multipartParts } from "../multipart";
|
||||
import { hashSecret, newDownloadKey, newFileId, sanitizeMime, sanitizeOriginalName } from "../security";
|
||||
import { getBoundary, MultipartError, multipartParts, type MultipartPart } from "../multipart";
|
||||
import {
|
||||
hashPassword,
|
||||
hashSecret,
|
||||
newDownloadKey,
|
||||
newFileId,
|
||||
sanitizeMime,
|
||||
sanitizeOriginalName,
|
||||
} from "../security";
|
||||
import { UploadTooLargeError, type Storage } from "../storage";
|
||||
|
||||
const MAX_PASSWORD_LENGTH = 256;
|
||||
|
||||
async function readField(part: MultipartPart, limit: number): Promise<string> {
|
||||
let value = "";
|
||||
for await (const chunk of part.chunks) {
|
||||
value += Buffer.from(chunk).toString("utf8");
|
||||
if (value.length > limit) throw new MultipartError("field too long");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function handleUpload(
|
||||
req: Request,
|
||||
storage: Storage,
|
||||
@@ -21,34 +39,48 @@ export async function handleUpload(
|
||||
let mime = "application/octet-stream";
|
||||
let size = 0;
|
||||
let hasFile = false;
|
||||
let password = "";
|
||||
|
||||
for await (const part of multipartParts(req.body, boundary)) {
|
||||
if (!hasFile) {
|
||||
hasFile = true;
|
||||
filename = part.filename;
|
||||
mime = sanitizeMime(part.contentType);
|
||||
size = await storage.saveFile(id, part.chunks, maxFileSize);
|
||||
break;
|
||||
}
|
||||
for await (const _ of part.chunks) {
|
||||
void _;
|
||||
if (part.filename != null) {
|
||||
if (!hasFile) {
|
||||
hasFile = true;
|
||||
filename = part.filename;
|
||||
mime = sanitizeMime(part.contentType);
|
||||
size = await storage.saveFile(id, part.chunks, maxFileSize);
|
||||
} else {
|
||||
for await (const _ of part.chunks) {
|
||||
void _;
|
||||
}
|
||||
}
|
||||
} else if (part.name === "password" && password === "") {
|
||||
password = await readField(part, MAX_PASSWORD_LENGTH);
|
||||
} else {
|
||||
for await (const _ of part.chunks) {
|
||||
void _;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasFile) return jsonError(400, "no file part in the upload");
|
||||
|
||||
const originalName = sanitizeOriginalName(filename);
|
||||
const protected_ = password.trim().length > 0;
|
||||
await storage.writeMetadata({
|
||||
id,
|
||||
originalName,
|
||||
size,
|
||||
mimeType: mime,
|
||||
downloadKeyHash: hashSecret(key),
|
||||
passwordHash: protected_ ? hashPassword(password) : undefined,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`);
|
||||
return json({ id, key, url: `/d/${key}`, originalName, size });
|
||||
log(
|
||||
"info",
|
||||
`upload ok id=${id} size=${size} name=${JSON.stringify(originalName)} protected=${protected_}`,
|
||||
);
|
||||
return json({ id, key, url: `/d/${key}`, originalName, size, protected: protected_ });
|
||||
} catch (err) {
|
||||
await storage.delete(id);
|
||||
await storage.deletePartial(id);
|
||||
|
||||
+49
-1
@@ -1,4 +1,4 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
||||
|
||||
export const ID_BYTES = 16;
|
||||
export const KEY_BYTES = 32;
|
||||
@@ -54,3 +54,51 @@ export function contentDisposition(name: string): string {
|
||||
);
|
||||
return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(password, salt, 64).toString("hex");
|
||||
return `scrypt:${salt}:${hash}`;
|
||||
}
|
||||
|
||||
export function passwordMatches(stored: string, password: string): boolean {
|
||||
const parts = stored.split(":");
|
||||
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
|
||||
const salt = parts[1]!;
|
||||
const expected = Buffer.from(parts[2]!, "hex");
|
||||
const computed = scryptSync(password, salt, 64);
|
||||
if (expected.length !== computed.length) return false;
|
||||
return timingSafeEqual(expected, computed);
|
||||
}
|
||||
|
||||
export function safeStringEqual(a: string, b: string): boolean {
|
||||
const ab = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
if (ab.length !== bb.length) return false;
|
||||
return timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
function hmacSign(payload: string, secret: string): string {
|
||||
return createHmac("sha256", secret).update(payload).digest("base64url");
|
||||
}
|
||||
|
||||
export function makeAuthToken(key: string, secret: string, ttlMs: number): string {
|
||||
const expires = Date.now() + ttlMs;
|
||||
const payload = `${key}.${expires}`;
|
||||
return `${payload}.${hmacSign(payload, secret)}`;
|
||||
}
|
||||
|
||||
export function parseAuthToken(token: string, secret: string): { key: string } | null {
|
||||
const lastDot = token.lastIndexOf(".");
|
||||
if (lastDot <= 0) return null;
|
||||
const payload = token.slice(0, lastDot);
|
||||
const signature = token.slice(lastDot + 1);
|
||||
if (!safeStringEqual(signature, hmacSign(payload, secret))) return null;
|
||||
const dot = payload.lastIndexOf(".");
|
||||
if (dot <= 0) return null;
|
||||
const key = payload.slice(0, dot);
|
||||
const expires = Number(payload.slice(dot + 1));
|
||||
if (!Number.isFinite(expires) || expires <= Date.now()) return null;
|
||||
if (!KEY_PATTERN.test(key)) return null;
|
||||
return { key };
|
||||
}
|
||||
|
||||
+15
-1
@@ -3,6 +3,7 @@ import { loadConfig } from "./config";
|
||||
import { json, jsonError, notFound, securityHeaders } from "./http";
|
||||
import { log } from "./log";
|
||||
import { RateLimiter } from "./ratelimit";
|
||||
import { handleAuth } from "./routes/auth";
|
||||
import { handleDelete } from "./routes/delete";
|
||||
import { handleDownload } from "./routes/download";
|
||||
import { handleUpload } from "./routes/upload";
|
||||
@@ -12,6 +13,7 @@ const cfg = loadConfig();
|
||||
|
||||
const storage = new Storage(cfg.storageDir);
|
||||
await storage.init();
|
||||
const secret = await storage.loadSecret();
|
||||
const sweep = await storage.sweep();
|
||||
if (sweep.removedFiles > 0 || sweep.removedMetadata > 0) {
|
||||
log(
|
||||
@@ -77,6 +79,11 @@ function serveStatic(path: string): Response {
|
||||
});
|
||||
}
|
||||
|
||||
function isSecure(req: Request, url: URL): boolean {
|
||||
if (cfg.trustProxy) return req.headers.get("x-forwarded-proto") === "https";
|
||||
return url.protocol === "https:";
|
||||
}
|
||||
|
||||
async function route(req: Request, srv: Server<undefined>): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const path = url.pathname;
|
||||
@@ -92,11 +99,18 @@ async function route(req: Request, srv: Server<undefined>): Promise<Response> {
|
||||
return handleUpload(req, storage, cfg.maxFileSize);
|
||||
}
|
||||
|
||||
if (path.startsWith("/api/auth/") && method === "POST") {
|
||||
if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) {
|
||||
return jsonError(429, "too many requests");
|
||||
}
|
||||
return handleAuth(req, storage, path.slice("/api/auth/".length), secret, isSecure(req, url));
|
||||
}
|
||||
|
||||
if (path.startsWith("/d/") && (method === "GET" || method === "HEAD")) {
|
||||
if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) {
|
||||
return jsonError(429, "too many requests");
|
||||
}
|
||||
return handleDownload(req, storage, path.slice(3));
|
||||
return handleDownload(req, storage, path.slice(3), secret);
|
||||
}
|
||||
|
||||
if (path.startsWith("/api/files/") && method === "DELETE") {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { ID_PATTERN } from "./security";
|
||||
|
||||
@@ -8,6 +9,7 @@ export interface FileMeta {
|
||||
size: number;
|
||||
mimeType: string;
|
||||
downloadKeyHash: string;
|
||||
passwordHash?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -35,6 +37,19 @@ export class Storage {
|
||||
await mkdir(this.metadataDir, { recursive: true });
|
||||
}
|
||||
|
||||
async loadSecret(): Promise<string> {
|
||||
const path = join(this.root, ".secret");
|
||||
try {
|
||||
const existing = (await readFile(path, "utf8")).trim();
|
||||
if (existing) return existing;
|
||||
} catch {
|
||||
/* missing or unreadable: create below */
|
||||
}
|
||||
const secret = randomBytes(32).toString("base64url");
|
||||
await writeFile(path, secret, { encoding: "utf8", mode: 0o600 });
|
||||
return secret;
|
||||
}
|
||||
|
||||
filePath(id: string): string {
|
||||
if (!ID_PATTERN.test(id)) throw new Error("invalid file id");
|
||||
return join(this.filesDir, id);
|
||||
|
||||
Reference in New Issue
Block a user