Restrict admin to local connections
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
12
public/sw.js
12
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()),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user