feat: restrict guest subnetwork access

This commit is contained in:
etiennecollin
2025-09-07 18:21:26 -04:00
parent 89a1421efb
commit 492e71fcc3
3 changed files with 196 additions and 23 deletions

View File

@@ -50,10 +50,10 @@ Perfect for businesses, cafes, hotels, and home networks that need to provide gu
### 🎨 Modern Interface ### 🎨 Modern Interface
- **Touch-Friendly** Optimized for tablet, mobile, and desktop. - **Touch-Friendly** Optimized for tablet, mobile, and desktop
- **Dark/Light Mode** Follows system preference, with manual override. - **Dark/Light Mode** Follows system preference, with manual override
- **Responsive Design** - Works seamlessly across all screen sizes - **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 - **Real-time Notifications** - Instant feedback for all operations
### 🔧 Technical Features ### 🔧 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_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 controllers IP address (which usually serves a self-signed certificate), you may need to set this to `false`.** | `true` (default) | `bool` | | `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 controllers 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` | | `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_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_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` | | `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 > 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 > 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. > 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 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 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 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) 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)) 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 - Verify Docker container has network access to UniFi controller
- Check logs: `docker logs unifi-voucher-manager` - Check logs: `docker logs unifi-voucher-manager`
- **The WiFi QR code button is disabled** - **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 [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 browser console for variable configuration errors (generally by hitting `F12` and going to the 'console' tab)
### Getting Help ### Getting Help

View File

@@ -1,7 +1,8 @@
import { NextResponse, NextRequest } from "next/server"; import { NextResponse, NextRequest } from "next/server";
import { isInBlockedSubnet } from "@/utils/ipv4";
export const config = { export const config = {
matcher: "/rust-api/:path*", matcher: ["/", "/rust-api/:path*"],
}; };
const DEFAULT_FRONTEND_TO_BACKEND_URL = "http://127.0.0.1"; const DEFAULT_FRONTEND_TO_BACKEND_URL = "http://127.0.0.1";
@@ -9,7 +10,36 @@ const DEFAULT_BACKEND_BIND_PORT = "8080";
const IPV6_IPV4_MAPPED_PREFIX = "::ffff:"; const IPV6_IPV4_MAPPED_PREFIX = "::ffff:";
const guestAllowedPaths = [
"/welcome",
"/rust-api/vouchers/rolling",
"favicon.ico",
"favicon.svg",
];
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Extract client IP
let clientIp = request.headers.get("x-forwarded-for") || "";
// Strip IPv6 prefix if it's a mapped IPv4
if (clientIp.startsWith(IPV6_IPV4_MAPPED_PREFIX)) {
clientIp = clientIp.replace(IPV6_IPV4_MAPPED_PREFIX, "");
}
// 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 // Remove the /rust-api prefix and reconstruct the path for the backend
const backendPath = request.nextUrl.pathname.replace(/^\/rust-api/, "/api"); const backendPath = request.nextUrl.pathname.replace(/^\/rust-api/, "/api");
@@ -25,13 +55,9 @@ export function middleware(request: NextRequest) {
const response = NextResponse.rewrite(backendFullUrl, { request }); const response = NextResponse.rewrite(backendFullUrl, { request });
// Forward the real client IP // Forward the real client IP
let clientIp = request.headers.get("x-forwarded-for") || "";
// Strip IPv6 prefix if it's a mapped IPv4
if (clientIp.startsWith(IPV6_IPV4_MAPPED_PREFIX)) {
clientIp = clientIp.replace(IPV6_IPV4_MAPPED_PREFIX, "");
}
response.headers.set("x-forwarded-for", clientIp); response.headers.set("x-forwarded-for", clientIp);
return response; return response;
}
return NextResponse.next();
} }

145
frontend/src/utils/ipv4.ts Normal file
View File

@@ -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));
}