Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 204 additions & 0 deletions Perspective Server/AccessControlManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import Foundation

struct ApprovedClient: Codable, Identifiable, Sendable, Equatable {
let id: UUID
var name: String
var token: String
var allowedOriginHosts: [String]
var allowedPathPrefixes: [String]
var isEnabled: Bool
var isSystemClient: Bool
let createdAt: Date

func allowsOrigin(_ originHost: String?) -> Bool {
guard let originHost else { return true }
return allowedOriginHosts.contains(originHost.lowercased())
}

func allowsPath(_ path: String) -> Bool {
allowedPathPrefixes.contains { path.hasPrefix($0) }
}
}

enum AccessDecision: Sendable {
case allow
case deny(status: Int, message: String)
}

actor AccessControlManager {
static let shared = AccessControlManager()

private let defaultsKey = "approvedApiClientsV1"
private var clients: [ApprovedClient] = []

init() {
clients = Self.loadClients(defaultsKey: defaultsKey)
if clients.isEmpty {
clients = Self.defaultClients()
Self.saveClients(clients, defaultsKey: defaultsKey)
}
}

func listClients() -> [ApprovedClient] {
clients.sorted { lhs, rhs in
if lhs.isSystemClient != rhs.isSystemClient {
return lhs.isSystemClient && !rhs.isSystemClient
}
return lhs.createdAt < rhs.createdAt
}
}

func createClient(name: String, allowedOriginHosts: [String], allowedPathPrefixes: [String] = ["/v1/", "/api/"]) -> ApprovedClient {
let client = ApprovedClient(
id: UUID(),
name: name,
token: Self.generateToken(),
allowedOriginHosts: Self.normalizeOrigins(allowedOriginHosts),
allowedPathPrefixes: allowedPathPrefixes,
isEnabled: true,
isSystemClient: false,
createdAt: Date()
)
clients.append(client)
persist()
return client
}

@discardableResult
func rotateToken(id: UUID) -> String? {
guard let index = clients.firstIndex(where: { $0.id == id }) else { return nil }
let newToken = Self.generateToken()
clients[index].token = newToken
persist()
return newToken
}

func deleteClient(id: UUID) {
guard let index = clients.firstIndex(where: { $0.id == id }) else { return }
if clients[index].isSystemClient { return }
clients.remove(at: index)
persist()
}

func setClientEnabled(id: UUID, enabled: Bool) {
guard let index = clients.firstIndex(where: { $0.id == id }) else { return }
clients[index].isEnabled = enabled
persist()
}

func tokenForLocalApp() -> String? {
clients.first(where: { $0.isSystemClient && $0.name == "Local App" })?.token
}

func tokenForOrigin(_ originHost: String?) -> String? {
guard let originHost = originHost?.lowercased() else { return nil }
return clients.first(where: { $0.isEnabled && $0.allowedOriginHosts.contains(originHost) })?.token
}

func allowedBrowserOrigins() -> Set<String> {
Set(clients.filter { $0.isEnabled }.flatMap { $0.allowedOriginHosts })
}

func authorize(path: String, authHeader: String?, originHost: String?) -> AccessDecision {
guard isProtectedPath(path) else { return .allow }

guard let token = Self.bearerToken(from: authHeader) else {
return .deny(status: 401, message: "Unauthorized: missing bearer token")
}

guard let client = clients.first(where: { $0.token == token }) else {
return .deny(status: 401, message: "Unauthorized: invalid bearer token")
}

guard client.isEnabled else {
return .deny(status: 403, message: "Forbidden: client is disabled")
}

guard client.allowsPath(path) else {
return .deny(status: 403, message: "Forbidden: token not allowed for this endpoint")
}

guard client.allowsOrigin(originHost) else {
return .deny(status: 403, message: "Forbidden: origin not approved for this token")
}

return .allow
}

private func persist() {
Self.saveClients(clients, defaultsKey: defaultsKey)
}

private static func loadClients(defaultsKey: String) -> [ApprovedClient] {
guard let data = UserDefaults.standard.data(forKey: defaultsKey) else {
return []
}
return (try? JSONDecoder().decode([ApprovedClient].self, from: data)) ?? []
}

private static func saveClients(_ clients: [ApprovedClient], defaultsKey: String) {
if let data = try? JSONEncoder().encode(clients) {
UserDefaults.standard.set(data, forKey: defaultsKey)
}
}

private static func defaultClients() -> [ApprovedClient] {
let localOrigins = ["localhost", "127.0.0.1", "[::1]", "::1"]

let localAppClient = ApprovedClient(
id: UUID(),
name: "Local App",
token: Self.generateToken(),
allowedOriginHosts: localOrigins,
allowedPathPrefixes: ["/v1/", "/api/"],
isEnabled: true,
isSystemClient: true,
createdAt: Date()
)

let webClient = ApprovedClient(
id: UUID(),
name: "Perspective Intelligence Web",
token: Self.generateToken(),
allowedOriginHosts: ["perspectiveintelligence.app", "www.perspectiveintelligence.app"],
allowedPathPrefixes: ["/v1/", "/api/"],
isEnabled: true,
isSystemClient: true,
createdAt: Date()
)

return [localAppClient, webClient]
}

private func isProtectedPath(_ path: String) -> Bool {
path.hasPrefix("/v1/") || path.hasPrefix("/api/")
}

private static func normalizeOrigins(_ origins: [String]) -> [String] {
var set: Set<String> = []
for value in origins {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if !trimmed.isEmpty {
set.insert(trimmed)
}
}
return Array(set).sorted()
}

private static func bearerToken(from header: String?) -> String? {
guard let header else { return nil }
guard header.lowercased().hasPrefix("bearer ") else { return nil }
let token = String(header.dropFirst(7)).trimmingCharacters(in: .whitespacesAndNewlines)
return token.isEmpty ? nil : token
}

private static func generateToken() -> String {
let chars = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
var value = "pi_"
value.reserveCapacity(35)
for _ in 0..<32 {
value.append(chars[Int.random(in: 0..<chars.count)])
}
return value
}
}
5 changes: 4 additions & 1 deletion Perspective Server/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ final class ChatViewModel: ObservableObject {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token = await AccessControlManager.shared.tokenForLocalApp() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
request.httpBody = try JSONEncoder().encode(reqBody)

let (data, response) = try await URLSession.shared.data(for: request)
Expand Down Expand Up @@ -154,7 +157,7 @@ struct ChatView: View {
.fill(serverStatusColor)
.frame(width: 10, height: 10)
.accessibilityHidden(true)
Text(serverReady ? "Local API: 127.0.0.1:\(vm.port)" : "Server offline")
Text(verbatim: serverReady ? "Local API: 127.0.0.1:\(vm.port)" : "Server offline")
.font(.subheadline)
.foregroundStyle(.secondary)
.accessibilityLabel(serverReady ? "Local API on port \(vm.port)" : "Server offline")
Expand Down
67 changes: 46 additions & 21 deletions Perspective Server/LocalHTTPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ actor LocalHTTPServer {
private(set) var lastError: String? = nil
private var activeRequestCount: Int = 0

/// Ports to try in order if the primary port is unavailable
private let fallbackPorts: [UInt16] = [11434, 11435, 11436, 11437, 8080]
/// Ports to try in order if the primary port is unavailable.
/// Keep fallback in a dedicated high local range to avoid common
/// development ports (e.g. 3000/5173/8080).
private let fallbackPorts: [UInt16] = [11434, 11435, 11436, 11437, 11438, 11439, 11440, 11441, 11442, 11443, 11444]
private var portsToTry: [UInt16] = []
private var currentPortIndex: Int = 0

Expand All @@ -30,16 +32,6 @@ actor LocalHTTPServer {
// already running on the user's machine with full filesystem access.
// This matches how Ollama and similar local AI servers handle security.

/// Origins allowed to make cross-origin requests from a browser.
/// Localhost covers local dev and browser extensions.
/// The Perspective Intelligence web app is allowed so Basic tier users
/// can stream directly from the browser to their Mac.
private static let allowedOriginHosts: Set<String> = [
"localhost", "127.0.0.1", "[::1]", "::1",
"perspectiveintelligence.app",
"www.perspectiveintelligence.app",
]

// MARK: - Pairing

/// 6-digit pairing code for connecting the web app to this server.
Expand Down Expand Up @@ -88,10 +80,19 @@ actor LocalHTTPServer {
/// Check if an Origin header value is in the allowlist by parsing the URL host.
/// Browser-enforced Origin headers cannot be spoofed by JavaScript, so this
/// provides reliable cross-origin protection without requiring a bearer token.
nonisolated private static func isAllowedOrigin(_ origin: String?) -> Bool {
nonisolated private static func isAllowedOrigin(_ origin: String?, allowedHosts: Set<String>) -> Bool {
guard let origin = origin else { return true } // No Origin = not a browser cross-origin request
guard let url = URL(string: origin), let host = url.host else { return false }
return allowedOriginHosts.contains(host.lowercased())
return allowedHosts.contains(host.lowercased())
}

/// Browser requests that omit Origin can still be cross-site (e.g. some no-cors subresource loads).
/// If Fetch Metadata headers are present, treat this as browser traffic and require Origin.
nonisolated private static func isBrowserRequestMissingOrigin(headers: [String: String], origin: String?) -> Bool {
guard origin == nil else { return false }
let hasSecFetchSite = headers["sec-fetch-site"] != nil
let hasSecFetchMode = headers["sec-fetch-mode"] != nil
return hasSecFetchSite || hasSecFetchMode
}

nonisolated private static func jsonHeaders(corsOrigin: String? = nil) -> [String: String] {
Expand Down Expand Up @@ -178,7 +179,17 @@ actor LocalHTTPServer {
let serverPort = await self.port
let host = request.headers["host"]
let origin = request.headers["origin"]
let validatedCorsOrigin = Self.isAllowedOrigin(origin) ? (origin ?? "") : ""
let approvedOriginHosts = await AccessControlManager.shared.allowedBrowserOrigins()

// 1a. Require Origin for browser-context requests to close no-origin CSRF paths.
if Self.isBrowserRequestMissingOrigin(headers: request.headers, origin: origin) {
logger.warning("[req:\(rid, privacy: .public)] Blocked: browser request missing Origin header")
let msg = ["error": ["message": "Forbidden: browser requests must include Origin"]]
let data = (try? JSONSerialization.data(withJSONObject: msg, options: [])) ?? Data()
return .normal(HTTPResponse(status: 403, headers: Self.jsonHeaders(), body: data))
}

let validatedCorsOrigin = Self.isAllowedOrigin(origin, allowedHosts: approvedOriginHosts) ? (origin ?? "") : ""
if !Self.isAllowedHost(host, serverPort: serverPort) {
logger.warning("[req:\(rid, privacy: .public)] Blocked: invalid Host header '\(host ?? "nil", privacy: .public)'")
let msg = ["error": ["message": "Forbidden: invalid Host header"]]
Expand All @@ -187,7 +198,7 @@ actor LocalHTTPServer {
}

// 2. Validate Origin header (cross-origin protection)
if !Self.isAllowedOrigin(origin) {
if !Self.isAllowedOrigin(origin, allowedHosts: approvedOriginHosts) {
logger.warning("[req:\(rid, privacy: .public)] Blocked: disallowed Origin '\(origin ?? "nil", privacy: .public)'")
let msg = ["error": ["message": "Forbidden: origin not allowed"]]
let data = (try? JSONSerialization.data(withJSONObject: msg, options: [])) ?? Data()
Expand All @@ -196,6 +207,8 @@ actor LocalHTTPServer {

// Compute CORS origin: echo back the validated origin, or empty if no Origin header
let corsOrigin = validatedCorsOrigin
let originHost = URL(string: origin ?? "")?.host?.lowercased()
let rawPath = request.path.split(separator: "?").first.map(String.init) ?? request.path

// CORS preflight support (no auth token required for preflight)
if request.method == "OPTIONS" {
Expand All @@ -207,16 +220,15 @@ actor LocalHTTPServer {
], body: Data()))
}

// No bearer token required — security is provided by:
// 1. Loopback-only binding (not reachable from network)
// 2. CORS origin allowlist (blocks random websites)
// 3. Host header validation (blocks DNS rebinding)
// Security stack:
// 1. Loopback-only binding
// 2. Host + Origin validation
// 3. Per-application bearer tokens on protected API/model routes

// --- Pairing endpoint ---
// POST /pair/verify { "code": "123456" }
// Returns 200 with server info if code matches, 403 if not.
// Only accessible from allowed origins (CORS validated above).
let rawPath = request.path.split(separator: "?").first.map(String.init) ?? request.path
if request.method == "POST" && rawPath == "/pair/verify" {
let currentCode = await self.pairingCode
guard !currentCode.isEmpty else {
Expand All @@ -238,6 +250,7 @@ actor LocalHTTPServer {
"paired": true,
"port": serverPort,
"server": "Perspective Intelligence Server",
"apiToken": await AccessControlManager.shared.tokenForOrigin(originHost) ?? "",
]
let data = (try? JSONSerialization.data(withJSONObject: obj, options: [])) ?? Data()
return .normal(HTTPResponse(status: 200, headers: Self.jsonHeaders(corsOrigin: corsOrigin), body: data))
Expand All @@ -248,6 +261,18 @@ actor LocalHTTPServer {
}
}

// Require per-application bearer tokens for model/API endpoints.
let authHeader = request.headers["authorization"] ?? request.headers["Authorization"]
switch await AccessControlManager.shared.authorize(path: rawPath, authHeader: authHeader, originHost: originHost) {
case .allow:
break
case .deny(let status, let message):
logger.warning("[req:\(rid, privacy: .public)] Auth denied: \(message, privacy: .public)")
let msg = ["error": ["message": message]]
let data = (try? JSONSerialization.data(withJSONObject: msg, options: [])) ?? Data()
return .normal(HTTPResponse(status: status, headers: Self.jsonHeaders(corsOrigin: corsOrigin), body: data))
}

// Normalize path: strip query string and trailing slash
let basePath: String = {
if let q = request.path.firstIndex(of: "?") { return String(request.path[..<q]) }
Expand Down
5 changes: 5 additions & 0 deletions Perspective Server/MenuBarContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
//

import SwiftUI
#if os(macOS)
import AppKit
#endif

struct MenuBarContentView: View {
@Environment(\.openWindow) private var openWindow
Expand All @@ -17,9 +20,11 @@ struct MenuBarContentView: View {
.environmentObject(serverController)
Divider()
Button("Open Dashboard") {
NSApp.activate(ignoringOtherApps: true)
openWindow(id: "dashboard")
}
Button("Open Chat Window") {
NSApp.activate(ignoringOtherApps: true)
openWindow(id: "chat")
}
Divider()
Expand Down
Loading