From c2751f2700e208ab810c6a328b17c029839a41f5 Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 19 May 2026 12:13:04 -0500 Subject: [PATCH] Restrict admin to local connections --- README.md | 3 ++ docker-compose.yml | 1 + public/sw.js | 12 +++++-- server/server.js | 78 ++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 89 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 013754a..edc8c90 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Required production environment: PORT=2354 HOST=0.0.0.0 APP_MODE=all +ADMIN_ALLOWED_HOSTS=admin.local,l484.local MEMBERSHIP_ENCRYPTION_KEY=<32+ random bytes> ACCESS_HMAC_KEY=<32+ random bytes> ACCESS_CONTROLLER_TOKEN= @@ -35,6 +36,8 @@ DEV_SEED_MEMBERS=false Keep `server/data` on a persistent volume. Do not deploy `.env.local`. +The admin UI, admin APIs, and controller card-scan endpoint are available only when the request comes from localhost, a private LAN IP, a `.local` hostname, or a hostname listed in `ADMIN_ALLOWED_HOSTS`. Public members can still use `/api/member/door/unlock` from the external site when their local membership secret verifies an active paid membership. + ## BTCPay Create a BTCPay webhook pointing at: diff --git a/docker-compose.yml b/docker-compose.yml index f601376..0e1e315 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: HOST: 0.0.0.0 PORT: 2354 APP_MODE: all + ADMIN_ALLOWED_HOSTS: ${ADMIN_ALLOWED_HOSTS:-} MEMBERSHIP_ENCRYPTION_KEY: ${MEMBERSHIP_ENCRYPTION_KEY:?Set a unique 64-character hex key} ACCESS_HMAC_KEY: ${ACCESS_HMAC_KEY:?Set a unique access HMAC key} ACCESS_CONTROLLER_TOKEN: ${ACCESS_CONTROLLER_TOKEN:-} diff --git a/public/sw.js b/public/sw.js index 10690a2..da96794 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'l484-pwa-v13' +const CACHE_NAME = 'l484-pwa-v14' const APP_SHELL = [ '/', '/manifest.webmanifest', @@ -25,11 +25,17 @@ self.addEventListener('fetch', (event) => { if (url.pathname.startsWith('/api/')) return if (event.request.mode === 'navigate') { + if (['/admin', '/edit'].includes(url.pathname)) { + event.respondWith(fetch(event.request).catch(() => Response.error())) + return + } event.respondWith( fetch(event.request) .then((response) => { - const clone = response.clone() - caches.open(CACHE_NAME).then((cache) => cache.put('/', clone)) + if (response.ok && response.type === 'basic') { + const clone = response.clone() + caches.open(CACHE_NAME).then((cache) => cache.put('/', clone)) + } return response }) .catch(async () => (await caches.match('/')) || (await caches.match(event.request)) || Response.error()), diff --git a/server/server.js b/server/server.js index 1cfb07c..840e964 100644 --- a/server/server.js +++ b/server/server.js @@ -15,6 +15,10 @@ const distDir = path.join(rootDir, 'dist') const port = Number(process.env.PORT || 3001) const host = process.env.HOST || '127.0.0.1' const appMode = ['public', 'admin', 'all'].includes(process.env.APP_MODE) ? process.env.APP_MODE : 'all' +const adminAllowedHosts = String(process.env.ADMIN_ALLOWED_HOSTS || '') + .split(',') + .map((value) => value.trim().toLowerCase()) + .filter(Boolean) const seedDevMembers = process.env.DEV_SEED_MEMBERS === 'true' const membershipMonthlyUsd = 350 const bitcoinFallbackUsd = 79592.095 @@ -81,6 +85,35 @@ const rateBuckets = new Map() const publicApiEnabled = () => appMode !== 'admin' const adminApiEnabled = () => appMode !== 'public' +const normalizeHost = (value) => { + const hostValue = String(value || '').split(',')[0].trim().toLowerCase() + if (hostValue.startsWith('[')) return hostValue.slice(1, hostValue.indexOf(']') > 0 ? hostValue.indexOf(']') : undefined) + return hostValue.split(':')[0] +} +const normalizeIp = (value) => String(value || '').trim().replace(/^::ffff:/, '') +const isPrivateIp = (value) => { + const ip = normalizeIp(value) + if (!ip) return false + if (ip === '::1' || ip === '127.0.0.1' || ip.startsWith('127.')) return true + const parts = ip.split('.').map((part) => Number.parseInt(part, 10)) + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false + if (parts[0] === 10) return true + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true + return parts[0] === 192 && parts[1] === 168 +} +const isLocalHost = (value) => { + const hostname = normalizeHost(value) + return hostname === 'localhost' || hostname.endsWith('.local') || isPrivateIp(hostname) || adminAllowedHosts.includes(hostname) +} +const privilegedConnectionAllowed = (req) => { + const requestHost = normalizeHost(req.headers['x-forwarded-host'] || req.headers.host) + if (!isLocalHost(requestHost)) return false + const socketAddress = normalizeIp(req.socket.remoteAddress || '') + if (!isPrivateIp(socketAddress)) return false + const forwardedFor = String(req.headers['x-forwarded-for'] || '').split(',').map((value) => normalizeIp(value)).filter(Boolean) + return forwardedFor.length ? forwardedFor.every(isPrivateIp) : true +} + if (isValidVapidPublicKey(vapidPublicKey) && vapidPrivateKey) { webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey) } @@ -93,6 +126,36 @@ const json = (res, status, body) => { res.end(JSON.stringify(body)) } +const adminConnectionUnavailable = (res) => { + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + }) + res.end(` + + + + + Admin Connection Required + + + +
+ L484 Admin +

Only Available to admin connection

+

Use the local admin connection to manage members, payments, cards, and site settings.

+
+ +`) +} + const rateLimit = (req, res) => { const method = String(req.method || 'GET').toUpperCase() if (!req.url?.startsWith('/api/') || method === 'GET') return false @@ -398,6 +461,10 @@ const requireAdmin = (req, res) => { json(res, 404, { error: 'Admin API is disabled on this deployment.' }) return false } + if (!privilegedConnectionAllowed(req)) { + json(res, 404, { error: 'Admin API is only available from the local admin connection.' }) + return false + } if (!isAdminPubkey(getAuthPubkey(req))) { json(res, 403, { error: 'This npub is not an admin. Please request access and we will authorise it if you are permissioned.' }) return false @@ -607,6 +674,10 @@ const hasControllerAuth = (req) => { const serveStatic = (req, res) => { const requested = decodeURIComponent(new URL(req.url, `http://${req.headers.host}`).pathname) + if (['/admin', '/edit'].includes(requested) && !privilegedConnectionAllowed(req)) { + adminConnectionUnavailable(res) + return + } const filePath = requested === '/' ? path.join(distDir, 'index.html') : path.join(distDir, requested) const safePath = filePath.startsWith(distDir) && existsSync(filePath) ? filePath : path.join(distDir, 'index.html') const ext = path.extname(safePath) @@ -1082,8 +1153,8 @@ const handleApi = async (req, res) => { return json(res, 200, { mode: appMode, publicMembershipEnabled: publicApiEnabled(), - adminEnabled: adminApiEnabled(), - accessEnabled: adminApiEnabled(), + adminEnabled: adminApiEnabled() && privilegedConnectionAllowed(req), + accessEnabled: adminApiEnabled() && privilegedConnectionAllowed(req), btcpayEnabled: btcpayConfigured(), masterAdminConfigured: Boolean(masterAdminPubkey), }) @@ -1145,6 +1216,7 @@ const handleApi = async (req, res) => { if (req.method === 'POST' && url.pathname === '/api/admin/request-access') { if (!adminApiEnabled()) return json(res, 404, { error: 'Admin API is disabled on this deployment.' }) + if (!privilegedConnectionAllowed(req)) return json(res, 404, { error: 'Admin API is only available from the local admin connection.' }) const body = await readBody(req) const pubkey = cleanText(body.pubkey, 64).toLowerCase() const npub = cleanText(body.npub, 90) @@ -1306,6 +1378,7 @@ const handleApi = async (req, res) => { if (req.method === 'GET' && url.pathname === '/api/admin/events') { if (!adminApiEnabled()) return json(res, 404, { error: 'Admin API is disabled on this deployment.' }) + if (!privilegedConnectionAllowed(req)) return json(res, 404, { error: 'Admin API is only available from the local admin connection.' }) const pubkey = cleanText(url.searchParams.get('pubkey'), 80).toLowerCase() if (!isAdminPubkey(pubkey)) return json(res, 403, { error: 'This npub is not an admin. Please request access and we will authorise it if you are permissioned.' }) res.writeHead(200, { @@ -1460,6 +1533,7 @@ const handleApi = async (req, res) => { } if (req.method === 'POST' && url.pathname === '/api/access/check') { + if (!privilegedConnectionAllowed(req)) return json(res, 404, { error: 'Controller access is only available from the local connection.' }) const controllerAuthed = hasControllerAuth(req) const isAdmin = (req.headers.authorization || '').startsWith('Bearer ') if (!controllerAuthed && (!isAdmin || !requireAdmin(req, res))) return