Restrict admin to local connections

This commit is contained in:
Dorian
2026-05-19 12:13:04 -05:00
parent 5c4ce583c5
commit c2751f2700
4 changed files with 89 additions and 5 deletions

View File

@@ -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=<random 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:

View File

@@ -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:-}

View File

@@ -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) => {
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()),

View File

@@ -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(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin Connection Required</title>
<style>
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #080808; color: #fafafa; }
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at top, rgba(242,169,0,0.12), transparent 34%), #080808; }
main { width: min(100%, 460px); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 28px; background: rgba(255,255,255,0.045); }
p { margin: 0 0 8px; color: rgba(255,255,255,0.62); line-height: 1.5; }
h1 { margin: 0 0 12px; font-size: clamp(28px, 8vw, 44px); line-height: 0.95; text-transform: uppercase; }
strong { color: #f2a900; font-size: 12px; letter-spacing: 0.14em; text-transform: uppercase; }
</style>
</head>
<body>
<main>
<strong>L484 Admin</strong>
<h1>Only Available to admin connection</h1>
<p>Use the local admin connection to manage members, payments, cards, and site settings.</p>
</main>
</body>
</html>`)
}
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