Compare commits
2
Commits
12854202ca
...
ad469460a4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad469460a4 | ||
|
|
4848e7a3e7 |
@@ -126,22 +126,26 @@ curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/uploa
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "YOWJyhDXxhe3-L8MpEFBbA",
|
"id": "xmK5lLMRErlN",
|
||||||
"key": "YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ",
|
"key": "xmK5lLMRErlN",
|
||||||
"url": "/d/YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ",
|
"url": "/d/xmK5lLMRErlN",
|
||||||
"originalName": "example.zip",
|
"originalName": "example.zip",
|
||||||
"size": 123456,
|
"size": 123456,
|
||||||
"protected": true
|
"protected": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`id` and `key` are the same 12-character base64url token (~72 bits of entropy);
|
||||||
|
the short length keeps share URLs tidy, and the required password adds a second
|
||||||
|
factor of protection.
|
||||||
|
|
||||||
### `GET /d/:key` (also `HEAD`)
|
### `GET /d/:key` (also `HEAD`)
|
||||||
|
|
||||||
Every file is password-protected. Without a valid auth cookie the server shows a
|
Every file is password-protected. Without a valid auth cookie the server shows a
|
||||||
password prompt; a wrong or non-existent key returns a generic `404`.
|
password prompt; a wrong or non-existent key returns a generic `404`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -O http://localhost:3000/d/YOWJyh...7OQ # prompts for password first
|
curl -O http://localhost:3000/d/xmK5lLMRErlN # prompts for password first
|
||||||
```
|
```
|
||||||
|
|
||||||
### Password protection
|
### Password protection
|
||||||
@@ -185,10 +189,11 @@ paths or stack traces.
|
|||||||
|
|
||||||
## Security model
|
## Security model
|
||||||
|
|
||||||
- **Secrets.** Each file gets a fresh 128-bit id + 256-bit secret via
|
- **Secrets.** Each file gets a fresh 72-bit (9 random bytes) base64url key via
|
||||||
`crypto.randomBytes`. Keys are encoded base64url; brute forcing them is
|
`crypto.randomBytes`, used as both the share id and the download key. Keys are
|
||||||
computationally infeasible. No timestamps, incrementing ids, `Math.random()`,
|
encoded base64url; guessing one is infeasible at scale, and the required
|
||||||
or filenames are ever used as secrets.
|
password is 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
|
- **At-rest.** Only a SHA-256 hash of the key is persisted. Comparisons use
|
||||||
constant-time `timingSafeEqual`.
|
constant-time `timingSafeEqual`.
|
||||||
- **Passwords.** Every file has a password stored only as an **scrypt** hash
|
- **Passwords.** Every file has a password stored only as an **scrypt** hash
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ auth() { # auth <key> -> POST password, stores cookie
|
|||||||
}
|
}
|
||||||
|
|
||||||
fetch_auth() { # fetch_auth <key> <outfile>
|
fetch_auth() { # fetch_auth <key> <outfile>
|
||||||
curl -sS -b "$COOKIES" -o "$2" "$BASE/d/$1"
|
curl -sS -b "$COOKIES" -o "$2" "$BASE/$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
printf 'hello world\n' > "$TMP/plain.txt"
|
printf 'hello world\n' > "$TMP/plain.txt"
|
||||||
@@ -62,7 +62,7 @@ URL="$(get "$TMP/up.json" url)"
|
|||||||
check "protected file serves a password form" test \
|
check "protected file serves a password form" test \
|
||||||
"$(curl -sS -o /dev/null -w '%{content_type}' "$BASE$URL")" = "text/html; charset=utf-8"
|
"$(curl -sS -o /dev/null -w '%{content_type}' "$BASE$URL")" = "text/html; charset=utf-8"
|
||||||
check "invalid key returns 404" test \
|
check "invalid key returns 404" test \
|
||||||
"$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/d/AAAAAAAAAAAA")" = "404"
|
"$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/AAAAAAAAAAAA")" = "404"
|
||||||
check "storage directory not served" test \
|
check "storage directory not served" test \
|
||||||
"$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/storage/")" = "404"
|
"$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/storage/")" = "404"
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -19,7 +19,7 @@ export async function handleAuth(
|
|||||||
if (!meta) return notFound();
|
if (!meta) return notFound();
|
||||||
|
|
||||||
if (!meta.passwordHash) {
|
if (!meta.passwordHash) {
|
||||||
return new Response(null, { status: 302, headers: { location: `/d/${rawKey}` } });
|
return new Response(null, { status: 302, headers: { location: `/${rawKey}` } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const password = await req.formData().then(
|
const password = await req.formData().then(
|
||||||
@@ -32,7 +32,7 @@ export async function handleAuth(
|
|||||||
|
|
||||||
if (!passwordMatches(meta.passwordHash, password)) {
|
if (!passwordMatches(meta.passwordHash, password)) {
|
||||||
log("warn", `auth failed id=${id}`);
|
log("warn", `auth failed id=${id}`);
|
||||||
return new Response(null, { status: 302, headers: { location: `/d/${rawKey}?e=1` } });
|
return new Response(null, { status: 302, headers: { location: `/${rawKey}?e=1` } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = makeAuthToken(rawKey, secret, AUTH_TTL_MS);
|
const token = makeAuthToken(rawKey, secret, AUTH_TTL_MS);
|
||||||
@@ -41,5 +41,5 @@ export async function handleAuth(
|
|||||||
}; Max-Age=${Math.floor(AUTH_TTL_MS / 1000)}`;
|
}; Max-Age=${Math.floor(AUTH_TTL_MS / 1000)}`;
|
||||||
|
|
||||||
log("info", `auth ok id=${id}`);
|
log("info", `auth ok id=${id}`);
|
||||||
return new Response(null, { status: 302, headers: { location: `/d/${rawKey}`, "set-cookie": cookie } });
|
return new Response(null, { status: 302, headers: { location: `/${rawKey}`, "set-cookie": cookie } });
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,7 @@ export async function handleUpload(
|
|||||||
});
|
});
|
||||||
|
|
||||||
log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`);
|
log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`);
|
||||||
return json({ id, key, url: `/d/${key}`, originalName, size, protected: true });
|
return json({ id, key, url: `/${key}`, originalName, size, protected: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await storage.delete(id);
|
await storage.delete(id);
|
||||||
await storage.deletePartial(id);
|
await storage.deletePartial(id);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { handleAuth } from "./routes/auth";
|
|||||||
import { handleDelete } from "./routes/delete";
|
import { handleDelete } from "./routes/delete";
|
||||||
import { handleDownload } from "./routes/download";
|
import { handleDownload } from "./routes/download";
|
||||||
import { handleUpload } from "./routes/upload";
|
import { handleUpload } from "./routes/upload";
|
||||||
|
import { KEY_PATTERN } from "./security";
|
||||||
import { Storage } from "./storage";
|
import { Storage } from "./storage";
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
@@ -113,6 +114,19 @@ async function route(req: Request, srv: Server<undefined>): Promise<Response> {
|
|||||||
return handleDownload(req, storage, path.slice(3), secret);
|
return handleDownload(req, storage, path.slice(3), secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rootKey = path.slice(1);
|
||||||
|
if (
|
||||||
|
rootKey &&
|
||||||
|
!rootKey.includes("/") &&
|
||||||
|
KEY_PATTERN.test(rootKey) &&
|
||||||
|
(method === "GET" || method === "HEAD")
|
||||||
|
) {
|
||||||
|
if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) {
|
||||||
|
return jsonError(429, "too many requests");
|
||||||
|
}
|
||||||
|
return handleDownload(req, storage, rootKey, secret);
|
||||||
|
}
|
||||||
|
|
||||||
if (path.startsWith("/api/files/") && method === "DELETE") {
|
if (path.startsWith("/api/files/") && method === "DELETE") {
|
||||||
if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) {
|
if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) {
|
||||||
return jsonError(429, "too many requests");
|
return jsonError(429, "too many requests");
|
||||||
|
|||||||
Reference in New Issue
Block a user