Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions migrations/0011_session_payload.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Migration: move the session payload server-side
-- The session cookie used to carry the whole user object as unsigned base64
-- JSON, so anyone could forge one with isAdmin/isOwner set and take over admin.
-- The fix keeps the cookie as an opaque session id (already a random UUID in
-- the sessions table) and stores the trusted payload here, read back on every
-- request. A forged cookie now names a session that does not exist.
--
-- Nullable and additive: existing session rows (written for activity tracking)
-- keep working, and the per-user session COUNT in the admin UI is unaffected.

ALTER TABLE sessions ADD COLUMN data TEXT;
55 changes: 19 additions & 36 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
prefersMarkdown,
toMarkdownResponse
} from '$lib/server/markdown-negotiation';
import { decodeSessionCookie } from '$lib/utils/session';
import { getAuthSession } from '$lib/utils/db';
import {
browserBucket,
deviceBucket,
Expand All @@ -21,47 +21,30 @@ import { sequence } from '@sveltejs/kit/hooks';

// Auth handling hook
const authHandler: Handle = async ({ event, resolve }) => {
// Get session cookie
// The cookie is an OPAQUE session id, not the user object. The trusted payload
// lives server-side in sessions.data (see createAuthSession) and is read back
// here on every request, so isOwner/isAdmin cannot be forged by editing the
// cookie. A cookie that resolves to no session leaves the request
// unauthenticated — fail closed.
const sessionId = event.cookies.get('session');

if (sessionId) {
const sessionData = decodeSessionCookie(sessionId);

if (sessionData) {
// Refresh admin flags from the database (optional - don't fail auth if
// DB unavailable). Reading them per-request rather than trusting the
// cookie means granting or revoking access takes effect immediately.
if (event.platform?.env?.DB) {
const db = event.platform.env.DB;
let userRecord: { is_admin: number; can_view_stats?: number } | null = null;
try {
userRecord = await db
.prepare('SELECT is_admin, can_view_stats FROM users WHERE id = ?')
.bind(sessionData.id)
.first<{ is_admin: number; can_view_stats: number }>();
} catch {
// `can_view_stats` arrives in migration 0009. On a database that
// hasn't run it yet the combined SELECT fails, which must not cost
// us the is_admin refresh — fall back to the narrower query.
try {
userRecord = await db
.prepare('SELECT is_admin FROM users WHERE id = ?')
.bind(sessionData.id)
.first<{ is_admin: number }>();
} catch {
// Database error - continue with session data from cookie
}
}

if (userRecord) {
sessionData.isAdmin = userRecord.is_admin === 1;
sessionData.canViewStats = userRecord.can_view_stats === 1;
}
const db = event.platform?.env?.DB;
let user = null;
if (db) {
try {
user = await getAuthSession(db, sessionId);
} catch {
// A database error means we cannot authenticate this request — treat
// it as unauthenticated rather than trusting the cookie.
user = null;
}
}

event.locals.user = sessionData;
if (user) {
event.locals.user = user;
} else {
// Invalid session, clear cookie
// Unknown, expired, or unverifiable session — clear the stale cookie.
event.cookies.delete('session', { path: '/' });
}
}
Expand Down
56 changes: 54 additions & 2 deletions src/lib/utils/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Database utility functions for D1
*/
import type { D1Database } from '@cloudflare/workers-types';
import type { SessionUser } from './session';

export interface User {
id: string;
Expand Down Expand Up @@ -73,11 +74,62 @@ export async function createSession(
return result;
}

/**
* Create an authenticated session and return its opaque id for the cookie.
*
* The id is a random UUID; the TRUSTED user payload is stored in `sessions.data`
* server-side and read back on every request (see getAuthSession). The cookie
* never carries the payload, so isOwner/isAdmin cannot be forged by editing it.
*/
export async function createAuthSession(
db: D1Database,
user: SessionUser,
expiresInDays: number = 7
): Promise<string> {
const id = crypto.randomUUID();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + expiresInDays);

await db
.prepare('INSERT INTO sessions (id, user_id, expires_at, data) VALUES (?, ?, ?, ?)')
.bind(id, user.id, expiresAt.toISOString(), JSON.stringify(user))
.run();

return id;
}

/**
* Resolve a session cookie to its stored user payload, or null if the session
* is unknown, expired, or predates this scheme (no stored payload). A forged
* cookie resolves to null because it names no real session — fail closed.
*/
export async function getAuthSession(
db: D1Database,
sessionId: string
): Promise<SessionUser | null> {
// datetime(expires_at) normalizes the stored ISO string ("...T...Z") before
// comparing: a raw string compare against datetime('now') ("... ...") sorts
// 'T' after ' ', so a same-day expiry read as still-valid for up to a day.
const row = await db
.prepare("SELECT data FROM sessions WHERE id = ? AND datetime(expires_at) > datetime('now')")
.bind(sessionId)
.first<{ data: string | null }>();

if (!row?.data) return null;
try {
return JSON.parse(row.data) as SessionUser;
} catch {
return null;
}
}

/**
* Find session by ID and check if it's valid
*/
export async function findValidSession(db: D1Database, sessionId: string): Promise<Session | null> {
const stmt = db.prepare('SELECT * FROM sessions WHERE id = ? AND expires_at > datetime("now")');
const stmt = db.prepare(
"SELECT * FROM sessions WHERE id = ? AND datetime(expires_at) > datetime('now')"
);
return await stmt.bind(sessionId).first<Session>();
}

Expand All @@ -93,6 +145,6 @@ export async function deleteSession(db: D1Database, sessionId: string): Promise<
* Clean up expired sessions
*/
export async function cleanupExpiredSessions(db: D1Database): Promise<void> {
const stmt = db.prepare('DELETE FROM sessions WHERE expires_at < datetime("now")');
const stmt = db.prepare("DELETE FROM sessions WHERE datetime(expires_at) < datetime('now')");
await stmt.run();
}
20 changes: 13 additions & 7 deletions src/lib/utils/dev-auth.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1']);

function isLocalDevHost(url: URL): boolean {
return LOCAL_DEV_HOSTS.has(url.hostname);
function isLocalDevHost(url: URL | undefined): boolean {
// No URL means we cannot prove this is a local host — fail closed.
return !!url && LOCAL_DEV_HOSTS.has(url.hostname);
}

export function isDevAuthSimulationEnabled(
url: URL,
platform: App.Platform | undefined
): boolean {
export function isDevAuthSimulationEnabled(url: URL, platform: App.Platform | undefined): boolean {
// The simulator mints a real owner/admin session server-side, so it must never
// be reachable in production. Both the explicit override and the implicit
// dev-mode path additionally require a local host — a stray DEV_AUTH_BYPASS on
// a deployed environment can no longer hand out owner access from the internet.
if (!isLocalDevHost(url)) {
return false;
}

const explicitOverride = platform?.env?.DEV_AUTH_BYPASS === 'true';
if (explicitOverride) {
return true;
Expand All @@ -18,5 +24,5 @@ export function isDevAuthSimulationEnabled(
return false;
}

return import.meta.env.DEV && isLocalDevHost(url);
return import.meta.env.DEV;
}
48 changes: 14 additions & 34 deletions src/lib/utils/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,6 @@ interface SessionUserInput {
isAdmin?: boolean;
}

function toBase64Url(value: string): string {
return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

function fromBase64Url(value: string): string {
let normalized = value;
if (normalized.includes('-') || normalized.includes('_')) {
normalized = normalized.replace(/-/g, '+').replace(/_/g, '/');
}
while (normalized.length % 4) {
normalized += '=';
}

return atob(normalized);
}

export function deriveLoginIdentifier(email: string, githubLogin?: string | null): string {
if (githubLogin) {
return githubLogin;
Expand Down Expand Up @@ -69,25 +53,21 @@ export function createSessionUser(input: SessionUserInput): SessionUser {
};
}

export function encodeSession(user: SessionUser): string {
return toBase64Url(JSON.stringify(user));
}

export function decodeSessionCookie(sessionCookie?: string): SessionUser | null {
if (!sessionCookie) {
return null;
}

try {
return JSON.parse(fromBase64Url(sessionCookie)) as SessionUser;
} catch {
return null;
}
}

export function buildSessionCookieHeader(user: SessionUser, url: URL): string {
/**
* Build the Set-Cookie header for a session.
*
* The value is an OPAQUE session id (from createAuthSession), never the user
* object. The trusted payload lives server-side in sessions.data, so a client
* that edits this cookie only names a different (non-existent) session and is
* refused — isOwner/isAdmin can no longer be forged in the cookie.
*
* The cookie used to carry base64(JSON(user)); `encodeSession`/`decodeSessionCookie`
* were removed with that scheme. Do not reintroduce a cookie the hooks trust
* without a server-side lookup.
*/
export function buildSessionCookieHeader(sessionId: string, url: URL): string {
const cookieParts = [
`session=${encodeSession(user)}`,
`session=${sessionId}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
Expand Down
18 changes: 11 additions & 7 deletions src/routes/api/auth/connections/+server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { error, json } from '@sveltejs/kit';
import { createAuthSession } from '$lib/utils/db';
import { buildSessionCookieHeader } from '$lib/utils/session';
import type { RequestHandler } from './$types';

Expand Down Expand Up @@ -73,17 +74,20 @@ export const DELETE: RequestHandler = async ({ locals, platform, request, url })
const existingConnections = locals.user.simulatedConnections || [];
const simulatedConnections = existingConnections.filter((value) => value !== provider);

if (!platform?.env?.DB) {
throw error(500, 'Database not available');
}

const sessionId = await createAuthSession(platform.env.DB, {
...locals.user,
simulatedConnections
});

return json(
{ success: true, connections: simulatedConnections.map((name) => ({ provider: name })) },
{
headers: {
'Set-Cookie': buildSessionCookieHeader(
{
...locals.user,
simulatedConnections
},
url
)
'Set-Cookie': buildSessionCookieHeader(sessionId, url)
}
}
);
Expand Down
32 changes: 20 additions & 12 deletions src/routes/api/auth/dev-simulate/+server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { buildSessionCookieHeader, decodeSessionCookie } from '$lib/utils/session';
import { createAuthSession, getAuthSession } from '$lib/utils/db';
import { buildSessionCookieHeader } from '$lib/utils/session';
import { isDevAuthSimulationEnabled } from '$lib/utils/dev-auth';
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
Expand Down Expand Up @@ -37,8 +38,7 @@ function buildDevUser(provider: SupportedProvider, role: SimulatedRole) {
const login = `${loginPrefix}-${suffix}`;
const isSuperadmin = role === 'superadmin';
const isAdmin = role === 'admin' || isSuperadmin;
const namePrefix =
role === 'superadmin' ? 'Superadmin' : role === 'admin' ? 'Admin' : 'User';
const namePrefix = role === 'superadmin' ? 'Superadmin' : role === 'admin' ? 'Admin' : 'User';

return {
id: `dev-${provider}-${suffix}`,
Expand All @@ -59,31 +59,37 @@ export const GET: RequestHandler = async ({ url, platform, cookies }) => {
throw redirect(302, '/auth/login?error=not_configured');
}

// The simulator still mints a real server-side session (its payload is trusted
// exactly like a real login's), so it needs the database like any other login.
const db = platform?.env?.DB;
if (!db) {
throw redirect(302, '/auth/login?error=not_configured');
}

const provider = parseProvider(url.searchParams.get('provider'));
if (!provider) {
throw redirect(302, '/auth/login?error=oauth_failed');
}

const mode = parseMode(url.searchParams.get('mode'));
if (mode === 'link') {
const existingUser = decodeSessionCookie(cookies.get('session'));
const existingUser = await getAuthSession(db, cookies.get('session') ?? '');

if (existingUser?.isPretend) {
const simulatedConnections = Array.from(
new Set([...(existingUser.simulatedConnections || []), provider])
);

const linkedSessionId = await createAuthSession(db, {
...existingUser,
simulatedConnections
});

return new Response(null, {
status: 302,
headers: {
Location: new URL(`/profile?linked=${provider}`, url.origin).toString(),
'Set-Cookie': buildSessionCookieHeader(
{
...existingUser,
simulatedConnections
},
url
)
'Set-Cookie': buildSessionCookieHeader(linkedSessionId, url)
}
});
}
Expand All @@ -93,11 +99,13 @@ export const GET: RequestHandler = async ({ url, platform, cookies }) => {
const sessionUser = buildDevUser(provider, role);
const redirectTarget = role === 'admin' || role === 'superadmin' ? '/admin' : '/';

const sessionId = await createAuthSession(db, sessionUser);

return new Response(null, {
status: 302,
headers: {
Location: new URL(redirectTarget, url.origin).toString(),
'Set-Cookie': buildSessionCookieHeader(sessionUser, url)
'Set-Cookie': buildSessionCookieHeader(sessionId, url)
}
});
};
Loading
Loading