fix: strengthen cloud sessions, share secrets and password storage

This commit is contained in:
webadderall
2026-09-21 21:22:01 +10:00
parent 35335d4a94
commit f40b2cb87a
7 changed files with 87 additions and 40 deletions
@@ -69,7 +69,7 @@ async function createCommentSession(request, env, user) {
export async function handleCommentRegister(request, env) {
const ip = request.headers.get('CF-Connecting-IP') || 'unknown';
if (!checkLoginRateLimit(`comment:${ip}`)) return errorResponse('Too many attempts', 429);
if (!checkLoginRateLimit(`register:${ip}`)) return errorResponse('Too many attempts', 429);
const body = await request.json();
const email = String(body.email || '').trim().toLowerCase();
const displayName = String(body.displayName || '').trim();
@@ -89,7 +89,6 @@ export async function handleCommentRegister(request, env) {
const result = await env.DB.prepare(
'INSERT INTO comment_users (email, display_name, password_hash, password_salt) VALUES (?, ?, ?, ?)'
).bind(email, displayName, passwordHash, salt).run();
clearLoginRateLimit(`comment:${ip}`);
return createCommentSession(request, env, { id: result.meta.last_row_id, email, display_name: displayName });
} catch (error) {
if (/unique|constraint/i.test(error.message || '')) {
+18 -20
View File
@@ -1,7 +1,7 @@
// Adapted from Voom (MIT), Copyright (c) 2026 Aritro Paul.
// See ../../LICENSE and ../../../../THIRD_PARTY_NOTICES.md for attribution.
import { generateSalt, sha256Hex, timingSafeEqual } from './crypto.js';
import { generateSalt, sha256Hex, timingSafeEqual, hashRecordingPassword } from './crypto.js';
import { errorResponse, jsonResponse, parseCookies } from './http.js';
export async function isAuthorized(request, env) {
@@ -61,15 +61,15 @@ export function dashboardPassword(env) {
return env.DASHBOARD_PASSWORD || env.API_SECRET;
}
export async function expectedSessionToken(env) {
export async function expectedSessionToken(env, expiresAt = Math.floor(Date.now() / 1000) + 604800) {
const password = dashboardPassword(env);
if (!password) throw new Error('Dashboard sign-in is not configured');
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw', encoder.encode(password), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode('voom-dashboard-v1'));
return Array.from(new Uint8Array(sig), b => b.toString(16).padStart(2, '0')).join('');
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(`voom-dashboard-v2:${expiresAt}`));
return `${expiresAt}.` + Array.from(new Uint8Array(sig), b => b.toString(16).padStart(2, '0')).join('');
}
export async function isDashboardAuthed(request, env) {
@@ -80,8 +80,10 @@ export async function dashboardCookieAuthed(request, env) {
if (!dashboardPassword(env)) return false;
const cookies = parseCookies(request.headers.get('Cookie') || '');
const sessionToken = cookies['voom_session'];
if (!sessionToken) return false;
return timingSafeEqual(sessionToken, await expectedSessionToken(env));
if (!sessionToken || !/^\d+\.[0-9a-f]{64}$/.test(sessionToken)) return false;
const expiresAt = Number(sessionToken.split('.')[0]);
if (!Number.isSafeInteger(expiresAt) || expiresAt <= Math.floor(Date.now() / 1000)) return false;
return timingSafeEqual(sessionToken, await expectedSessionToken(env, expiresAt));
}
// best-effort, per-isolate login rate limiter (real protection is the password's entropy)
@@ -123,21 +125,17 @@ export async function handleVerifyPassword(request, env, shareCode) {
const body = await request.json();
const password = body.password || '';
// The browser sends the raw password (HTTPS); the app stored it as a salted
// hash of SHA256(password). Legacy rows (pre-salt) hold bare SHA256(password).
const clientHash = await sha256Hex(password);
let matches;
if (video.password_salt) {
matches = timingSafeEqual(await sha256Hex(video.password_salt + clientHash), video.password_hash);
} else {
matches = timingSafeEqual(clientHash, video.password_hash);
// Lazy upgrade: re-store the legacy unsalted hash as salted on success.
if (matches) {
const salt = generateSalt();
const upgraded = await sha256Hex(salt + clientHash);
await env.DB.prepare('UPDATE videos SET password_hash = ?, password_salt = ? WHERE id = ?')
.bind(upgraded, salt, video.id).run();
}
const slowHash = video.password_hash.startsWith('pbkdf2-sha256:210000:');
const actual = slowHash
? await hashRecordingPassword(clientHash, video.password_salt)
: video.password_salt ? await sha256Hex(video.password_salt + clientHash) : clientHash;
const matches = timingSafeEqual(actual, video.password_hash);
if (matches && !slowHash) {
const salt = generateSalt();
const upgraded = await hashRecordingPassword(clientHash, salt);
await env.DB.prepare('UPDATE videos SET password_hash = ?, password_salt = ? WHERE id = ?')
.bind(upgraded, salt, video.id).run();
}
if (!matches) {
@@ -27,3 +27,10 @@ export function generateSalt() {
crypto.getRandomValues(bytes);
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
}
// The prefix versions the algorithm and work factor without changing legacy rows.
export async function hashRecordingPassword(clientHash, salt) {
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(clientHash), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: new TextEncoder().encode(salt), iterations: 210000 }, key, 256);
return 'pbkdf2-sha256:210000:' + Array.from(new Uint8Array(bits), b => b.toString(16).padStart(2, '0')).join('');
}
+5 -11
View File
@@ -3,16 +3,12 @@
import { errorResponse, jsonResponse } from './http.js';
import { EXPIRY_DAYS, finiteNonnegative } from './video.js';
import { generateSalt, sha256Hex } from './crypto.js';
const SHARE_CODE_CHARS = 'abcdefghjkmnpqrstuvwxyz23456789';
const SHARE_CODE_LENGTH = 10;
import { generateSalt, hashRecordingPassword } from './crypto.js';
export function generateShareCode() {
const bytes = new Uint8Array(SHARE_CODE_LENGTH);
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, b => SHARE_CODE_CHARS[b % SHARE_CODE_CHARS.length]).join('');
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
}
export async function handleUpload(request, env) {
@@ -31,14 +27,12 @@ export async function handleUpload(request, env) {
const shareCode = generateShareCode();
const expiresAt = new Date(Date.now() + EXPIRY_DAYS * 24 * 60 * 60 * 1000).toISOString();
// Store password hashes salted: hash = SHA256(salt + clientHash). The client
// sends SHA256(password) so the raw password never leaves the user's machine;
// salting server-side makes a leaked D1 dump useless against rainbow tables.
// Versioned slow hash of the client digest, with a unique per-record salt.
let storedHash = null;
let salt = null;
if (password_hash) {
salt = generateSalt();
storedHash = await sha256Hex(salt + password_hash);
storedHash = await hashRecordingPassword(password_hash, salt);
}
await env.DB.prepare(
@@ -134,7 +134,7 @@ describe('upload validation', () => {
it('accepts https CTA URLs and returns a well-formed share code', async () => {
const data = await createShare({ cta_url: 'https://example.com', cta_text: 'Visit' });
expect(data.shareCode).toMatch(/^[a-z0-9]{10}$/);
expect(data.shareCode).toMatch(/^[0-9a-f]{64}$/);
expect(data.shareURL).toContain(`/s/${data.shareCode}`);
});
@@ -338,7 +338,7 @@ describe('password protection', () => {
const row = await env.DB.prepare('SELECT password_hash, password_salt FROM videos WHERE share_code = ?')
.bind(shareCode).first();
expect(row.password_salt).toMatch(/^[0-9a-f]{32}$/);
expect(row.password_hash).not.toBe(clientHash);
expect(row.password_hash).toMatch(/^pbkdf2-sha256:210000:[0-9a-f]{64}$/);
});
it('lazily upgrades legacy unsalted rows on successful verify', async () => {
@@ -357,7 +357,7 @@ describe('password protection', () => {
const row = await env.DB.prepare('SELECT password_hash, password_salt FROM videos WHERE share_code = ?')
.bind(shareCode).first();
expect(row.password_salt).toMatch(/^[0-9a-f]{32}$/);
expect(row.password_hash).not.toBe(clientHash); // re-stored salted
expect(row.password_hash).toMatch(/^pbkdf2-sha256:210000:[0-9a-f]{64}$/); // re-stored salted
});
it('rate-limits brute-force attempts (429 after 10 failures)', async () => {
@@ -562,3 +562,42 @@ it('clamps a bounded video range to the actual object size', async () => {
expect(response.headers.get('Content-Length')).toBe('2');
expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual([6, 7]);
});
it('counts successful registrations without allowing login to reset their quota', async () => {
const headers = { 'Content-Type': 'application/json', 'CF-Connecting-IP': '192.0.2.201' };
for (let i = 0; i < 10; i++) {
const response = await SELF.fetch(`${BASE}/auth/register`, {
method: 'POST', headers,
body: JSON.stringify({ email: `quota-${i}@example.com`, displayName: 'Quota Test', password: 'test-password' }),
});
expect(response.status).toBe(200);
}
const login = await SELF.fetch(`${BASE}/auth/login`, {
method: 'POST', headers,
body: JSON.stringify({ email: 'quota-0@example.com', password: 'test-password' }),
});
expect(login.status).toBe(200);
const blocked = await SELF.fetch(`${BASE}/auth/register`, {
method: 'POST', headers,
body: JSON.stringify({ email: 'quota-extra@example.com', displayName: 'Quota Test', password: 'test-password' }),
});
expect(blocked.status).toBe(429);
});
it('upgrades salted legacy recording passwords only after correct verification', async () => {
const password = 'legacy-recording-password';
const { shareCode } = await createShare();
await completeUpload(shareCode);
const salt = 'legacy-salt';
const hash = await sha256Hex(salt + await sha256Hex(password));
await env.DB.prepare('UPDATE videos SET password_hash = ?, password_salt = ? WHERE share_code = ?').bind(hash, salt, shareCode).run();
const verify = (value) => SELF.fetch(`${BASE}/s/${shareCode}/verify-password`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: value }),
});
expect((await verify('wrong')).status).toBe(403);
expect((await env.DB.prepare('SELECT password_hash FROM videos WHERE share_code = ?').bind(shareCode).first()).password_hash).toBe(hash);
expect((await verify(password)).status).toBe(200);
const row = await env.DB.prepare('SELECT password_hash FROM videos WHERE share_code = ?').bind(shareCode).first();
expect(row.password_hash).toMatch(/^pbkdf2-sha256:210000:[0-9a-f]{64}$/);
expect((await verify(password)).status).toBe(200);
});
@@ -9,8 +9,8 @@ import {
formatVTTTime,
} from '../src/index.js';
const SHARE_CODE_CHARS = 'abcdefghjkmnpqrstuvwxyz23456789';
const SHARE_CODE_LENGTH = 10;
const SHARE_CODE_CHARS = '0123456789abcdef';
const SHARE_CODE_LENGTH = 64;
describe('generateShareCode', () => {
it('produces codes of the documented length and charset', () => {
@@ -22,8 +22,8 @@ describe('generateShareCode', () => {
}
});
it('excludes ambiguous characters (i, l, o, 0, 1)', () => {
for (const ch of 'ilo01') expect(SHARE_CODE_CHARS).not.toContain(ch);
it('generates distinct random codes', () => {
expect(new Set(Array.from({ length: 100 }, generateShareCode)).size).toBe(100);
});
});
@@ -153,3 +153,13 @@ describe('GET /library (dashboard gate)', () => {
expect([200, 304]).toContain(res.status);
});
});
it('rejects expired and tampered dashboard sessions', async () => {
const expired = await expectedSessionToken(env, Math.floor(Date.now() / 1000) - 1);
const valid = await expectedSessionToken(env);
const tampered = `${Number(valid.split('.')[0]) + 604800}.${valid.split('.')[1]}`;
for (const token of [expired, tampered, valid.split('.')[1]]) {
const res = await SELF.fetch(`${BASE}/api/videos`, { headers: { Cookie: `voom_session=${token}` } });
expect(res.status).toBe(401);
}
});