mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 23:35:43 +00:00
fix: strengthen cloud sessions, share secrets and password storage
This commit is contained in:
@@ -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 || '')) {
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user