From 8e43674218f4f8181161fd530671088517cd7f11 Mon Sep 17 00:00:00 2001 From: Andrii Oriekhov Date: Wed, 4 Nov 2026 16:21:45 +0200 Subject: [PATCH 1/2] Allow plain-HTTP LAN hosts via LAN_HOSTS LAN_HOSTS (comma-separated host:port, e.g. 192.168.0.162:3300) adds those hosts to the accepted Host list and gives requests on them a session cookie without Secure, since no HTTPS is in front of them. Unset, behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/main.ts | 3 +++ src/web.ts | 14 ++++++++++-- test/web.test.ts | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/main.ts b/src/main.ts index 8352d9f..581f03f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -253,6 +253,9 @@ const server = web(repo, { mode, release: process.env.RELEASE_SHA ?? 'local', publicOrigin: process.env.PUBLIC_ORIGIN, + lanHosts: process.env.LAN_HOSTS?.split(',') + .map((host) => host.trim()) + .filter(Boolean), demoLogin, monobankJarsExcluded: process.env.MONOBANK_INCLUDE_JARS === 'false', }); diff --git a/src/web.ts b/src/web.ts index 3da66d6..741d9db 100644 --- a/src/web.ts +++ b/src/web.ts @@ -163,6 +163,12 @@ export type WebConfig = { // `seedOwners` before the server listens — see src/auth.ts. release: string; publicOrigin?: string; + /** + * Hosts (host:port) the dashboard is reached at over plain HTTP on a home + * network, e.g. 192.168.0.162:3300. Accepted as a Host, and given a + * non-Secure session cookie, since no HTTPS is in front of them. + */ + lanHosts?: string[]; monobankJarsExcluded?: boolean; telegram?: TelegramClarifications; classifierFor?: (owner: Owner) => Promise; @@ -458,6 +464,7 @@ export function web( `127.0.0.1:${config.port}`, `localhost:${config.port}`, ...(publicHost ? [publicHost] : []), + ...(config.lanHosts ?? []), ].includes(req.headers.host ?? '') ) { json(403, { error: 'invalid_host', requestId }); @@ -529,8 +536,11 @@ export function web( res.end(content); }; // The cookie is marked Secure wherever HTTPS is known to be in front: - // always for the real workspace, and for a demo served at an origin. - const secure = config.mode !== 'demo' || Boolean(publicHost); + // always for the real workspace, and for a demo served at an origin — + // except on a LAN host, which is plain HTTP and would drop the cookie. + const secure = + (config.mode !== 'demo' || Boolean(publicHost)) && + !config.lanHosts?.includes(req.headers.host ?? ''); const token = sessionToken(req); const session = selfSignedIn ? demoSession diff --git a/test/web.test.ts b/test/web.test.ts index 4578c0b..c9fa264 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -169,6 +169,62 @@ test('HTTP flow: CSRF, duplicate import, classification, escaping, exact summary } }); +test('LAN hosts are accepted over plain HTTP with a non-Secure cookie', async () => { + const db = memoryDatabase(); + await migrate(db); + await seedTestOwners(db); + const config: WebConfig = { + port: 0, + mode: 'postgres', + release: 'test', + lanHosts: ['192.168.0.162:3300'], + }; + const server = web(new Repository(db), config, () => {}); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + config.port = (server.address() as AddressInfo).port; + const base = `http://127.0.0.1:${config.port}`; + const owner = TEST_OWNERS[0]!; + const login = (host: string) => + new Promise<{ status: number; cookie: string }>((resolve, reject) => { + const req = request( + base + '/api/login', + { + method: 'POST', + headers: { + host, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + (res) => { + res.resume(); + resolve({ + status: res.statusCode!, + cookie: String(res.headers['set-cookie'] ?? ''), + }); + }, + ); + req.on('error', reject); + req.end( + new URLSearchParams({ + email: owner.email, + password: owner.password, + }).toString(), + ); + }); + try { + const lan = await login('192.168.0.162:3300'); + assert.equal(lan.status, 200); + assert.equal(lan.cookie.includes('Secure'), false); + const local = await login(`127.0.0.1:${config.port}`); + assert.equal(local.status, 200); + assert.equal(local.cookie.includes('Secure'), true); + assert.equal((await login('192.168.0.99:3300')).status, 403); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + await db.close(); + } +}); + test('PostgreSQL mode requires credentials and enforces owner writes', async () => { const db = memoryDatabase(); await migrate(db); From a9709e778cf608662cfcc24ca1bcbfacc7d11794 Mon Sep 17 00:00:00 2001 From: Andrii Oriekhov Date: Wed, 4 Nov 2026 16:35:14 +0200 Subject: [PATCH 2/2] docs(config): describe LAN_HOSTS in app.env.example Co-Authored-By: Claude Opus 5.5 --- deploy/config-examples/app.env.example | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/deploy/config-examples/app.env.example b/deploy/config-examples/app.env.example index ae549d6..3d43273 100644 --- a/deploy/config-examples/app.env.example +++ b/deploy/config-examples/app.env.example @@ -72,6 +72,14 @@ MEMBER_2_PASSWORD=change-me # with a bank application but no PUBLIC_ORIGIN. PUBLIC_ORIGIN=https://finances.example.com +# Optional. Comma-separated host:port addresses the dashboard is also reached at +# over plain HTTP on a home network, with no proxy in front. They are accepted +# as a Host header, and sign-in through them gets a session cookie without +# Secure, which a browser would otherwise drop over HTTP. The password and the +# session then cross the network unencrypted, so use it only on a trusted LAN, +# and publish the port beyond loopback in compose.yaml. +#LAN_HOSTS=192.168.1.10:3300 + # ---- Telegram (shared by the dashboard and the worker) ----------------------- # Here rather than in telegram.env because the dashboard also sends questions # when a signed-in member asks it to. The dashboard enables Telegram only when