redweb 0.13.2 → 0.13.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import express, { type ErrorRequestHandler } from 'express';
2
2
  import { mkdirSync } from 'node:fs';
3
+ import type { IncomingMessage } from 'node:http';
3
4
  import { dirname, resolve } from 'node:path';
4
5
  import { page, start, type LivePageRequestContext } from 'redweb';
5
6
  import { DashboardAuth, sessionToken } from './auth';
@@ -11,6 +12,23 @@ export interface DashboardOptions { port?: number; database?: string; origin?: s
11
12
 
12
13
  export function databasePath() { return resolve(process.env.DASHBOARD_DATABASE ?? 'data/dashboard.sqlite'); }
13
14
 
15
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
16
+
17
+ /** Development is loopback-only, so its equivalent browser hostnames share one trust boundary. */
18
+ export function allowsDashboardOrigin(candidate: string | undefined, expected: string, configured: boolean, host?: string) {
19
+ if (!candidate) return false;
20
+ if (configured) return candidate === expected;
21
+ if (!host || /[\/@?#\\]/.test(host)) return false;
22
+ try {
23
+ const actual = new URL(candidate);
24
+ const local = new URL(expected);
25
+ const target = new URL(`http://${host}`);
26
+ return actual.origin === candidate && actual.protocol === 'http:'
27
+ && actual.origin === target.origin && actual.port === local.port
28
+ && LOOPBACK_HOSTS.has(actual.hostname);
29
+ } catch { return false; }
30
+ }
31
+
14
32
  export function createApp(options: DashboardOptions = {}) {
15
33
  const port = options.port ?? Number(process.env.PORT ?? 8181);
16
34
  const configuredOrigin = options.origin ?? process.env.DASHBOARD_ORIGIN;
@@ -32,6 +50,8 @@ export function createApp(options: DashboardOptions = {}) {
32
50
  };
33
51
  app.use(invalidBody);
34
52
  const origin = () => configuredOrigin ?? `http://127.0.0.1:${(server.server.address() as { port: number }).port}`;
53
+ const allowsOrigin = (candidate: string | undefined, request: IncomingMessage) =>
54
+ allowsDashboardOrigin(candidate, origin(), Boolean(configuredOrigin), request.headers.host);
35
55
 
36
56
  @page('/login', { live: false, css: 'app.css', head: { title: 'Sign in · Your cards' } })
37
57
  class Login {
@@ -57,10 +77,10 @@ export function createApp(options: DashboardOptions = {}) {
57
77
  }
58
78
  }
59
79
 
60
- auth.mount(app, origin, account => server.revoke(account));
80
+ auth.mount(app, origin, allowsOrigin, account => server.revoke(account));
61
81
  const server = start([Login, Dashboard], {
62
82
  server: app, port, bind: configuredOrigin ? '0.0.0.0' : '127.0.0.1', logger: null, templateRoot: __dirname,
63
- origins: value => value === origin(),
83
+ origins: allowsOrigin,
64
84
  authenticate: request => request.method === 'GET' && request.url?.split('?')[0] === '/login'
65
85
  ? true : store.session(sessionToken(request.headers.cookie))?.account,
66
86
  });
@@ -26,8 +26,10 @@ export class DashboardAuth {
26
26
  private closed = false;
27
27
  private readonly attempts = new Map<string, { count: number; expires: number }>();
28
28
 
29
- constructor(private readonly store: DashboardStore, private readonly ttlMs = 3600000) {
29
+ constructor(private readonly store: DashboardStore, private readonly ttlMs = 3600000,
30
+ private readonly attemptWindowMs = 60000) {
30
31
  if (!Number.isInteger(ttlMs) || ttlMs < 100 || ttlMs > 86400000) throw new RangeError('Invalid session lifetime.');
32
+ if (!Number.isInteger(attemptWindowMs) || attemptWindowMs < 20 || attemptWindowMs > 60000) throw new RangeError('Invalid login attempt window.');
31
33
  }
32
34
 
33
35
  async login(ip: string, account: unknown, password: unknown): Promise<string | undefined> {
@@ -37,7 +39,7 @@ export class DashboardAuth {
37
39
  let attempt = this.attempts.get(ip);
38
40
  if (!attempt) {
39
41
  if (this.attempts.size >= 1024) return undefined;
40
- attempt = { count: 0, expires: now + 60000 };
42
+ attempt = { count: 0, expires: now + this.attemptWindowMs };
41
43
  this.attempts.set(ip, attempt);
42
44
  }
43
45
  if (++attempt.count > 10 || this.active >= 4) return undefined;
@@ -55,12 +57,12 @@ export class DashboardAuth {
55
57
 
56
58
  close() { this.closed = true; this.attempts.clear(); }
57
59
 
58
- mount(app: Application, origin: () => string, revoke: (account: string) => Promise<unknown>) {
60
+ mount(app: Application, origin: () => string, allowsOrigin: (candidate: string | undefined, request: Request) => boolean, revoke: (account: string) => Promise<unknown>) {
59
61
  const cookie = (token: string) => `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${token ? Math.ceil(this.ttlMs / 1000) : 0}${origin().startsWith('https:') ? '; Secure' : ''}`;
60
62
  const post = (route: string, handler: (request: Request, response: Response) => Promise<void>) => {
61
63
  app.post(route, (request, response) => {
62
64
  response.set('Cache-Control', 'private, no-store');
63
- if (request.get('origin') !== origin()) { response.status(403).send('This form must be submitted from this site.'); return; }
65
+ if (!allowsOrigin(request.get('origin'), request)) { response.status(403).send('This form must be submitted from this site.'); return; }
64
66
  void handler(request, response).catch(() => response.status(503).send('Unable to complete the request. Try again later.'));
65
67
  });
66
68
  };
@@ -3,15 +3,15 @@ const { test } = require('node:test');
3
3
  const { DashboardStore } = require('../dist/store');
4
4
  const { DashboardAuth, credentials } = require('../dist/auth');
5
5
 
6
- test('login admission reopens after the real one-minute window, without clock mocks', { timeout: 65000 }, async t => {
6
+ test('login admission reopens after its configured short real window', async t => {
7
7
  const store = new DashboardStore(':memory:');
8
- const auth = new DashboardAuth(store);
8
+ const auth = new DashboardAuth(store, 3600000, 250);
9
9
  t.after(() => { auth.close(); store.close(); });
10
10
  const password = 'test-only-login-window-password';
11
11
  store.provision('alice', await credentials(password));
12
- for (let attempt = 0; attempt < 10; attempt++) assert.equal(await auth.login('same-peer', 'invalid', password), undefined);
12
+ for (let attempt = 0; attempt < 10; attempt++) assert.equal(await auth.login('same-peer', 'invalid', 'short'), undefined);
13
13
  assert.equal(await auth.login('same-peer', 'alice', password), undefined);
14
- await new Promise(resolve => setTimeout(resolve, 60010));
14
+ await new Promise(resolve => setTimeout(resolve, 275));
15
15
  const token = await auth.login('same-peer', 'alice', password);
16
16
  assert.equal(store.session(token).account, 'alice');
17
17
  });
@@ -4,5 +4,5 @@
4
4
  The browser button invokes only the decorated `increment` action; it does not supply the new count.
5
5
  Open two tabs to check the broadcast. State is in memory and resets when the server restarts.
6
6
 
7
- `<output>{this.count}</output>` updates automatically because the page reads decorated state during rendering.
7
+ `{this.count}` updates automatically because the page reads decorated state during rendering; no wrapper element is required.
8
8
  No repeated binding name or browser-side state is needed. State changes are assignment-driven; render methods must not modify state.
@@ -6,10 +6,10 @@ test('one server action updates both visitors', { timeout: 10000 }, async t => {
6
6
  const origin = await listen(t);
7
7
  const first = await live(t, origin);
8
8
  const second = await live(t, origin);
9
- await first.patch(patch => patch.html.includes('<output>0</output>'));
10
- await second.patch(patch => patch.html.includes('<output>0</output>'));
9
+ await first.patch(patch => patch.html.includes('Count 0'));
10
+ await second.patch(patch => patch.html.includes('Count 0'));
11
11
  first.action('increment');
12
- await first.patch(patch => patch.html.includes('<output>1</output>'));
13
- await second.patch(patch => patch.html.includes('<output>1</output>'));
14
- assert.match(await (await fetch(origin)).text(), /<output>1<\/output>/);
12
+ await first.patch(patch => patch.html.includes('Count 1'));
13
+ await second.patch(patch => patch.html.includes('Count 1'));
14
+ assert.match(await (await fetch(origin)).text(), /Count 1/);
15
15
  });
@@ -14,7 +14,7 @@ export class CounterPage {
14
14
  <h1>A counter owned by the server</h1>
15
15
  <p>Open this page in two tabs. Either button updates both.</p>
16
16
  <button rw-click="increment">
17
- Count <output>{this.count}</output>
17
+ Count {this.count}
18
18
  </button>
19
19
  </main>
20
20
  );