diff --git a/app/src/main/kotlin/com/arflix/tv/server/AiKeyConfigServer.kt b/app/src/main/kotlin/com/arflix/tv/server/AiKeyConfigServer.kt index f2b9da5f..15ae8cfa 100644 --- a/app/src/main/kotlin/com/arflix/tv/server/AiKeyConfigServer.kt +++ b/app/src/main/kotlin/com/arflix/tv/server/AiKeyConfigServer.kt @@ -2,13 +2,35 @@ package com.arflix.tv.server import fi.iki.elonen.NanoHTTPD import java.io.ByteArrayInputStream +import java.security.SecureRandom +/** + * Local HTTP server used to collect a user-provided AI API key during onboarding. + * + * Security hardening: require a one-time pairing token to be included with the + * POST to `/api/key`. The token is short-lived and displayed on-device (and + * encoded into the QR URL by the caller) which reduces the attack surface when + * the server is reachable on the local network. For stronger protection consider + * binding to localhost only or serving over TLS; this change implements a + * pragmatic one-time token improvement that preserves the previous UX (QR -> phone). + */ class AiKeyConfigServer( private val onKeyReceived: (String) -> Unit, private val logoProvider: (() -> ByteArray?)? = null, - port: Int = 8095 + port: Int = 8095, + /** + * Pairing token required for /api/key POSTs. If null, a random token will be generated. + */ + val pairingToken: String? = null ) : NanoHTTPD(port) { + private val token: String = pairingToken ?: generateToken() + /** + * Public accessor for the active pairing token (generated or provided). + */ + val currentPairingToken: String + get() = token + override fun serve(session: IHTTPSession): Response { val uri = session.uri val method = session.method @@ -18,6 +40,7 @@ class AiKeyConfigServer( method == Method.GET && uri == "/groq" -> serveHtml(AiKeyWebPage.getGroqHtml()) method == Method.GET && uri == "/gemini" -> serveHtml(AiKeyWebPage.getGeminiHtml()) method == Method.GET && uri == "/logo.png" -> serveLogo() + // Require the client to supply the pairing token when submitting the key. method == Method.POST && uri == "/api/key" -> handleKeySubmit(session, onKeyReceived) else -> newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Not found") } @@ -44,11 +67,21 @@ class AiKeyConfigServer( val bodyMap = HashMap() session.parseBody(bodyMap) val body = bodyMap["postData"] ?: "" - val key = try { - org.json.JSONObject(body).optString("key", "").trim() + val json = try { + org.json.JSONObject(body) } catch (e: Exception) { - "" + null + } + val key = json?.optString("key", "")?.trim() ?: "" + val suppliedToken = json?.optString("token", "") ?: "" + + // Validate pairing token + if (token.isNotEmpty() && suppliedToken != token) { + val forbidden = org.json.JSONObject().put("status", "forbidden").put("reason", "invalid_token") + return newFixedLengthResponse(Response.Status.FORBIDDEN, "application/json", forbidden.toString()) } + + // Accept key (empty string allowed to clear) onKeyReceived(key) val response = org.json.JSONObject().put("status", "saved") return newFixedLengthResponse(Response.Status.OK, "application/json", response.toString()) @@ -59,11 +92,12 @@ class AiKeyConfigServer( onKeyReceived: (String) -> Unit, logoProvider: (() -> ByteArray?)? = null, startPort: Int = 8095, - maxAttempts: Int = 10 + maxAttempts: Int = 10, + pairingToken: String? = null ): AiKeyConfigServer? { for (port in startPort until startPort + maxAttempts) { try { - val server = AiKeyConfigServer(onKeyReceived, logoProvider, port) + val server = AiKeyConfigServer(onKeyReceived, logoProvider, port, pairingToken) server.start(SOCKET_READ_TIMEOUT, false) return server } catch (e: Exception) { @@ -73,4 +107,10 @@ class AiKeyConfigServer( return null } } + + private fun generateToken(length: Int = 8): String { + val src = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + val rnd = SecureRandom() + return (1..length).map { src[rnd.nextInt(src.length)] }.joinToString("") + } } diff --git a/app/src/main/kotlin/com/arflix/tv/server/AiKeyWebPage.kt b/app/src/main/kotlin/com/arflix/tv/server/AiKeyWebPage.kt index e8f65fa8..70e6d6cb 100644 --- a/app/src/main/kotlin/com/arflix/tv/server/AiKeyWebPage.kt +++ b/app/src/main/kotlin/com/arflix/tv/server/AiKeyWebPage.kt @@ -128,7 +128,11 @@ async function saveKey() { btn.disabled = true; status.className = 'status'; try { - var res = await fetch('/api/key', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({key: key}) }); + // Read pairing token from query param (encoded in the QR URL). The server + // validates this token before accepting the key to reduce accidental/remote + // submissions from other devices on the local network. + var token = new URLSearchParams(window.location.search).get('t') || ''; + var res = await fetch('/api/key', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({key: key, token: token}) }); var data = await res.json(); if (data.status === 'saved') { status.className = 'status success'; @@ -191,7 +195,8 @@ async function saveKey() { btn.disabled = true; status.className = 'status'; try { - var res = await fetch('/api/key', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({key: key}) }); + var token = new URLSearchParams(window.location.search).get('t') || ''; + var res = await fetch('/api/key', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({key: key, token: token}) }); var data = await res.json(); if (data.status === 'saved') { status.className = 'status success'; diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 48fd5198..e91b873b 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -1287,7 +1287,9 @@ class SettingsViewModel @Inject constructor( ) ?: return@launch aiKeyServer = server val ip = DeviceIpAddress.get(context) ?: "device-ip" - val url = "http://$ip:${server.listeningPort}" + // Include the one-time pairing token as query param so the QR (scanned + // by a phone) encodes the token and the server can validate it. + val url = "http://$ip:${server.listeningPort}?t=${server.currentPairingToken}" val qr = runCatching { QrCodeGenerator.generate(url, 512) }.getOrNull() _uiState.value = _uiState.value.copy( aiKeyServerState = AiKeyServerState(isActive = true, serverUrl = url, qrBitmap = qr) diff --git a/supabase/functions/app-usage-event/index.ts b/supabase/functions/app-usage-event/index.ts index fb6efea3..5120aec9 100644 --- a/supabase/functions/app-usage-event/index.ts +++ b/supabase/functions/app-usage-event/index.ts @@ -1,9 +1,17 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts" -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, apikey, x-client-info, content-type, x-user-token", - "Access-Control-Allow-Methods": "POST, OPTIONS", +// CORS: restrict origins using env `CORS_ALLOWED_ORIGINS` (comma-separated). +const DEFAULT_ALLOWED_ORIGINS = (Deno.env.get('CORS_ALLOWED_ORIGINS') || 'https://auth.arvio.tv,https://arvio.tv').split(',').map(s => s.trim()).filter(Boolean) + +function corsHeaders(req: Request) { + const origin = req.headers.get('origin') || '' + const allowed = DEFAULT_ALLOWED_ORIGINS + const allowOrigin = allowed.includes(origin) ? origin : 'null' + return { + 'Access-Control-Allow-Origin': allowOrigin, + 'Access-Control-Allow-Headers': 'authorization, apikey, x-client-info, content-type, x-user-token', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + } } const RATE_LIMIT = 120 @@ -37,10 +45,10 @@ setInterval(() => { } }, RATE_WINDOW_MS) -function jsonResponse(body: Record, status = 200): Response { +function jsonResponse(req: Request, body: Record, status = 200): Response { return new Response(JSON.stringify(body), { status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -86,8 +94,8 @@ async function resolveUserId(supabaseUrl: string, anonKey: string, token: string } serve(async (req) => { - if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }) - if (req.method !== "POST") return jsonResponse({ error: "Method not allowed" }, 405) + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders(req) }) + if (req.method !== "POST") return jsonResponse(req, { error: "Method not allowed" }, 405) try { const anonHeader = req.headers.get("apikey") @@ -98,8 +106,8 @@ serve(async (req) => { const hasValidBearer = !!authHeader && authHeader.startsWith("Bearer ") && !!expectedAnon && authHeader.replace("Bearer ", "") === expectedAnon - if (!hasValidApiKey && !hasValidBearer) return jsonResponse({ error: "Unauthorized" }, 401) - if (!checkRateLimit(req)) return jsonResponse({ error: "Rate limit exceeded" }, 429) + if (!hasValidApiKey && !hasValidBearer) return jsonResponse(req, { error: "Unauthorized" }, 401) + if (!checkRateLimit(req)) return jsonResponse(req, { error: "Rate limit exceeded" }, 429) const supabaseUrl = Deno.env.get("SUPABASE_URL") const serviceRole = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") @@ -109,10 +117,10 @@ serve(async (req) => { const body = await req.json().catch(() => ({})) as Record const eventName = cleanText(body.event_name, 40) || "app_open" - if (eventName !== "app_open") return jsonResponse({ error: "Unsupported event" }, 400) + if (eventName !== "app_open") return jsonResponse(req, { error: "Unsupported event" }, 400) const installId = cleanText(body.install_id, 128) - if (!installId || installId.length < 8) return jsonResponse({ error: "Invalid install_id" }, 400) + if (!installId || installId.length < 8) return jsonResponse(req, { error: "Invalid install_id" }, 400) const userToken = cleanText(req.headers.get("x-user-token"), 4096) const userId = await resolveUserId(supabaseUrl, expectedAnon, userToken) @@ -151,9 +159,9 @@ serve(async (req) => { throw new Error(`Usage upsert failed: ${response.status} ${text}`) } - return jsonResponse({ ok: true }) + return jsonResponse(req, { ok: true }) } catch (error) { console.error(error) - return jsonResponse({ error: "Internal server error" }, 500) + return jsonResponse(req, { error: "Internal server error" }, 500) } }) diff --git a/supabase/functions/cloud-auth-email/index.ts b/supabase/functions/cloud-auth-email/index.ts index ea859431..5a00cf84 100644 --- a/supabase/functions/cloud-auth-email/index.ts +++ b/supabase/functions/cloud-auth-email/index.ts @@ -1,9 +1,17 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts" -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, apikey, x-client-info, content-type", - "Access-Control-Allow-Methods": "POST, OPTIONS", +// CORS: restrict origins using env `CORS_ALLOWED_ORIGINS` (comma-separated). +const DEFAULT_ALLOWED_ORIGINS = (Deno.env.get('CORS_ALLOWED_ORIGINS') || 'https://auth.arvio.tv,https://arvio.tv').split(',').map(s => s.trim()).filter(Boolean) + +function corsHeaders(req: Request) { + const origin = req.headers.get('origin') || '' + const allowed = DEFAULT_ALLOWED_ORIGINS + const allowOrigin = allowed.includes(origin) ? origin : 'null' + return { + 'Access-Control-Allow-Origin': allowOrigin, + 'Access-Control-Allow-Headers': 'authorization, apikey, x-client-info, content-type', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + } } const emailRegex = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}$/i @@ -201,13 +209,13 @@ function parseAuthError(raw: string): string { serve(async (req) => { if (req.method === "OPTIONS") { - return new Response("ok", { headers: corsHeaders }) + return new Response("ok", { headers: corsHeaders(req) }) } if (req.method !== "POST") { return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -223,7 +231,7 @@ serve(async (req) => { if (!hasValidApiKey && !hasValidBearer) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -246,14 +254,14 @@ serve(async (req) => { if (emailError) { return new Response(JSON.stringify({ error: emailError }), { status: 400, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } if (password.length < 6) { return new Response(JSON.stringify({ error: "Password must be at least 6 characters" }), { status: 400, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -261,7 +269,7 @@ serve(async (req) => { if (rateLimitError) { return new Response(JSON.stringify({ error: rateLimitError }), { status: 429, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -278,7 +286,7 @@ serve(async (req) => { if (!alreadyExists) { return new Response(JSON.stringify({ error: "Unable to create account" }), { status: 400, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } } @@ -291,7 +299,7 @@ serve(async (req) => { : "Account already exists. Sign in instead." return new Response(JSON.stringify({ error: message }), { status: tokenResp.status === 400 ? 409 : tokenResp.status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -301,12 +309,12 @@ serve(async (req) => { refresh_token: tokenJson.refresh_token, user: tokenJson.user, }), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } catch (error) { return new Response( JSON.stringify({ error: error instanceof Error ? error.message : "Unexpected error" }), - { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + { status: 500, headers: { ...corsHeaders(req), "Content-Type": "application/json" } }, ) } }) diff --git a/supabase/functions/cloud-auth-reset/index.ts b/supabase/functions/cloud-auth-reset/index.ts index 12071a13..a8bf572a 100644 --- a/supabase/functions/cloud-auth-reset/index.ts +++ b/supabase/functions/cloud-auth-reset/index.ts @@ -1,9 +1,17 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts" -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, apikey, x-client-info, content-type", - "Access-Control-Allow-Methods": "POST, OPTIONS", +// CORS: restrict origins using env `CORS_ALLOWED_ORIGINS` (comma-separated). +const DEFAULT_ALLOWED_ORIGINS = (Deno.env.get('CORS_ALLOWED_ORIGINS') || 'https://auth.arvio.tv,https://arvio.tv').split(',').map(s => s.trim()).filter(Boolean) + +function corsHeaders(req: Request) { + const origin = req.headers.get('origin') || '' + const allowed = DEFAULT_ALLOWED_ORIGINS + const allowOrigin = allowed.includes(origin) ? origin : 'null' + return { + 'Access-Control-Allow-Origin': allowOrigin, + 'Access-Control-Allow-Headers': 'authorization, apikey, x-client-info, content-type', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + } } const emailRegex = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}$/i @@ -161,13 +169,13 @@ function parseAuthError(raw: string): string { serve(async (req) => { if (req.method === "OPTIONS") { - return new Response("ok", { headers: corsHeaders }) + return new Response("ok", { headers: corsHeaders(req) }) } if (req.method !== "POST") { return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -183,7 +191,7 @@ serve(async (req) => { if (!hasValidApiKey && !hasValidBearer) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -205,7 +213,7 @@ serve(async (req) => { if (emailError) { return new Response(JSON.stringify({ error: emailError }), { status: 400, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -213,7 +221,7 @@ serve(async (req) => { if (rateLimitError) { return new Response(JSON.stringify({ error: rateLimitError }), { status: 429, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -234,17 +242,17 @@ serve(async (req) => { const resetText = await resetResp.text() return new Response(JSON.stringify({ error: parseAuthError(resetText) || "Password reset failed" }), { status: resetResp.status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } return new Response(JSON.stringify({ ok: true }), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } catch (error) { return new Response( JSON.stringify({ error: error instanceof Error ? error.message : "Unexpected error" }), - { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + { status: 500, headers: { ...corsHeaders(req), "Content-Type": "application/json" } }, ) } }) diff --git a/supabase/functions/tmdb-proxy/index.ts b/supabase/functions/tmdb-proxy/index.ts index 6680d629..c70d75ad 100644 --- a/supabase/functions/tmdb-proxy/index.ts +++ b/supabase/functions/tmdb-proxy/index.ts @@ -68,9 +68,18 @@ setInterval(() => { } }, 60000) -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +// CORS: restrict origins using env `CORS_ALLOWED_ORIGINS` (comma-separated). +// If not set, default to common safe origins used by the app. +const DEFAULT_ALLOWED_ORIGINS = (Deno.env.get('CORS_ALLOWED_ORIGINS') || 'https://auth.arvio.tv,https://arvio.tv').split(',').map(s => s.trim()).filter(Boolean) + +function corsHeaders(req: Request) { + const origin = req.headers.get('origin') || '' + const allowed = DEFAULT_ALLOWED_ORIGINS + const allowOrigin = allowed.includes(origin) ? origin : 'null' + return { + 'Access-Control-Allow-Origin': allowOrigin, + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + } } function errorMessage(error: unknown): string { @@ -126,7 +135,7 @@ async function fetchTmdbJson(tmdbUrl: URL): Promise { serve(async (req) => { // Handle CORS preflight if (req.method === 'OPTIONS') { - return new Response('ok', { headers: corsHeaders }) + return new Response('ok', { headers: corsHeaders(req) }) } try { @@ -140,7 +149,7 @@ serve(async (req) => { retryAfter: Math.ceil(rateCheck.resetIn / 1000) }), { headers: { - ...corsHeaders, + ...corsHeaders(req), 'Content-Type': 'application/json', 'Retry-After': String(Math.ceil(rateCheck.resetIn / 1000)), 'X-RateLimit-Limit': String(RATE_LIMIT), @@ -164,7 +173,7 @@ serve(async (req) => { if (!hasValidApiKey && !hasValidAuth) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + headers: { ...corsHeaders(req), 'Content-Type': 'application/json' }, status: 401, }) } @@ -185,7 +194,7 @@ serve(async (req) => { // Validate path against allowlist if (!isPathAllowed(path)) { return new Response(JSON.stringify({ error: 'Path not allowed' }), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + headers: { ...corsHeaders(req), 'Content-Type': 'application/json' }, status: 403, }) } @@ -205,7 +214,7 @@ serve(async (req) => { return new Response(JSON.stringify(result.data), { headers: { - ...corsHeaders, + ...corsHeaders(req), 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-RateLimit-Limit': String(RATE_LIMIT), @@ -216,7 +225,7 @@ serve(async (req) => { }) } catch (error) { return new Response(JSON.stringify({ error: errorMessage(error) }), { - headers: { ...corsHeaders, 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, + headers: { ...corsHeaders(req), 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, status: 502, }) } diff --git a/supabase/functions/trakt-proxy/index.ts b/supabase/functions/trakt-proxy/index.ts index ec398646..dded9e2f 100644 --- a/supabase/functions/trakt-proxy/index.ts +++ b/supabase/functions/trakt-proxy/index.ts @@ -73,15 +73,23 @@ setInterval(() => { } }, 60000) -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', // App uses native HTTP, CORS is for browser fallback - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, x-user-token', +// CORS: restrict origins using env `CORS_ALLOWED_ORIGINS` (comma-separated). +const DEFAULT_ALLOWED_ORIGINS = (Deno.env.get('CORS_ALLOWED_ORIGINS') || 'https://auth.arvio.tv,https://arvio.tv').split(',').map(s => s.trim()).filter(Boolean) + +function corsHeaders(req: Request) { + const origin = req.headers.get('origin') || '' + const allowed = DEFAULT_ALLOWED_ORIGINS + const allowOrigin = allowed.includes(origin) ? origin : 'null' + return { + 'Access-Control-Allow-Origin': allowOrigin, + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, x-user-token', + } } serve(async (req) => { // Handle CORS preflight if (req.method === 'OPTIONS') { - return new Response('ok', { headers: corsHeaders }) + return new Response('ok', { headers: corsHeaders(req) }) } try { @@ -95,7 +103,7 @@ serve(async (req) => { retryAfter: Math.ceil(rateCheck.resetIn / 1000) }), { headers: { - ...corsHeaders, + ...corsHeaders(req), 'Content-Type': 'application/json', 'Retry-After': String(Math.ceil(rateCheck.resetIn / 1000)), 'X-RateLimit-Limit': String(RATE_LIMIT), @@ -119,7 +127,7 @@ serve(async (req) => { if (!hasValidApiKey && !hasValidAuth) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + headers: { ...corsHeaders(req), 'Content-Type': 'application/json' }, status: 401, }) } @@ -142,7 +150,7 @@ serve(async (req) => { // Validate path against allowlist if (!isPathAllowed(path)) { return new Response(JSON.stringify({ error: 'Path not allowed' }), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + headers: { ...corsHeaders(req), 'Content-Type': 'application/json' }, status: 403, }) } @@ -224,7 +232,7 @@ serve(async (req) => { return new Response(JSON.stringify(data), { headers: { - ...corsHeaders, + ...corsHeaders(req), 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-RateLimit-Limit': String(RATE_LIMIT), @@ -238,7 +246,7 @@ serve(async (req) => { }) } catch (error) { return new Response(JSON.stringify({ error: error.message }), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + headers: { ...corsHeaders(req), 'Content-Type': 'application/json' }, status: 500, }) } diff --git a/supabase/functions/tv-auth-approve/index.ts b/supabase/functions/tv-auth-approve/index.ts index 225ffd43..4109e7a9 100644 --- a/supabase/functions/tv-auth-approve/index.ts +++ b/supabase/functions/tv-auth-approve/index.ts @@ -1,9 +1,17 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts" -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, apikey, x-client-info, content-type", - "Access-Control-Allow-Methods": "POST, OPTIONS", +// CORS: restrict origins using env `CORS_ALLOWED_ORIGINS` (comma-separated). +const DEFAULT_ALLOWED_ORIGINS = (Deno.env.get('CORS_ALLOWED_ORIGINS') || 'https://auth.arvio.tv,https://arvio.tv').split(',').map(s => s.trim()).filter(Boolean) + +function corsHeaders(req: Request) { + const origin = req.headers.get('origin') || '' + const allowed = DEFAULT_ALLOWED_ORIGINS + const allowOrigin = allowed.includes(origin) ? origin : 'null' + return { + 'Access-Control-Allow-Origin': allowOrigin, + 'Access-Control-Allow-Headers': 'authorization, apikey, x-client-info, content-type', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + } } type ApproveBody = { @@ -13,13 +21,13 @@ type ApproveBody = { serve(async (req) => { if (req.method === "OPTIONS") { - return new Response("ok", { headers: corsHeaders }) + return new Response("ok", { headers: corsHeaders(req) }) } if (req.method !== "POST") { return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -32,14 +40,14 @@ serve(async (req) => { if (!hasValidApiKey) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } if (!authHeader.startsWith("Bearer ")) { return new Response(JSON.stringify({ error: "Missing user access token" }), { status: 401, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -47,7 +55,7 @@ serve(async (req) => { if (!accessToken) { return new Response(JSON.stringify({ error: "Missing user access token" }), { status: 401, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -65,7 +73,7 @@ serve(async (req) => { if (!code || !refreshToken) { return new Response(JSON.stringify({ error: "Missing required fields" }), { status: 400, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -114,7 +122,7 @@ serve(async (req) => { if (!row) { return new Response(JSON.stringify({ error: "Invalid or expired code" }), { status: 400, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -122,7 +130,7 @@ serve(async (req) => { if (isExpired || row.status !== "pending") { return new Response(JSON.stringify({ error: "Code has expired" }), { status: 400, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -152,12 +160,12 @@ serve(async (req) => { } return new Response(JSON.stringify({ ok: true }), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } catch (error) { return new Response( JSON.stringify({ error: error instanceof Error ? error.message : "Unexpected error" }), - { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + { status: 500, headers: { ...corsHeaders(req), "Content-Type": "application/json" } }, ) } }) diff --git a/supabase/functions/tv-auth-complete/index.ts b/supabase/functions/tv-auth-complete/index.ts index 3b2dcf8d..7be63dc6 100644 --- a/supabase/functions/tv-auth-complete/index.ts +++ b/supabase/functions/tv-auth-complete/index.ts @@ -1,9 +1,17 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts" -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, apikey, x-client-info, content-type", - "Access-Control-Allow-Methods": "POST, OPTIONS", +// CORS: restrict origins using env `CORS_ALLOWED_ORIGINS` (comma-separated). +const DEFAULT_ALLOWED_ORIGINS = (Deno.env.get('CORS_ALLOWED_ORIGINS') || 'https://auth.arvio.tv,https://arvio.tv').split(',').map(s => s.trim()).filter(Boolean) + +function corsHeaders(req: Request) { + const origin = req.headers.get('origin') || '' + const allowed = DEFAULT_ALLOWED_ORIGINS + const allowOrigin = allowed.includes(origin) ? origin : 'null' + return { + 'Access-Control-Allow-Origin': allowOrigin, + 'Access-Control-Allow-Headers': 'authorization, apikey, x-client-info, content-type', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + } } const emailRegex = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}$/i @@ -153,7 +161,7 @@ function parseAuthError(raw: string): string { serve(async (req) => { if (req.method === "OPTIONS") { - return new Response("ok", { headers: corsHeaders }) + return new Response("ok", { headers: corsHeaders(req) }) } if (req.method !== "POST") { diff --git a/supabase/functions/tv-auth-start/index.ts b/supabase/functions/tv-auth-start/index.ts index 82f89af4..08711f29 100644 --- a/supabase/functions/tv-auth-start/index.ts +++ b/supabase/functions/tv-auth-start/index.ts @@ -1,8 +1,16 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts" -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, apikey, x-client-info, content-type", +// CORS: restrict origins using env `CORS_ALLOWED_ORIGINS` (comma-separated). +const DEFAULT_ALLOWED_ORIGINS = (Deno.env.get('CORS_ALLOWED_ORIGINS') || 'https://auth.arvio.tv,https://arvio.tv').split(',').map(s => s.trim()).filter(Boolean) + +function corsHeaders(req: Request) { + const origin = req.headers.get('origin') || '' + const allowed = DEFAULT_ALLOWED_ORIGINS + const allowOrigin = allowed.includes(origin) ? origin : 'null' + return { + 'Access-Control-Allow-Origin': allowOrigin, + 'Access-Control-Allow-Headers': 'authorization, apikey, x-client-info, content-type', + } } function baseUrlJoin(base: string, path: string): string { @@ -19,7 +27,7 @@ function randomCode(length: number): string { serve(async (req) => { if (req.method === "OPTIONS") { - return new Response("ok", { headers: corsHeaders }) + return new Response("ok", { headers: corsHeaders(req) }) } try { @@ -33,7 +41,7 @@ serve(async (req) => { if (!hasValidApiKey && !hasValidBearer) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -86,12 +94,12 @@ serve(async (req) => { expires_in: 600, interval: 3, }), - { headers: { ...corsHeaders, "Content-Type": "application/json" } }, + { headers: { ...corsHeaders(req), "Content-Type": "application/json" } }, ) } catch (error) { return new Response( JSON.stringify({ error: error instanceof Error ? error.message : "Unexpected error" }), - { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + { status: 500, headers: { ...corsHeaders(req), "Content-Type": "application/json" } }, ) } }) diff --git a/supabase/functions/tv-auth-status/index.ts b/supabase/functions/tv-auth-status/index.ts index 361a6dcc..1d5bb6d4 100644 --- a/supabase/functions/tv-auth-status/index.ts +++ b/supabase/functions/tv-auth-status/index.ts @@ -1,13 +1,21 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts" -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, apikey, x-client-info, content-type", +// CORS: restrict origins using env `CORS_ALLOWED_ORIGINS` (comma-separated). +const DEFAULT_ALLOWED_ORIGINS = (Deno.env.get('CORS_ALLOWED_ORIGINS') || 'https://auth.arvio.tv,https://arvio.tv').split(',').map(s => s.trim()).filter(Boolean) + +function corsHeaders(req: Request) { + const origin = req.headers.get('origin') || '' + const allowed = DEFAULT_ALLOWED_ORIGINS + const allowOrigin = allowed.includes(origin) ? origin : 'null' + return { + 'Access-Control-Allow-Origin': allowOrigin, + 'Access-Control-Allow-Headers': 'authorization, apikey, x-client-info, content-type', + } } serve(async (req) => { if (req.method === "OPTIONS") { - return new Response("ok", { headers: corsHeaders }) + return new Response("ok", { headers: corsHeaders(req) }) } try { @@ -21,7 +29,7 @@ serve(async (req) => { if (!hasValidApiKey && !hasValidBearer) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -36,7 +44,7 @@ serve(async (req) => { if (!deviceCode) { return new Response(JSON.stringify({ error: "device_code is required" }), { status: 400, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -60,7 +68,7 @@ serve(async (req) => { const row = rows[0] if (!row) { return new Response(JSON.stringify({ status: "expired", message: "Session not found" }), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -77,7 +85,7 @@ serve(async (req) => { body: JSON.stringify({ status: "expired" }), }) return new Response(JSON.stringify({ status: "expired", message: "Code expired" }), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } @@ -104,23 +112,23 @@ serve(async (req) => { refresh_token: row.refresh_token, email: row.user_email, }), - { headers: { ...corsHeaders, "Content-Type": "application/json" } }, + { headers: { ...corsHeaders(req), "Content-Type": "application/json" } }, ) } if (row.status === "expired" || row.status === "consumed") { return new Response(JSON.stringify({ status: "expired", message: "Code expired" }), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } return new Response(JSON.stringify({ status: "pending" }), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders(req), "Content-Type": "application/json" }, }) } catch (error) { return new Response( JSON.stringify({ error: error instanceof Error ? error.message : "Unexpected error" }), - { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + { status: 500, headers: { ...corsHeaders(req), "Content-Type": "application/json" } }, ) } })