assafvayner HF Staff commited on
Commit
b11153a
·
1 Parent(s): 3327d9d

fix(ui): shared safe-next validation, no lockout on success, logout origin check, tab/feed polish

Browse files
src/hooks.server.ts CHANGED
@@ -1,11 +1,11 @@
1
  import { dev } from '$app/environment';
 
2
  import { SESSION_COOKIE, verifySessionToken } from '$lib/server/auth/session';
3
  import { getApp } from '$lib/server/app';
4
  import { error, redirect, type Handle } from '@sveltejs/kit';
5
 
6
  const PUBLIC_PATHS = new Set(['/login']);
7
  const ALLOWED_WHILE_PASSWORD_CHANGE = new Set(['/me/password', '/logout']);
8
- const SAFE_NEXT = /^\/([^/\\]|$)/;
9
 
10
  export const handle: Handle = async ({ event, resolve }) => {
11
  const app = await getApp();
@@ -28,7 +28,8 @@ export const handle: Handle = async ({ event, resolve }) => {
28
 
29
  if (!user) {
30
  if (!PUBLIC_PATHS.has(path)) {
31
- const next = SAFE_NEXT.test(path) ? `?next=${encodeURIComponent(path)}` : '';
 
32
  redirect(303, `/login${next}`);
33
  }
34
  } else {
 
1
  import { dev } from '$app/environment';
2
+ import { SAFE_NEXT } from '$lib/server/auth/safe-next';
3
  import { SESSION_COOKIE, verifySessionToken } from '$lib/server/auth/session';
4
  import { getApp } from '$lib/server/app';
5
  import { error, redirect, type Handle } from '@sveltejs/kit';
6
 
7
  const PUBLIC_PATHS = new Set(['/login']);
8
  const ALLOWED_WHILE_PASSWORD_CHANGE = new Set(['/me/password', '/logout']);
 
9
 
10
  export const handle: Handle = async ({ event, resolve }) => {
11
  const app = await getApp();
 
28
 
29
  if (!user) {
30
  if (!PUBLIC_PATHS.has(path)) {
31
+ const fullPath = event.url.pathname + event.url.search;
32
+ const next = SAFE_NEXT.test(path) ? `?next=${encodeURIComponent(fullPath)}` : '';
33
  redirect(303, `/login${next}`);
34
  }
35
  } else {
src/lib/components/TabBar.svelte CHANGED
@@ -12,11 +12,22 @@
12
  { href: '/me', label: 'Me', icon: '👤' }
13
  ]);
14
 
15
- const isActive = (href: string) =>
16
- href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
 
 
 
 
 
 
 
 
 
 
17
  </script>
18
 
19
  <nav
 
20
  class="fixed inset-x-0 bottom-0 z-10 border-t border-gray-200 bg-white pb-[env(safe-area-inset-bottom)]"
21
  >
22
  <ul class="mx-auto flex max-w-lg justify-around">
 
12
  { href: '/me', label: 'Me', icon: '👤' }
13
  ]);
14
 
15
+ const isActive = (href: string) => {
16
+ const path = page.url.pathname;
17
+ if (href === '/') {
18
+ return (
19
+ path === '/' ||
20
+ (path.startsWith('/expenses/') && path !== '/expenses/new') ||
21
+ path.startsWith('/settlements/')
22
+ );
23
+ }
24
+ if (href === '/expenses/new') return path === '/expenses/new';
25
+ return path.startsWith(href);
26
+ };
27
  </script>
28
 
29
  <nav
30
+ aria-label="Primary"
31
  class="fixed inset-x-0 bottom-0 z-10 border-t border-gray-200 bg-white pb-[env(safe-area-inset-bottom)]"
32
  >
33
  <ul class="mx-auto flex max-w-lg justify-around">
src/lib/server/auth/rate-limit.ts CHANGED
@@ -34,4 +34,8 @@ export class RateLimiter {
34
  this.hits.set(key, recent);
35
  return true;
36
  }
 
 
 
 
37
  }
 
34
  this.hits.set(key, recent);
35
  return true;
36
  }
37
+
38
+ reset(key: string): void {
39
+ this.hits.delete(key);
40
+ }
41
  }
src/lib/server/auth/safe-next.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export const SAFE_NEXT = /^\/([^/\\]|$)/;
2
+
3
+ export function safeNext(next: string | null, fallback = '/'): string {
4
+ return next !== null && SAFE_NEXT.test(next) ? next : fallback;
5
+ }
src/routes/+page.server.ts CHANGED
@@ -22,17 +22,18 @@ export const load: PageServerLoad = ({ locals }) => {
22
  ...ledger.listExpenses().map((e): FeedItem => {
23
  const paid = e.paidBy === me.id ? e.amountCents : 0;
24
  const share = e.shares[me.id] ?? 0;
 
25
  return {
26
  kind: 'expense', id: e.id, date: e.date, createdAt: e.createdAt,
27
  title: e.description,
28
- subtitle: `${name(e.paidBy)} paid · ${Object.keys(e.shares).length} people`,
29
  amountCents: e.amountCents, myDeltaCents: paid - share, hasPhoto: !!e.photo
30
  };
31
  }),
32
  ...ledger.listSettlements().map((s): FeedItem => ({
33
  kind: 'settlement', id: s.id, date: s.date, createdAt: s.createdAt,
34
  title: `${name(s.from)} paid ${name(s.to)}`,
35
- subtitle: s.note ?? 'Settlement',
36
  amountCents: s.amountCents,
37
  myDeltaCents: s.from === me.id ? s.amountCents : s.to === me.id ? -s.amountCents : 0,
38
  hasPhoto: false
 
22
  ...ledger.listExpenses().map((e): FeedItem => {
23
  const paid = e.paidBy === me.id ? e.amountCents : 0;
24
  const share = e.shares[me.id] ?? 0;
25
+ const peopleCount = Object.keys(e.shares).length;
26
  return {
27
  kind: 'expense', id: e.id, date: e.date, createdAt: e.createdAt,
28
  title: e.description,
29
+ subtitle: `${name(e.paidBy)} paid · ${peopleCount} ${peopleCount === 1 ? 'person' : 'people'}`,
30
  amountCents: e.amountCents, myDeltaCents: paid - share, hasPhoto: !!e.photo
31
  };
32
  }),
33
  ...ledger.listSettlements().map((s): FeedItem => ({
34
  kind: 'settlement', id: s.id, date: s.date, createdAt: s.createdAt,
35
  title: `${name(s.from)} paid ${name(s.to)}`,
36
+ subtitle: s.note?.trim() || 'Settlement',
37
  amountCents: s.amountCents,
38
  myDeltaCents: s.from === me.id ? s.amountCents : s.to === me.id ? -s.amountCents : 0,
39
  hasPhoto: false
src/routes/login/+page.server.ts CHANGED
@@ -1,4 +1,5 @@
1
  import { dev } from '$app/environment';
 
2
  import { createSessionToken, SESSION_COOKIE, SESSION_TTL_SECONDS } from '$lib/server/auth/session';
3
  import { fail, redirect } from '@sveltejs/kit';
4
  import type { Actions } from './$types';
@@ -16,6 +17,8 @@ export const actions: Actions = {
16
  const user = await locals.app.users.authenticate(username, password);
17
  if (!user) return fail(400, { error: 'Invalid username or password', username });
18
 
 
 
19
  const token = createSessionToken(
20
  { uid: user.id, sv: user.sessionVersion },
21
  locals.app.config.sessionSecret
@@ -28,7 +31,6 @@ export const actions: Actions = {
28
  maxAge: SESSION_TTL_SECONDS
29
  });
30
 
31
- const next = url.searchParams.get('next');
32
- redirect(303, next && next.startsWith('/') && !next.startsWith('//') ? next : '/');
33
  }
34
  };
 
1
  import { dev } from '$app/environment';
2
+ import { safeNext } from '$lib/server/auth/safe-next';
3
  import { createSessionToken, SESSION_COOKIE, SESSION_TTL_SECONDS } from '$lib/server/auth/session';
4
  import { fail, redirect } from '@sveltejs/kit';
5
  import type { Actions } from './$types';
 
17
  const user = await locals.app.users.authenticate(username, password);
18
  if (!user) return fail(400, { error: 'Invalid username or password', username });
19
 
20
+ locals.app.loginLimiter.reset(username.toLowerCase());
21
+
22
  const token = createSessionToken(
23
  { uid: user.id, sv: user.sessionVersion },
24
  locals.app.config.sessionSecret
 
31
  maxAge: SESSION_TTL_SECONDS
32
  });
33
 
34
+ redirect(303, safeNext(url.searchParams.get('next')));
 
35
  }
36
  };
src/routes/logout/+server.ts CHANGED
@@ -1,7 +1,8 @@
1
  import { SESSION_COOKIE } from '$lib/server/auth/session';
2
- import { redirect, type RequestHandler } from '@sveltejs/kit';
3
 
4
- export const POST: RequestHandler = ({ cookies }) => {
 
5
  cookies.delete(SESSION_COOKIE, { path: '/' });
6
  redirect(303, '/login');
7
  };
 
1
  import { SESSION_COOKIE } from '$lib/server/auth/session';
2
+ import { error, redirect, type RequestHandler } from '@sveltejs/kit';
3
 
4
+ export const POST: RequestHandler = ({ cookies, request, url }) => {
5
+ if (request.headers.get('origin') !== url.origin) error(403, 'Forbidden');
6
  cookies.delete(SESSION_COOKIE, { path: '/' });
7
  redirect(303, '/login');
8
  };
tests/unit/auth/auth.test.ts CHANGED
@@ -66,6 +66,19 @@ describe('RateLimiter', () => {
66
  expect(rl.allow('bob')).toBe(true);
67
  });
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  it('evicts stale keys once the tracked-key cap is exceeded', () => {
70
  let now = 0;
71
  const rl = new RateLimiter(5, 1_000, () => now);
 
66
  expect(rl.allow('bob')).toBe(true);
67
  });
68
 
69
+ it('reset() clears a key so it is immediately allowed again', () => {
70
+ let now = 0;
71
+ const rl = new RateLimiter(5, 60_000, () => now);
72
+ expect(rl.allow('bob')).toBe(true);
73
+ expect(rl.allow('bob')).toBe(true);
74
+ expect(rl.allow('bob')).toBe(true);
75
+ expect(rl.allow('bob')).toBe(true);
76
+ expect(rl.allow('bob')).toBe(true);
77
+ expect(rl.allow('bob')).toBe(false);
78
+ rl.reset('bob');
79
+ expect(rl.allow('bob')).toBe(true);
80
+ });
81
+
82
  it('evicts stale keys once the tracked-key cap is exceeded', () => {
83
  let now = 0;
84
  const rl = new RateLimiter(5, 1_000, () => now);