Skip to content
Open
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
8 changes: 8 additions & 0 deletions deploy/config-examples/app.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Expand Down
14 changes: 12 additions & 2 deletions src/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Classifier>;
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions test/web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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<void>((resolve) => server.close(() => resolve()));
await db.close();
}
});

test('PostgreSQL mode requires credentials and enforces owner writes', async () => {
const db = memoryDatabase();
await migrate(db);
Expand Down