From 3dcaf57ffd028ac247c27129815b0598dcdff45b Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 07:55:26 +0200 Subject: [PATCH 01/11] feat(peers): peersOfOwner(userId) helper (direct peers.user_id lookup) --- src/services/peers.js | 9 +++++++++ tests/peers_of_owner.test.js | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 tests/peers_of_owner.test.js diff --git a/src/services/peers.js b/src/services/peers.js index 33fe41ab..75cf2a3f 100644 --- a/src/services/peers.js +++ b/src/services/peers.js @@ -711,6 +711,14 @@ async function batch(action, ids) { return ids.length; } +/** + * Get all peer IDs owned by a user + */ +function peersOfOwner(userId) { + if (userId == null) return []; + return getDb().prepare('SELECT id FROM peers WHERE user_id = ?').all(userId).map(r => r.id); +} + module.exports = { getAll, getById, @@ -724,4 +732,5 @@ module.exports = { batch, setHostname, markHostnamesStale, + peersOfOwner, }; diff --git a/tests/peers_of_owner.test.js b/tests/peers_of_owner.test.js new file mode 100644 index 00000000..c1ad140f --- /dev/null +++ b/tests/peers_of_owner.test.js @@ -0,0 +1,23 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown } = require('./helpers/setup'); +let peers, getDb; +beforeEach(async () => { await setup(); peers = require('../src/services/peers'); getDb = require('../src/db/connection').getDb; }); +afterEach(teardown); +function seedUser(n){ return getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES (?,?,'admin')").run(n,'x').lastInsertRowid; } +function seedPeer(name, uid){ return getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES (?,?,?,1,'regular',?)").run(name,name+'k',name+'/32',uid).lastInsertRowid; } + +test('peersOfOwner returns only the owner\'s peer ids', () => { + const u1 = seedUser('o1'), u2 = seedUser('o2'); + const a = seedPeer('10.8.0.1', u1); const b = seedPeer('10.8.0.2', u1); seedPeer('10.8.0.3', u2); + const ids = peers.peersOfOwner(u1).sort((x,y)=>x-y); + assert.deepEqual(ids, [a,b].sort((x,y)=>x-y)); +}); +test('peersOfOwner returns [] for an owner with no peers and for null', () => { + const u = seedUser('lonely'); + assert.deepEqual(peers.peersOfOwner(u), []); + assert.deepEqual(peers.peersOfOwner(null), []); +}); From c76a85bc7910980e2ed8c9ec2fbb0455b58389d0 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 07:57:51 +0200 Subject: [PATCH 02/11] feat(portal): portal.trust_owner_mapping setting (default off, config + PUT) --- src/routes/api/settings/portal.js | 12 ++++++++---- src/services/portalConfig.js | 4 +++- tests/portal_trust_setting.test.js | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 tests/portal_trust_setting.test.js diff --git a/src/routes/api/settings/portal.js b/src/routes/api/settings/portal.js index aaf20752..a91afb2f 100644 --- a/src/routes/api/settings/portal.js +++ b/src/routes/api/settings/portal.js @@ -1,7 +1,7 @@ 'use strict'; -// Portal settings cluster — master switch + per-widget toggles + public host. -// Keys: portal.enabled, portal.widget.{device,traffic,services,pihole}, +// Portal settings cluster — master switch + per-widget toggles + public host + owner mapping. +// Keys: portal.enabled, portal.widget.{device,traffic,services,pihole}, portal.trust_owner_mapping, // portal.base_domain, portal.prefix const { Router } = require('express'); @@ -30,11 +30,11 @@ router.get('/portal', (req, res) => { }); /** - * PUT /api/v1/settings/portal — Update portal master switch + widget toggles + host + * PUT /api/v1/settings/portal — Update portal master switch + widget toggles + host + owner mapping * * Accepts: * { enabled: bool, widgets: { device: bool, traffic: bool, services: bool, pihole: bool }, - * base_domain: string, prefix: string } + * trust_owner_mapping: bool, base_domain: string, prefix: string } */ router.put('/portal', (req, res) => { try { @@ -57,6 +57,10 @@ router.put('/portal', (req, res) => { settings.set('portal.widget.pihole', widgets.pihole ? '1' : '0'); } + if (body.trust_owner_mapping !== undefined) { + settings.set('portal.trust_owner_mapping', body.trust_owner_mapping ? '1' : '0'); + } + // Host change (base_domain + prefix committed together). if (body.base_domain !== undefined || body.prefix !== undefined) { const base = String(body.base_domain !== undefined ? body.base_domain : settings.get('portal.base_domain', '') || '').trim().toLowerCase(); diff --git a/src/services/portalConfig.js b/src/services/portalConfig.js index 53511fcb..e3ba8786 100644 --- a/src/services/portalConfig.js +++ b/src/services/portalConfig.js @@ -5,8 +5,9 @@ const settings = require('./settings'); /** * Returns the current VPN landing portal configuration derived from settings. * All values default to enabled ('1') unless explicitly set to '0'. + * Note: trustOwnerMapping defaults to disabled ('0') — unlike the widgets. * - * @returns {{ enabled: boolean, widgets: { device: boolean, traffic: boolean, services: boolean, pihole: boolean } }} + * @returns {{ enabled: boolean, widgets: { device: boolean, traffic: boolean, services: boolean, pihole: boolean }, trustOwnerMapping: boolean }} */ const on = (key) => settings.get(key, '1') !== '0'; @@ -19,6 +20,7 @@ function portalConfig() { services: on('portal.widget.services'), pihole: on('portal.widget.pihole'), }, + trustOwnerMapping: settings.get('portal.trust_owner_mapping', '0') !== '0', }; } diff --git a/tests/portal_trust_setting.test.js b/tests/portal_trust_setting.test.js new file mode 100644 index 00000000..1a2b3ff5 --- /dev/null +++ b/tests/portal_trust_setting.test.js @@ -0,0 +1,19 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup'); +let portalConfig, settings; +beforeEach(async () => { await setup(); portalConfig = require('../src/services/portalConfig'); settings = require('../src/services/settings'); }); +afterEach(teardown); + +test('trustOwnerMapping defaults to false (key unwritten)', () => { + assert.equal(portalConfig().trustOwnerMapping, false); +}); +test('PUT persists trust_owner_mapping=true and config reflects it', async () => { + const agent = getAgent(); const csrf = getCsrf(); + await agent.put('/api/v1/settings/portal').set('X-CSRF-Token', csrf).send({ trust_owner_mapping: true }).expect(200); + assert.equal(settings.get('portal.trust_owner_mapping'), '1'); + assert.equal(portalConfig().trustOwnerMapping, true); +}); From ef31010d18bd4824b7ccefd1e8c6e68c985c70fe Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:00:50 +0200 Subject: [PATCH 03/11] feat(portal): portalOwner seam (session>device precedence, no IDOR) --- src/middleware/portalOwner.js | 40 +++++++++++++++++++++++ src/routes/index.js | 3 +- tests/portal_owner_middleware.test.js | 47 +++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/middleware/portalOwner.js create mode 100644 tests/portal_owner_middleware.test.js diff --git a/src/middleware/portalOwner.js b/src/middleware/portalOwner.js new file mode 100644 index 00000000..eeac97ab --- /dev/null +++ b/src/middleware/portalOwner.js @@ -0,0 +1,40 @@ +// src/middleware/portalOwner.js +'use strict'; +const { getDb } = require('../db/connection'); +const settings = require('../services/settings'); + +function trustEnabled() { + return settings.get('portal.trust_owner_mapping', '0') !== '0'; +} + +/** Owner (users.id) of a peer via the TP1 peers.user_id column, or null. */ +function ownerOfPeer(peerId) { + if (peerId == null) return null; + const row = getDb().prepare('SELECT user_id FROM peers WHERE id = ?').get(peerId); + return row && row.user_id != null ? row.user_id : null; +} + +/** + * Resolve the portal OWNER on top of portalIdentity (which set req.portalPeerId). + * Precedence: an authenticated session ALWAYS wins over device-owner trust. + * Device-owner trust only applies when there is no session AND the admin enabled it. + * The owner id never comes from the request body/query/header (no IDOR). + */ +function portalOwner(req, _res, next) { + req.portalLoggedIn = !!(req.session && req.session.userId); + if (req.portalLoggedIn) { + req.portalOwnerId = req.session.userId; + req.portalOwnerSource = 'session'; + } else if (trustEnabled() && req.portalPeerId != null) { + const uid = ownerOfPeer(req.portalPeerId); + req.portalOwnerId = uid; + req.portalOwnerSource = uid != null ? 'device' : null; + } else { + req.portalOwnerId = null; + req.portalOwnerSource = null; + } + next(); +} + +module.exports = portalOwner; +module.exports.ownerOfPeer = ownerOfPeer; diff --git a/src/routes/index.js b/src/routes/index.js index 44525992..08210217 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -298,7 +298,8 @@ router.get('/api/v1/events', requireAuth, require('./api/events')); // ─── Portal API (source-IP identity, no session auth) ────────── const portalIdentity = require('../middleware/portalIdentity'); -router.use('/api/v1/portal', apiLimiter, portalIdentity, require('./api/portal')); +const portalOwner = require('../middleware/portalOwner'); +router.use('/api/v1/portal', apiLimiter, portalIdentity, portalOwner, require('./api/portal')); // ─── Portal page (source-IP identity, no session auth) ───────── const portalConfig = require('../services/portalConfig'); diff --git a/tests/portal_owner_middleware.test.js b/tests/portal_owner_middleware.test.js new file mode 100644 index 00000000..89f4f9ee --- /dev/null +++ b/tests/portal_owner_middleware.test.js @@ -0,0 +1,47 @@ +// tests/portal_owner_middleware.test.js +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown } = require('./helpers/setup'); +let portalOwner, settings, getDb; +beforeEach(async () => { await setup(); portalOwner = require('../src/middleware/portalOwner'); settings = require('../src/services/settings'); getDb = require('../src/db/connection').getDb; }); +afterEach(teardown); +function seedUser(n){ return getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES (?,?,'admin')").run(n,'x').lastInsertRowid; } +function seedPeer(name, uid){ return getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES (?,?,?,1,'regular',?)").run(name,name+'k',name+'/32',uid).lastInsertRowid; } +function run(req){ let nexted=false; portalOwner(req, {}, ()=>{ nexted=true; }); assert.ok(nexted,'must call next'); return req; } + +test('logged in → owner=session.userId, source session (device irrelevant)', () => { + const u = seedUser('alice'); const other = seedUser('bob'); const dev = seedPeer('10.8.0.9', other); + const req = run({ session:{ userId:u }, portalPeerId: dev }); + assert.equal(req.portalOwnerId, u); // session wins over device owner 'bob' + assert.equal(req.portalOwnerSource, 'session'); + assert.equal(req.portalLoggedIn, true); +}); +test('zero-login + trust OFF → owner null', () => { + const u = seedUser('carol'); const dev = seedPeer('10.8.0.10', u); + const req = run({ session:{}, portalPeerId: dev }); + assert.equal(req.portalOwnerId, null); + assert.equal(req.portalOwnerSource, null); + assert.equal(req.portalLoggedIn, false); +}); +test('zero-login + trust ON + device has owner → owner=device.user_id, source device', () => { + settings.set('portal.trust_owner_mapping','1'); + const u = seedUser('dave'); const dev = seedPeer('10.8.0.11', u); + const req = run({ session:{}, portalPeerId: dev }); + assert.equal(req.portalOwnerId, u); + assert.equal(req.portalOwnerSource, 'device'); +}); +test('zero-login + trust ON + device has NO owner → owner null', () => { + settings.set('portal.trust_owner_mapping','1'); + const dev = seedPeer('10.8.0.12', null); + const req = run({ session:{}, portalPeerId: dev }); + assert.equal(req.portalOwnerId, null); + assert.equal(req.portalOwnerSource, null); +}); +test('zero-login + trust ON + unidentified device → owner null', () => { + settings.set('portal.trust_owner_mapping','1'); + const req = run({ session:{}, portalPeerId: null }); + assert.equal(req.portalOwnerId, null); +}); From 434fe738bcd61e08a5ec6cef31d6a751a7c319c1 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:06:47 +0200 Subject: [PATCH 04/11] feat(portal): GET /pihole/owner per-owner aggregate (no IDOR, whitelisted) --- src/routes/api/portal.js | 23 ++++++++++ tests/pihole_portal_owner.test.js | 76 +++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 tests/pihole_portal_owner.test.js diff --git a/src/routes/api/portal.js b/src/routes/api/portal.js index 42e2d5b9..27ba4ca1 100644 --- a/src/routes/api/portal.js +++ b/src/routes/api/portal.js @@ -163,4 +163,27 @@ router.get('/pihole', (req, res) => { } }); +router.get('/pihole/owner', (req, res) => { + try { + if (!portalConfig().widgets.pihole) return res.status(404).json({ ok: false }); + const cache = pihole.getCache(); + if (!license.hasFeature('pihole_integration') || !cache.instances || cache.instances.length === 0) { + return res.json({ ok: true, data: null, reason: 'unavailable' }); + } + if (req.portalOwnerId == null) return res.json({ ok: true, data: null, reason: 'no_owner' }); + if (cache.attribution === 'collapsed') return res.json({ ok: true, data: null, reason: 'collapsed' }); + const ownerPeerIds = new Set(peers.peersOfOwner(req.portalOwnerId)); // owner id NEVER from req body/query + let allowed = 0, blocked = 0; const seen = new Set(); + for (const c of (cache.topClients || [])) if (ownerPeerIds.has(c.peerId)) { allowed += c.count; seen.add(c.peerId); } + for (const c of (cache.topClientsBlocked || [])) if (ownerPeerIds.has(c.peerId)) { blocked += c.count; seen.add(c.peerId); } + if (seen.size === 0) return res.json({ ok: true, data: null, reason: 'no_data' }); + const total = allowed + blocked; + const blockedPct = total ? Math.round((blocked / total) * 100) : 0; + res.json({ ok: true, data: { total, blocked, allowed, blockedPct, deviceCount: seen.size, asOf: cache.lastSyncAt } }); + } catch (err) { + logger.error({ error: err.message }, 'portal /pihole/owner failed'); + return res.json({ ok: true, data: null, reason: 'unavailable' }); + } +}); + module.exports = router; diff --git a/tests/pihole_portal_owner.test.js b/tests/pihole_portal_owner.test.js new file mode 100644 index 00000000..9a41dc59 --- /dev/null +++ b/tests/pihole_portal_owner.test.js @@ -0,0 +1,76 @@ +// tests/pihole_portal_owner.test.js +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const supertest = require('supertest'); +// setup MUST be required before config/default so NODE_ENV=test is set before +// config/default.js is evaluated (it throws on missing GC_SECRET in non-test mode). +const { setup, teardown, getAgent } = require('./helpers/setup'); +const config = require('../config/default'); +const HOME_HOST = `home.${config.dns.domain}`; +let app, getDb, pihole, license, adminId, p1, p2, foreignPeer, foreignUser; + +beforeEach(async () => { + await setup(); + app = require('../src/app').createApp(); + getDb = require('../src/db/connection').getDb; + pihole = require('../src/services/pihole'); + license = require('../src/services/license'); + license.hasFeature = () => true; + adminId = getDb().prepare("SELECT id FROM users WHERE username='admin'").get().id; // getAgent() is this user + p1 = getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES ('m1','k1','10.8.0.5/32',1,'regular',?)").run(adminId).lastInsertRowid; + p2 = getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES ('m2','k2','10.8.0.6/32',1,'regular',?)").run(adminId).lastInsertRowid; + foreignUser = getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES ('bob','x','admin')").run().lastInsertRowid; + foreignPeer = getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES ('b1','k3','10.8.0.50/32',1,'regular',?)").run(foreignUser).lastInsertRowid; +}); +afterEach(() => { teardown(); }); + +function cacheWith(extra){ return Object.assign({ instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt:1750000000000, topClients:[], topClientsBlocked:[] }, extra); } + +test('logged-in owner → aggregate over OWN devices only (+ deviceCount), no foreign device', async () => { + pihole.getCache = () => cacheWith({ + topClients: [{ ip:'10.8.0.5', count:500, peerId:p1, peerName:'m1' }, { ip:'10.8.0.6', count:300, peerId:p2, peerName:'m2' }, { ip:'10.8.0.50', count:999, peerId:foreignPeer, peerName:'b1' }], + topClientsBlocked: [{ ip:'10.8.0.5', count:50, peerId:p1, peerName:'m1' }, { ip:'10.8.0.50', count:888, peerId:foreignPeer, peerName:'b1' }], + }); + const r = await getAgent().get('/api/v1/portal/pihole/owner').expect(200); + assert.equal(r.body.data.allowed, 800); // 500+300, NOT 999 + assert.equal(r.body.data.blocked, 50); // 50, NOT 888 + assert.equal(r.body.data.total, 850); + assert.equal(r.body.data.deviceCount, 2); // p1 + p2 + assert.deepEqual(Object.keys(r.body.data).sort(), ['allowed','asOf','blocked','blockedPct','deviceCount','total']); +}); +test('IDOR: a foreign owner_id in body/query is ignored', async () => { + pihole.getCache = () => cacheWith({ topClients:[{ ip:'10.8.0.50', count:999, peerId:foreignPeer, peerName:'b1' }] }); + const r = await getAgent().get('/api/v1/portal/pihole/owner?user_id=' + foreignUser).send({ user_id: foreignUser }).expect(200); + // admin owns p1/p2 which are NOT in the cache here → no_data, never the foreign 999 + assert.equal(r.body.data, null); + assert.equal(r.body.reason, 'no_data'); +}); +test('owner with devices but none in lists → no_data', async () => { + pihole.getCache = () => cacheWith({}); + const r = await getAgent().get('/api/v1/portal/pihole/owner').expect(200); + assert.equal(r.body.data, null); assert.equal(r.body.reason, 'no_data'); +}); +test('not logged in, trust off → no_owner', async () => { + pihole.getCache = () => cacheWith({}); + const r = await supertest(app).get('/api/v1/portal/pihole/owner').set('Host', HOME_HOST).expect(200); + assert.equal(r.body.data, null); assert.equal(r.body.reason, 'no_owner'); +}); +test('collapsed → collapsed', async () => { + pihole.getCache = () => cacheWith({ attribution:'collapsed' }); + const r = await getAgent().get('/api/v1/portal/pihole/owner').expect(200); + assert.equal(r.body.data, null); assert.equal(r.body.reason, 'collapsed'); +}); +test('feature off → unavailable', async () => { + license.hasFeature = () => false; + const r = await getAgent().get('/api/v1/portal/pihole/owner').expect(200); + assert.equal(r.body.data, null); assert.equal(r.body.reason, 'unavailable'); +}); +test('logged-in but user deleted (peers user_id nulled) → no_data, not no_owner', async () => { + getDb().prepare('UPDATE peers SET user_id = NULL WHERE user_id = ?').run(adminId); // simulate TP1 cleanup + pihole.getCache = () => cacheWith({ topClients:[{ ip:'10.8.0.5', count:5, peerId:p1, peerName:'m1' }] }); + const r = await getAgent().get('/api/v1/portal/pihole/owner').expect(200); + assert.equal(r.body.data, null); assert.equal(r.body.reason, 'no_data'); +}); From 9c0c7a6bd41dfdbad0ee7edc93bae6c4e135031b Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:10:32 +0200 Subject: [PATCH 05/11] feat(portal): GET /pihole/household global summary (login-gated, whitelisted) --- src/routes/api/portal.js | 19 +++++++++++ tests/pihole_portal_household.test.js | 48 +++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 tests/pihole_portal_household.test.js diff --git a/src/routes/api/portal.js b/src/routes/api/portal.js index 27ba4ca1..2df595b1 100644 --- a/src/routes/api/portal.js +++ b/src/routes/api/portal.js @@ -186,4 +186,23 @@ router.get('/pihole/owner', (req, res) => { } }); +router.get('/pihole/household', (req, res) => { + try { + if (!portalConfig().widgets.pihole) return res.status(404).json({ ok: false }); + const cache = pihole.getCache(); + if (!license.hasFeature('pihole_integration') || !cache.instances || cache.instances.length === 0) { + return res.json({ ok: true, data: null, reason: 'unavailable' }); + } + if (!req.portalLoggedIn) return res.json({ ok: true, data: null, reason: 'login_required' }); // trust switch never relaxes household + const s = cache.summary; + if (!s || !s.queries) return res.json({ ok: true, data: null, reason: 'unavailable' }); + const total = s.queries.total || 0, blocked = s.queries.blocked || 0; + const blockedPct = total ? Math.round((blocked / total) * 100) : 0; + res.json({ ok: true, data: { total, blocked, blockedPct, activeClients: (s.clients && s.clients.active != null) ? s.clients.active : null, asOf: cache.lastSyncAt } }); + } catch (err) { + logger.error({ error: err.message }, 'portal /pihole/household failed'); + return res.json({ ok: true, data: null, reason: 'unavailable' }); + } +}); + module.exports = router; diff --git a/tests/pihole_portal_household.test.js b/tests/pihole_portal_household.test.js new file mode 100644 index 00000000..ffa3be25 --- /dev/null +++ b/tests/pihole_portal_household.test.js @@ -0,0 +1,48 @@ +// tests/pihole_portal_household.test.js +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const supertest = require('supertest'); +// setup MUST be required before config/default so NODE_ENV=test is set before +// config/default.js is evaluated (it throws on missing GC_SECRET in non-test mode). +const { setup, teardown, getAgent } = require('./helpers/setup'); +const config = require('../config/default'); +const HOME_HOST = `home.${config.dns.domain}`; +let app, getDb, pihole, license; +beforeEach(async () => { + await setup(); + app = require('../src/app').createApp(); + getDb = require('../src/db/connection').getDb; + pihole = require('../src/services/pihole'); license = require('../src/services/license'); + license.hasFeature = () => true; +}); +afterEach(() => { teardown(); }); +function cacheWith(summary){ return { instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt:1750000000000, summary }; } + +test('logged-in → global summary numbers', async () => { + pihole.getCache = () => cacheWith({ queries:{ total:1000, blocked:250, percent:25 }, clients:{ active:7 } }); + const r = await getAgent().get('/api/v1/portal/pihole/household').expect(200); + assert.equal(r.body.data.total, 1000); + assert.equal(r.body.data.blocked, 250); + assert.equal(r.body.data.blockedPct, 25); + assert.equal(r.body.data.activeClients, 7); + assert.deepEqual(Object.keys(r.body.data).sort(), ['activeClients','asOf','blocked','blockedPct','total']); +}); +test('not logged in → login_required (even with trust on)', async () => { + require('../src/services/settings').set('portal.trust_owner_mapping','1'); + pihole.getCache = () => cacheWith({ queries:{ total:1000, blocked:250 }, clients:{ active:7 } }); + const r = await supertest(app).get('/api/v1/portal/pihole/household').set('Host', HOME_HOST).expect(200); + assert.equal(r.body.data, null); assert.equal(r.body.reason, 'login_required'); +}); +test('summary null (pre first sync) → unavailable', async () => { + pihole.getCache = () => cacheWith(null); + const r = await getAgent().get('/api/v1/portal/pihole/household').expect(200); + assert.equal(r.body.data, null); assert.equal(r.body.reason, 'unavailable'); +}); +test('feature off → unavailable', async () => { + license.hasFeature = () => false; + const r = await getAgent().get('/api/v1/portal/pihole/household').expect(200); + assert.equal(r.body.data, null); assert.equal(r.body.reason, 'unavailable'); +}); From b9486f12f05ed896bca03c426947ea3ef63cc997 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:19:01 +0200 Subject: [PATCH 06/11] =?UTF-8?q?test(portal):=20regression=20=E2=80=94=20?= =?UTF-8?q?owner/household=20never=20leak=20foreign=20or=20raw=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ole_portal_owner_household_no_leak.test.js | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/pihole_portal_owner_household_no_leak.test.js diff --git a/tests/pihole_portal_owner_household_no_leak.test.js b/tests/pihole_portal_owner_household_no_leak.test.js new file mode 100644 index 00000000..719b4079 --- /dev/null +++ b/tests/pihole_portal_owner_household_no_leak.test.js @@ -0,0 +1,65 @@ +// tests/pihole_portal_owner_household_no_leak.test.js +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); const path = require('node:path'); +const { setup, teardown, getAgent } = require('./helpers/setup'); +let getDb, pihole, license, adminId, p1, foreignPeer, foreignUser; +beforeEach(async () => { + await setup(); + getDb = require('../src/db/connection').getDb; + pihole = require('../src/services/pihole'); license = require('../src/services/license'); license.hasFeature = () => true; + adminId = getDb().prepare("SELECT id FROM users WHERE username='admin'").get().id; + p1 = getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES ('m1','k1','10.8.0.5/32',1,'regular',?)").run(adminId).lastInsertRowid; + foreignUser = getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES ('bob','x','admin')").run().lastInsertRowid; + foreignPeer = getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES ('b1','k3','10.8.0.50/32',1,'regular',?)").run(foreignUser).lastInsertRowid; +}); +afterEach(() => { teardown(); }); + +test('owner endpoint: response exposes only the aggregate, never foreign device or raw fields', async () => { + pihole.getCache = () => ({ instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt:1, + topClients:[{ ip:'10.8.0.5', count:100, peerId:p1, peerName:'m1' }, { ip:'10.8.0.50', count:999, peerId:foreignPeer, peerName:'b1' }], + topClientsBlocked:[{ ip:'10.8.0.5', count:10, peerId:p1, peerName:'m1' }] }); + const r = await getAgent().get('/api/v1/portal/pihole/owner').expect(200); + const raw = JSON.stringify(r.body); + assert.equal(r.body.data.allowed, 100); + // exhaustive key whitelist — cannot be gamed by field-name collisions + assert.deepEqual(Object.keys(r.body.data).sort(), ['allowed','asOf','blocked','blockedPct','deviceCount','total']); + assert.ok(!raw.includes('10.8.0.50') && !raw.includes('b1'), 'foreign device leaked'); + assert.ok(!/\bip\b|peerId|peerName|user_id|owner_name|topClients/.test(raw), 'raw field leaked: ' + raw); +}); +test('household endpoint: only the global aggregate, no client list', async () => { + pihole.getCache = () => ({ instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt:1, + summary:{ queries:{ total:9, blocked:3 }, clients:{ active:2 } }, + topClients:[{ ip:'10.8.0.50', count:999, peerId:foreignPeer, peerName:'b1' }] }); + const r = await getAgent().get('/api/v1/portal/pihole/household').expect(200); + const raw = JSON.stringify(r.body); + assert.equal(r.body.data.total, 9); + assert.deepEqual(Object.keys(r.body.data).sort(), ['activeClients','asOf','blocked','blockedPct','total']); + assert.ok(!/peerId|peerName|topClients|\bip\b|10\.8\.0\.50/.test(raw), 'raw field leaked: ' + raw); +}); +test('structural: owner+household handlers serialize only locally-built aggregates', () => { + const src = fs.readFileSync(path.join(__dirname,'..','src','routes','api','portal.js'),'utf8'); + for (const route of ['/pihole/owner','/pihole/household']) { + // String-based extraction (indexOf + brace-counting) — immune to regex-escaping bugs. + const start = src.indexOf("router.get('" + route + "'"); + assert.ok(start !== -1, route + ' handler not found'); + let depth = 0, i = start, end = -1; + while (i < src.length) { + const ch = src[i]; + if (ch === '{') depth++; + else if (ch === '}') { depth--; if (depth === 0) { end = i; break; } } + i++; + } + assert.ok(end !== -1, route + ' handler not terminated'); + const body = src.slice(start, end + 1); + // Regex LITERALS on the extracted string — single-escaped, verified to match real code. + assert.ok(!/res\.json\(\s*cache\s*\)/.test(body) && !/res\.json\(\s*\{[\s\S]*?\.\.\.\s*cache/.test(body), route + ' returns/spreads raw cache'); + // [^\n]* (same-line only) avoids false-positives from `res.json(...)` early-exit + // guards that appear in the body BEFORE the topClients loop lines. A real leak + // (e.g. `res.json({ data: cache.topClients })`) would be on one line → still caught. + assert.ok(!/res\.json\([^\n]*topClients/.test(body), route + ' serializes topClients'); + } +}); From 8f863ed648692449e822a9bc629404ddd6c4b050 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:25:06 +0200 Subject: [PATCH 07/11] feat(portal): admin trust_owner_mapping toggle + kiosk help text (3 themes) --- public/js/settings.js | 8 ++++++-- src/i18n/de.json | 2 ++ src/i18n/en.json | 2 ++ templates/aurora/pages/settings.njk | 5 +++++ templates/default/pages/settings.njk | 5 +++++ templates/pro/pages/settings.njk | 5 +++++ tests/portal_trust_settings_ui.test.js | 17 +++++++++++++++++ 7 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 tests/portal_trust_settings_ui.test.js diff --git a/public/js/settings.js b/public/js/settings.js index 1ed64275..13818f2c 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -1764,9 +1764,10 @@ var widgetTraffic = document.getElementById('portal-widget-traffic'); var widgetServices = document.getElementById('portal-widget-services'); var widgetPihole = document.getElementById('portal-widget-pihole'); + var trustToggle = document.getElementById('portal-trust-owner-mapping'); if (!enabledToggle) return; - [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole].forEach(function (el) { + [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole, trustToggle].forEach(function (el) { if (el) el.addEventListener('click', function () { el.classList.toggle('on'); el.dispatchEvent(new Event('change')); @@ -1786,12 +1787,13 @@ setToggle(widgetTraffic, d.widgets && d.widgets.traffic); setToggle(widgetServices, d.widgets && d.widgets.services); setToggle(widgetPihole, d.widgets && d.widgets.pihole); + setToggle(trustToggle, d.trustOwnerMapping); if (window.SettingsAutosave && SettingsAutosave.resync) SettingsAutosave.resync('portal'); }).catch(function (err) { console.error('Failed to load portal settings:', err); }); - var portalFields = [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole].filter(Boolean); + var portalFields = [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole, trustToggle].filter(Boolean); SettingsAutosave.bind({ cluster: 'portal', fields: portalFields, @@ -1803,6 +1805,7 @@ 'portal-widget-traffic': widgetTraffic ? widgetTraffic.classList.contains('on') : true, 'portal-widget-services': widgetServices ? widgetServices.classList.contains('on') : true, 'portal-widget-pihole': widgetPihole ? widgetPihole.classList.contains('on') : true, + 'portal-trust-owner-mapping': trustToggle ? trustToggle.classList.contains('on') : false, }; }, save: function () { @@ -1814,6 +1817,7 @@ services: widgetServices ? widgetServices.classList.contains('on') : true, pihole: widgetPihole ? widgetPihole.classList.contains('on') : true, }, + trust_owner_mapping: trustToggle ? trustToggle.classList.contains('on') : false, }); }, }); diff --git a/src/i18n/de.json b/src/i18n/de.json index dc93a422..00636aff 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -1980,6 +1980,8 @@ "settings.portal.widget_traffic": "Traffic-Diagramm", "settings.portal.widget_services": "Dienste", "settings.portal.widget_pihole": "Pi-hole-Widget", + "settings.portal.trust_owner_mapping": "Gerät→Besitzer-Vertrauen (pro-Besitzer ohne Login)", + "settings.portal.trust_owner_mapping_help": "Wenn aktiv, sehen nicht eingeloggte Nutzer auf Geräten mit zugeordnetem Besitzer dessen aggregierte DNS-Zahlen ohne Login. Nur aktivieren, wenn Geräte ausschließlich vom zugeordneten Besitzer genutzt werden.", "settings.portal.saved": "Portal-Einstellungen gespeichert", "settings.portal.host_not_verified": "Domain ist nicht verifiziert", "settings.portal.host_invalid_prefix": "Ungültiges Subdomain-Präfix", diff --git a/src/i18n/en.json b/src/i18n/en.json index 9a1a221b..1df1f305 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1980,6 +1980,8 @@ "settings.portal.widget_traffic": "Traffic chart", "settings.portal.widget_services": "Services", "settings.portal.widget_pihole": "Pi-hole widget", + "settings.portal.trust_owner_mapping": "Trust device→owner mapping (zero-login per-owner)", + "settings.portal.trust_owner_mapping_help": "When enabled, unauthenticated users on a device with an assigned owner see that owner's aggregated DNS counts without logging in. Enable only when devices are used exclusively by their assigned owner.", "settings.portal.saved": "Portal settings saved", "settings.portal.host_not_verified": "Domain is not verified", "settings.portal.host_invalid_prefix": "Invalid subdomain prefix", diff --git a/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk index 9e51a489..d897fc87 100644 --- a/templates/aurora/pages/settings.njk +++ b/templates/aurora/pages/settings.njk @@ -977,6 +977,11 @@ {{ t('settings.portal.widget_pihole') }}
+