From 803c92ec631026cb97cd94a461d4a11f6b011d3c Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Wed, 1 Jul 2026 12:57:58 +0000 Subject: [PATCH] offsite: use shared ip-check with 'auto' mode --- backend/src/auth.js | 51 ++--------------------- backend/src/ip-check.js | 92 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 47 deletions(-) create mode 100644 backend/src/ip-check.js diff --git a/backend/src/auth.js b/backend/src/auth.js index e89b153..852129a 100644 --- a/backend/src/auth.js +++ b/backend/src/auth.js @@ -1,45 +1,9 @@ import { jwtVerify } from 'jose' -import dns from 'dns/promises' +import { isOnsite } from './ip-check.js' const APP_SLUG = process.env.APP_SLUG || 'noticeboard' const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '') -// Offsite IP check — supports static IP, CIDR, DDNS hostname, or 'disabled' -const OFFICE_IP_CHECK = process.env.OFFICE_IP_CHECK || 'disabled' - -let cachedOfficeIP = null -let cacheExpiry = 0 - -function ipInCidr(ip, cidr) { - const [range, bits] = cidr.split('/') - const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0 - const ipInt = ip.split('.').reduce((acc, oct) => (acc << 8) + parseInt(oct), 0) >>> 0 - const rangeInt = range.split('.').reduce((acc, oct) => (acc << 8) + parseInt(oct), 0) >>> 0 - return (ipInt & mask) === (rangeInt & mask) -} - -async function resolveOfficeIP() { - if (OFFICE_IP_CHECK === 'disabled') return null - if (Date.now() < cacheExpiry && cachedOfficeIP) return cachedOfficeIP - - const isHostname = /[a-zA-Z]/.test(OFFICE_IP_CHECK) && !OFFICE_IP_CHECK.includes('/') - - if (isHostname) { - try { - const addrs = await dns.resolve4(OFFICE_IP_CHECK) - cachedOfficeIP = addrs[0] - cacheExpiry = Date.now() + 5 * 60 * 1000 - } catch { - // DNS failed — keep last known IP if we have one, don't lock everyone out - } - } else { - cachedOfficeIP = OFFICE_IP_CHECK - cacheExpiry = Infinity - } - - return cachedOfficeIP -} - export async function requireAuth(request, reply) { const token = request.cookies?.hnf_session if (!token) return reply.status(401).send({ error: 'Not authenticated' }) @@ -58,16 +22,9 @@ export async function requireAuth(request, reply) { // Offsite check — only for users without offsite_allowed flag if (!payload.offsite_allowed) { - const officeIP = await resolveOfficeIP() - if (officeIP) { - const clientIP = request.headers['x-real-ip'] || request.ip - const onsite = officeIP.includes('/') - ? ipInCidr(clientIP, officeIP) - : clientIP === officeIP - - if (!onsite) { - return reply.status(403).send({ error: 'Access restricted to site network' }) - } + const clientIP = request.headers['x-real-ip'] || request.ip + if (!(await isOnsite(clientIP))) { + return reply.status(403).send({ error: 'Access restricted to site network' }) } } diff --git a/backend/src/ip-check.js b/backend/src/ip-check.js new file mode 100644 index 0000000..059c323 --- /dev/null +++ b/backend/src/ip-check.js @@ -0,0 +1,92 @@ +import dns from 'dns/promises' + +// OFFICE_IP_CHECK: a comma-separated list of matchers — a request is "onsite" +// if ANY matcher matches. Each matcher can be: +// 'disabled' → onsite check off (everyone allowed) +// → exact static public IP +// → e.g. 10.4.0.0/22 (matches onsite LAN clients) +// → resolved via DNS (dynamic public IP via a DDNS service) +// 'auto' → detect the SITE's own public IP (no DDNS service needed) +// +// Typical dynamic-IP config: "10.4.0.0/22,auto" +// - LAN clients match the CIDR (onsite via internal DNS / LAN) +// - clients arriving via the public IP (NAT hairpin) match 'auto' +// - offsite clients (different public IP) match neither → blocked +const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim() +const matchers = raw.split(',').map(s => s.trim()).filter(Boolean) + +const TTL = 5 * 60 * 1000 // 5 min cache for dynamic lookups (auto / ddns) +const cache = new Map() // key → { ip, expiry } + +const PUBLIC_IP_URLS = [ + 'https://api.ipify.org', + 'https://ifconfig.co/ip', + 'https://icanhazip.com', +] + +function normalizeIP(ip) { + return ip?.startsWith('::ffff:') ? ip.slice(7) : ip +} + +function isIPv4(s) { + return /^\d{1,3}(\.\d{1,3}){3}$/.test(s) +} + +function ipInCidr(ip, cidr) { + const [range, bits] = cidr.split('/') + if (!isIPv4(ip) || !isIPv4(range)) return false + const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0 + const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0 + return (toInt(ip) & mask) === (toInt(range) & mask) +} + +async function fetchPublicIP() { + for (const url of PUBLIC_IP_URLS) { + try { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), 4000) + const res = await fetch(url, { signal: ctrl.signal }) + clearTimeout(timer) + if (!res.ok) continue + const ip = (await res.text()).trim() + if (isIPv4(ip)) return ip + } catch { + // try the next endpoint + } + } + return null +} + +async function resolveDynamic(key, resolver) { + const hit = cache.get(key) + if (hit && Date.now() < hit.expiry) return hit.ip + const ip = await resolver() + if (ip) { + cache.set(key, { ip, expiry: Date.now() + TTL }) + return ip + } + return hit ? hit.ip : null // stale-but-usable fallback +} + +export async function isOnsite(requestIP) { + if (matchers.length === 0 || matchers.includes('disabled')) return true + const ip = normalizeIP(requestIP) + if (!ip) return false + + for (const m of matchers) { + if (m === 'auto') { + const pub = await resolveDynamic('auto', fetchPublicIP) + if (pub && ip === pub) return true + } else if (m.includes('/')) { + if (ipInCidr(ip, m)) return true + } else if (/[a-zA-Z]/.test(m)) { + const resolved = await resolveDynamic(m, async () => { + try { return (await dns.resolve4(m))[0] } catch { return null } + }) + if (resolved && ip === resolved) return true + } else { + if (ip === m) return true + } + } + return false +}