From 492e71fcc3774a7130448f5c9fa1d2fdf58b1c41 Mon Sep 17 00:00:00 2001 From: etiennecollin Date: Sun, 7 Sep 2025 18:21:26 -0400 Subject: [PATCH] feat: restrict guest subnetwork access --- README.md | 14 ++-- frontend/src/middleware.ts | 60 ++++++++++----- frontend/src/utils/ipv4.ts | 145 +++++++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 23 deletions(-) create mode 100644 frontend/src/utils/ipv4.ts diff --git a/README.md b/README.md index e8fd4a3..3d02419 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,10 @@ Perfect for businesses, cafes, hotels, and home networks that need to provide gu ### 🎨 Modern Interface -- **Touch-Friendly** – Optimized for tablet, mobile, and desktop. -- **Dark/Light Mode** – Follows system preference, with manual override. +- **Touch-Friendly** – Optimized for tablet, mobile, and desktop +- **Dark/Light Mode** – Follows system preference, with manual override - **Responsive Design** - Works seamlessly across all screen sizes -- **Smooth Animations** – Semantic transitions for polished UX. +- **Smooth Animations** – Semantic transitions for polished UX - **Real-time Notifications** - Instant feedback for all operations ### 🔧 Technical Features @@ -126,6 +126,7 @@ To configure the WiFi QR code, you are required to configure the `WIFI_SSID` and | `UNIFI_API_KEY` | Required | API Key for your UniFi controller. | `abc123...` | `string` | | `UNIFI_HAS_VALID_CERT` | Optional | Whether your UniFi controller uses a valid SSL certificate. This should normally be set to `true`, especially if you access the controller through a reverse proxy or another setup that provides trusted certificates (e.g., Let's Encrypt). **If you connect directly to the controller’s IP address (which usually serves a self-signed certificate), you may need to set this to `false`.** | `true` (default) | `bool` | | `UNIFI_SITE_ID` | Optional | Site ID of your UniFi controller. Using the value `default`, the backend will try to fetch the ID of the default site. | `default` (default) | `string` | +| `GUEST_SUBNETWORK` | Optional | Restrict guest subnetwork access to UVM while still permitting access to the `/welcome` page, which users are redirected to from the UniFi captive portal. For more details, see [Rolling Vouchers and Kiosk Page](#rolling-vouchers-and-kiosk-page). | `10.0.5.0/24` | `IPv4 CIDR` | | `FRONTEND_BIND_HOST` | Optional | Address on which the frontend server binds. | `0.0.0.0` (default) | `IPv4` | | `FRONTEND_BIND_PORT` | Optional | Port on which the frontend server binds. | `3000` (default) | `u16` | | `FRONTEND_TO_BACKEND_URL` | Optional | URL where the frontend will make its API requests to the backend. | `http://127.0.0.1` (default) | `URL` | @@ -157,6 +158,7 @@ Rolling vouchers provide a seamless way to automatically generate guest network > > 1. Go to your UniFi Controller -> Insights -> Hotspot > 2. Set the **Success Landing Page** to: `https://your-uvm-domain.com/welcome`, the `/welcome` page of UVM +> 3. To restrict UVM access to the guest subnetwork while still allowing access to `/welcome` set the `GUEST_SUBNETWORK` environment variable > > Without this configuration, vouchers **will not** automatically roll when guests connect. @@ -165,7 +167,7 @@ Rolling vouchers provide a seamless way to automatically generate guest network 1. **Initial Setup**: Rolling vouchers are generated automatically when needed 2. **Guest Connection**: When a guest connects to your network, they're redirected to the `/welcome` page 3. **Automatic Rolling**: The welcome page triggers the creation of a new voucher for the next guest - - Rolling vouchers are created with special naming conventions to distinguish them from manually created vouchers, making them easy to identify in your voucher management interface. + - Rolling vouchers are created with special naming conventions to distinguish them from manually created vouchers, making them easy to identify in your voucher management interface 4. **IP-Based Uniqueness**: Each IP address can only generate one voucher per session (prevents abuse from page reloads) 5. **Daily Maintenance**: To prevent clutter, expired rolling vouchers are automatically deleted at midnight (based on your configured `TIMEZONE` in [Environment Variables](#environment-variables)) @@ -193,8 +195,8 @@ The kiosk page (`/kiosk`) provides a guest-friendly interface displaying: - Verify Docker container has network access to UniFi controller - Check logs: `docker logs unifi-voucher-manager` - **The WiFi QR code button is disabled** - - Check the [Environment Variables](#environment-variables) section and make sure you configured the variables required for the WiFi QR code. - - Check the browser console for variable configuration errors (generally by hitting `F12` and going to the 'console' tab). + - Check the [Environment Variables](#environment-variables) section and make sure you configured the variables required for the WiFi QR code + - Check the browser console for variable configuration errors (generally by hitting `F12` and going to the 'console' tab) ### Getting Help diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 2ca837d..4067d6d 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -1,7 +1,8 @@ import { NextResponse, NextRequest } from "next/server"; +import { isInBlockedSubnet } from "@/utils/ipv4"; export const config = { - matcher: "/rust-api/:path*", + matcher: ["/", "/rust-api/:path*"], }; const DEFAULT_FRONTEND_TO_BACKEND_URL = "http://127.0.0.1"; @@ -9,22 +10,17 @@ const DEFAULT_BACKEND_BIND_PORT = "8080"; const IPV6_IPV4_MAPPED_PREFIX = "::ffff:"; +const guestAllowedPaths = [ + "/welcome", + "/rust-api/vouchers/rolling", + "favicon.ico", + "favicon.svg", +]; + export function middleware(request: NextRequest) { - // Remove the /rust-api prefix and reconstruct the path for the backend - const backendPath = request.nextUrl.pathname.replace(/^\/rust-api/, "/api"); + const { pathname } = request.nextUrl; - const backendUrl = - process.env.FRONTEND_TO_BACKEND_URL || DEFAULT_FRONTEND_TO_BACKEND_URL; - const backendPort = - process.env.BACKEND_BIND_PORT || DEFAULT_BACKEND_BIND_PORT; - - const backendFullUrl = new URL( - `${backendUrl}:${backendPort}${backendPath}${request.nextUrl.search}`, - ); - - const response = NextResponse.rewrite(backendFullUrl, { request }); - - // Forward the real client IP + // Extract client IP let clientIp = request.headers.get("x-forwarded-for") || ""; // Strip IPv6 prefix if it's a mapped IPv4 @@ -32,6 +28,36 @@ export function middleware(request: NextRequest) { clientIp = clientIp.replace(IPV6_IPV4_MAPPED_PREFIX, ""); } - response.headers.set("x-forwarded-for", clientIp); - return response; + // Restrict access based on GUEST_SUBNET env variable + const guestSubnet = process.env.GUEST_SUBNET; + if (guestSubnet) { + if ( + !guestAllowedPaths.includes(pathname) && + isInBlockedSubnet(clientIp, guestSubnet) + ) { + return new NextResponse("Access denied", { status: 403 }); + } + } + + if (pathname.startsWith("/rust-api")) { + // Remove the /rust-api prefix and reconstruct the path for the backend + const backendPath = request.nextUrl.pathname.replace(/^\/rust-api/, "/api"); + + const backendUrl = + process.env.FRONTEND_TO_BACKEND_URL || DEFAULT_FRONTEND_TO_BACKEND_URL; + const backendPort = + process.env.BACKEND_BIND_PORT || DEFAULT_BACKEND_BIND_PORT; + + const backendFullUrl = new URL( + `${backendUrl}:${backendPort}${backendPath}${request.nextUrl.search}`, + ); + + const response = NextResponse.rewrite(backendFullUrl, { request }); + + // Forward the real client IP + response.headers.set("x-forwarded-for", clientIp); + return response; + } + + return NextResponse.next(); } diff --git a/frontend/src/utils/ipv4.ts b/frontend/src/utils/ipv4.ts new file mode 100644 index 0000000..796f71a --- /dev/null +++ b/frontend/src/utils/ipv4.ts @@ -0,0 +1,145 @@ +// Validate subnet format and values +export function isValidSubnet(subnet: string): boolean { + try { + // Handle single IP addresses (treat as /32) + if (!subnet.includes("/")) { + return isValidIPAddress(subnet); + } + + // Parse the subnet (e.g., "10.0.5.0/24") + const [subnetIp, prefixLength] = subnet.split("/"); + + if (!subnetIp || !prefixLength) { + return false; + } + + // Validate prefix length + const prefix = parseInt(prefixLength, 10); + if (isNaN(prefix) || prefix < 0 || prefix > 32) { + return false; + } + + // Validate IP address format + if (!isValidIPAddress(subnetIp)) { + return false; + } + + // Check if the IP is a valid network address + const ipToInt = (ipAddr: string): number => { + const parts = ipAddr.split("."); + return ( + parts.reduce((acc, part) => { + const num = parseInt(part, 10); + return (acc << 8) + num; + }, 0) >>> 0 + ); + }; + + const subnetIpInt = ipToInt(subnetIp); + const mask = (0xffffffff << (32 - prefix)) >>> 0; + const networkAddress = (subnetIpInt & mask) >>> 0; + + // Check if the provided IP is actually the network address + // Comment out these lines if you want to allow any IP in subnet notation + if (subnetIpInt !== networkAddress) { + console.warn( + `IP ${subnetIp} is not a network address for /${prefix}. Expected: ${intToIp(networkAddress)}`, + ); + return false; + } + + return true; + } catch (error) { + console.error( + `Error checking subnet: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + return false; + } +} + +// Utility export function to validate IP address format +// Taken from https://stackoverflow.com/a/36760050 +export function isValidIPAddress(ip: string): boolean { + const ipRegex = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/; + return ipRegex.test(ip); +} + +// Helper export function to convert integer back to IP string +export function intToIp(int: number): string { + return [ + (int >>> 24) & 255, + (int >>> 16) & 255, + (int >>> 8) & 255, + int & 255, + ].join("."); +} + +// Robust subnet check with proper CIDR notation support +export function isInBlockedSubnet(ip: string, subnet: string): boolean { + if (!ip || !subnet) return false; + + // Validate inputs first + if (!isValidIPAddress(ip)) { + console.warn(`Invalid IP address format: ${ip}`); + return false; + } + + if (!isValidSubnet(subnet)) { + console.warn(`Invalid subnet format: ${subnet}`); + return false; + } + + try { + // Normalize subnet (add /32 for single IPs) + const normalizedSubnet = subnet.includes("/") ? subnet : `${subnet}/32`; + const [subnetIp, prefixLength] = normalizedSubnet.split("/"); + const prefix = parseInt(prefixLength, 10); + + // Convert IP addresses to 32-bit integers + const ipToInt = (ipAddr: string): number => { + const parts = ipAddr.split("."); + return ( + parts.reduce((acc, part) => { + const num = parseInt(part, 10); + return (acc << 8) + num; + }, 0) >>> 0 + ); + }; + + const targetIpInt = ipToInt(ip); + const subnetIpInt = ipToInt(subnetIp); + + // Create subnet mask + const mask = (0xffffffff << (32 - prefix)) >>> 0; + + // Check if the IP is in the subnet + return (targetIpInt & mask) === (subnetIpInt & mask); + } catch (error) { + console.error( + `Error checking subnet: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + return false; + } +} + +// Enhanced version with support for multiple subnets +export function isInAnyBlockedSubnet(ip: string, subnets: string[]): boolean { + if (subnets.length === 0) return false; + + // Validate IP once + if (!isValidIPAddress(ip)) { + console.warn(`Invalid IP address format: ${ip}`); + return false; + } + + // Filter valid subnets and check + const validSubnets = subnets.filter((subnet) => { + const isValid = isValidSubnet(subnet); + if (!isValid) { + console.warn(`Skipping invalid subnet: ${subnet}`); + } + return isValid; + }); + + return validSubnets.some((subnet) => isInBlockedSubnet(ip, subnet)); +}