Files
central-unifi/src/Http/Controllers/PortalController.php
jwed b0287f1b10 Bound session_minutes to its column so an oversized value 422s (#5)
`unifi_vlan_mappings.session_minutes` is `unsignedInteger` (ceiling 4294967295)
but the mapping validator only said `integer|min:1`. Anything larger reached the
INSERT and raised SQLSTATE 22003, so the request died as an unhandled 500 where
a validation error belongs — the same shape just fixed in central-oncall#7.
Adds `max:525600` (a year of minutes) to both storeMapping and updateMapping.

Found while fixing #5, which reported a *silent* rejection rather than a crash.
The VLAN ID validation the issue was actually about was already correct on the
server (`min:1|max:4094` on all three forms); what was missing was any way to
see it, and that half of the fix is in the shell's Unifi/Portal.vue. Sibling
numeric fields were checked at the same time: `vlan_id` (unsignedSmallInteger,
bounded 1–4094) and `max_devices` (unsignedTinyInteger, bounded 1–50) were
already safe against their columns; `session_minutes` was the only one that
could overflow.

Version 1.13.3 -> 1.13.4.

Test: dashboard-shell tests/Feature/Unifi/VlanGroupValidationTest.php

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 17:05:14 -04:00

266 lines
12 KiB
PHP

<?php
namespace Dashboard\Unifi\Http\Controllers;
use Dashboard\Unifi\Models\KnownMac;
use Dashboard\Unifi\Models\PortalSession;
use Dashboard\Unifi\Models\VlanGroup;
use Dashboard\Unifi\Models\VlanMapping;
use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Inertia\Inertia;
class PortalController extends Controller
{
// ── Settings page (VLAN mappings + known MACs) ────────────────────────────
public function settings()
{
return Inertia::render('Unifi/Portal', [
'vlanGroups' => VlanGroup::orderBy('sort_order')->get(),
'vlanMappings' => VlanMapping::orderBy('sort_order')->get(),
'knownMacs' => KnownMac::orderBy('device_name')->paginate(50),
'activeSessions' => PortalSession::where('is_active', true)
->with('user:id,name,email')
->latest('authorized_at')
->get(),
]);
}
public function storeMapping(Request $request)
{
$data = $request->validate([
'group_name' => 'required|string|max:255',
'match_type' => 'required|in:ou,group,email_domain,default',
'match_value' => 'required|string|max:255',
'vlan_id' => 'required|integer|min:1|max:4094',
'max_devices' => 'required|integer|min:1|max:50',
// Bounded by the column that stores it: unifi_vlan_mappings.session_minutes
// is unsignedInteger, so an unbounded value reached the INSERT and raised
// SQLSTATE 22003 — a 500 where a validation error belongs (central-unifi#5).
// 525600 minutes is a year, far more session than anyone will ever grant.
'session_minutes' => 'required|integer|min:1|max:525600',
]);
$data['sort_order'] = VlanMapping::max('sort_order') + 1;
VlanMapping::create($data);
return back()->with('success', 'VLAN mapping created.');
}
public function updateMapping(Request $request, VlanMapping $mapping)
{
$data = $request->validate([
'group_name' => 'required|string|max:255',
'match_type' => 'required|in:ou,group,email_domain,default',
'match_value' => 'required|string|max:255',
'vlan_id' => 'required|integer|min:1|max:4094',
'max_devices' => 'required|integer|min:1|max:50',
// Bounded by the column that stores it: unifi_vlan_mappings.session_minutes
// is unsignedInteger, so an unbounded value reached the INSERT and raised
// SQLSTATE 22003 — a 500 where a validation error belongs (central-unifi#5).
// 525600 minutes is a year, far more session than anyone will ever grant.
'session_minutes' => 'required|integer|min:1|max:525600',
]);
$mapping->update($data);
return back()->with('success', 'Mapping updated.');
}
public function destroyMapping(VlanMapping $mapping)
{
$mapping->delete();
return back()->with('success', 'Mapping deleted.');
}
// ── Known MACs ────────────────────────────────────────────────────────────
public function storeMac(Request $request)
{
$data = $request->validate([
'mac_address' => 'required|string|regex:/^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/|unique:unifi_known_macs,mac_address',
'device_name' => 'nullable|string|max:255',
'device_type' => 'nullable|string|max:100',
'owner' => 'nullable|string|max:255',
'vlan_id' => 'required|integer|min:1|max:4094',
'notes' => 'nullable|string|max:1000',
]);
KnownMac::create($data);
return back()->with('success', 'MAC address added.');
}
public function destroyMac(KnownMac $mac)
{
$mac->delete();
return back()->with('success', 'MAC address removed.');
}
// ── Captive portal callback (called by UniFi external portal redirect) ───
/**
* A redirect target that cannot be turned into a phishing or script payload.
*
* Accepts only absolute http/https URLs and site-relative paths. Rejects
* javascript:/data:/vbscript: schemes, and protocol-relative "//host" values which
* redirect() would happily send off-site. Anything rejected falls back to the
* dashboard rather than erroring — the guest is already online at this point, so a
* bad `url` parameter should not look like a failed login.
*/
private function safeRedirectTarget(?string $url): string
{
$url = trim((string) $url);
if ($url === '' || str_starts_with($url, '//')) {
return '/';
}
// Site-relative is always fine.
if (str_starts_with($url, '/')) {
return $url;
}
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
return in_array($scheme, ['http', 'https'], true) && parse_url($url, PHP_URL_HOST)
? $url
: '/';
}
public function captiveCallback(Request $request, UnifiApiClient $unifi)
{
// UniFi redirects guest to this URL with ?id=<ap_mac>&ap=<ap_mac>&t=<timestamp>&url=<original_url>&ssid=<ssid>
// At this point the user has already authenticated via Google OAuth (handled by the shell's auth system)
$user = $request->user();
if (! $user) {
return redirect()->route('login');
}
$clientMac = strtolower($request->input('mac', $request->input('id', '')));
$ssid = $request->input('ssid', '');
$apMac = $request->input('ap', '');
if (! $clientMac) {
return response('Missing MAC address', 400);
}
// SECURITY: everything identifying the device is request-supplied, and this
// endpoint WRITES network state (authorizeGuest puts a MAC on the mapped VLAN).
// Before this check any signed-in account — a student, a parent — could call
// GET /portal/wifi/callback?mac=<someone else's MAC>&url=…
// and admit a device they do not own, up to max_devices.
//
// A well-formed MAC is the floor, not a fix: it stops the parameter being used
// to smuggle junk into authorizeGuest and into the PortalSession row, but it
// cannot prove the caller owns the device. Properly binding it needs the AP
// handshake — UniFi can sign the redirect, or we ask the controller whether this
// MAC is CURRENTLY associated to $apMac before admitting it. Both need testing
// against real hardware, so they are deliberately not guessed at here.
$clientMac = str_replace('-', ':', $clientMac);
if (! preg_match('/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/', $clientMac)) {
return response('Malformed MAC address', 400);
}
// Find the VLAN mapping for this user's group/OU
$mapping = $this->resolveMapping($user);
if (! $mapping) {
return Inertia::render('Unifi/PortalDenied', ['reason' => 'No WiFi access configured for your account.']);
}
// Check device limit
$activeSessions = PortalSession::where('user_id', $user->id)
->where('is_active', true)
->get();
foreach ($activeSessions as $session) {
if ($session->mac_address === $clientMac) {
// Same device reconnecting — refresh
$session->update(['is_active' => false]);
continue;
}
// Check if other device is still actually connected
if (! $unifi->isClientConnected($session->mac_address)) {
$session->update(['is_active' => false]); // stale session
}
}
$activeCount = PortalSession::where('user_id', $user->id)->where('is_active', true)->count();
if ($activeCount >= $mapping->max_devices) {
return Inertia::render('Unifi/PortalDenied', [
'reason' => "You already have {$activeCount} device(s) connected (limit: {$mapping->max_devices}).",
'sessions' => $activeSessions->map(fn ($s) => [
'id' => $s->id,
'mac' => $s->mac_address,
'hostname' => $s->device_hostname,
'os' => $s->device_os,
'since' => $s->authorized_at->toISOString(),
]),
'can_disconnect' => true,
]);
}
// Authorize the guest
$minutes = $mapping->session_minutes;
$unifi->authorizeGuest($clientMac, $minutes);
// Record the session
PortalSession::create([
'user_id' => $user->id,
'mac_address' => $clientMac,
'device_hostname' => $request->input('hostname'),
'device_os' => $request->input('os'),
'device_type' => $request->input('dev_cat'),
'ssid' => $ssid,
'vlan_id' => $mapping->vlan_id,
'ap_mac' => $apMac,
'is_active' => true,
'authorized_at' => now(),
'expires_at' => now()->addMinutes($minutes),
]);
// Redirect to the original URL the user was trying to reach.
//
// SECURITY: this was `redirect($request->input('url', '/'))` — an unvalidated
// open redirect on an authenticated route, i.e. a phishing primitive wearing
// this school's domain. The whole point of a captive portal is to send someone
// to an ARBITRARY external site they were already trying to reach, so an
// allowlist is not available; what is available is refusing anything that is
// not a plain http(s) URL, which blocks javascript:, data: and protocol-relative
// //evil.example payloads.
return redirect($this->safeRedirectTarget($request->input('url')));
}
public function disconnectSession(Request $request, PortalSession $session, UnifiApiClient $unifi)
{
// Allow users to disconnect their own sessions
abort_if($session->user_id !== auth()->id() && ! auth()->user()->can('unifi.auth'), 403);
try {
$unifi->kickClient($session->mac_address);
$unifi->unauthorizeGuest($session->mac_address);
} catch (\Throwable) {}
$session->update(['is_active' => false]);
return back()->with('success', 'Device disconnected.');
}
// ── Private helpers ───────────────────────────────────────────────────────
private function resolveMapping($user): ?VlanMapping
{
$mappings = VlanMapping::orderBy('sort_order')->get();
// Try to match by email domain, group, OU, etc.
foreach ($mappings as $mapping) {
$matched = match ($mapping->match_type) {
'email_domain' => str_ends_with($user->email ?? '', '@' . $mapping->match_value),
'group' => $user->groups?->contains('name', $mapping->match_value) ?? false,
'ou' => str_contains($user->ou ?? '', $mapping->match_value),
'default' => true,
default => false,
};
if ($matched) return $mapping;
}
return null;
}
}