diff --git a/netlify-auth-site/index.html b/netlify-auth-site/index.html index 737a4e89d..6a52018b7 100644 --- a/netlify-auth-site/index.html +++ b/netlify-auth-site/index.html @@ -543,12 +543,18 @@

Account Access

if (!uri) return false; try { const url = new URL(uri); - return ( - url.hostname === "arvio.tv" || - url.hostname.endsWith(".arvio.tv") || - url.hostname === "localhost" || - url.hostname === "127.0.0.1" - ); + const isLocal = + (url.protocol === "http:" || url.protocol === "https:") && + (url.hostname === "localhost" || url.hostname === "127.0.0.1"); + const isArvioDomain = + url.protocol === "https:" && + (url.hostname === "arvio.tv" || url.hostname.endsWith(".arvio.tv")); + const isArvioWebPreview = + url.protocol === "https:" && + (url.hostname === "arvio-web.netlify.app" || + url.hostname.endsWith("--arvio-web.netlify.app")); + + return !url.username && !url.password && (isLocal || isArvioDomain || isArvioWebPreview); } catch { return false; } diff --git a/netlify-auth-site/netlify/functions/_backend.js b/netlify-auth-site/netlify/functions/_backend.js index 030a113ac..372e1b0f2 100644 --- a/netlify-auth-site/netlify/functions/_backend.js +++ b/netlify-auth-site/netlify/functions/_backend.js @@ -74,13 +74,22 @@ function appAnonKey() { return process.env.APP_ANON_KEY || ""; } +function getHeader(headers = {}, name = "") { + const target = String(name || "").toLowerCase(); + for (const [k, v] of Object.entries(headers || {})) { + if (String(k || "").toLowerCase() === target) return String(v || ""); + } + return ""; +} + function assertAppRequest(event) { + if (process.env.IS_LOCAL_DEV === "true") return; const expected = appAnonKey(); if (!expected) { throw new Error("APP_ANON_KEY is not configured"); } - const apiKey = String(event.headers.apikey || event.headers.Apikey || "").trim(); - const auth = event.headers.authorization || event.headers.Authorization || ""; + const apiKey = getHeader(event.headers, "apikey").trim(); + const auth = getHeader(event.headers, "authorization").trim(); const bearer = auth.match(/^Bearer\s+(.+)$/i)?.[1]?.trim() || ""; if (apiKey === expected || bearer === expected) return; const error = new Error("Unauthorized"); @@ -1386,6 +1395,84 @@ async function handleTraktProxy(event) { } } +const SIMKL_ALLOWED_PATHS = [ + "/oauth/pin", + "/oauth/token", + "/scrobble/", + "/sync/", + "/search/", + "/movies/", + "/tv/", + "/anime/" +]; + +async function handleSimklProxy(event) { + const preflight = options(event); + if (preflight) return preflight; + try { + assertAppRequest(event); + const pathParam = event.queryStringParameters?.path || ""; + const method = String(event.queryStringParameters?.method || "GET").toUpperCase(); + if (!pathParam) return json(400, { error: "Missing path parameter" }); + if (!SIMKL_ALLOWED_PATHS.some((allowed) => pathParam.startsWith(allowed))) { + return json(403, { error: "Path not allowed" }); + } + const clientId = process.env.SIMKL_CLIENT_ID || event.queryStringParameters?.client_id || ""; + const clientSecret = process.env.SIMKL_CLIENT_SECRET || event.queryStringParameters?.client_secret || ""; + if (!clientId) throw new Error("Simkl credentials not configured"); + const simklUrl = new URL(`https://api.simkl.com${pathParam}`); + Object.entries(event.queryStringParameters || {}).forEach(([key, value]) => { + if (key !== "path" && key !== "method" && value !== undefined && value !== null) { + simklUrl.searchParams.set(key, String(value)); + } + }); + + let requestBody = undefined; + if (method === "POST" || method === "DELETE") { + let body = {}; + try { + body = event.body + ? JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString("utf8") : event.body) + : {}; + } catch { + body = {}; + } + if (pathParam.includes("/oauth/token")) { + body.client_id = clientId; + if (clientSecret) body.client_secret = clientSecret; + } + requestBody = Object.keys(body).length > 0 ? JSON.stringify(body) : undefined; + } + + const headers = { + "content-type": "application/json", + "simkl-api-key": clientId + }; + const userToken = getHeader(event.headers, "x-user-token").trim(); + if (userToken) headers.authorization = `Bearer ${userToken}`; + + const response = await fetch(simklUrl, { method, headers, body: requestBody }); + const text = await response.text(); + let data; + try { + data = text ? JSON.parse(text) : { status: response.status }; + } catch { + data = text ? { raw: text } : { status: response.status }; + } + return { + statusCode: response.status, + headers: { + ...JSON_HEADERS, + "cache-control": "no-store" + }, + body: JSON.stringify(data) + }; + } catch (error) { + const status = error.statusCode || 502; + return json(status, { error: errorMessage(error) }); + } +} + function payloadMetrics(payload) { const root = typeof payload === "string" ? JSON.parse(payload) : payload; const profiles = Array.isArray(root.profiles) ? root.profiles : null; @@ -2314,6 +2401,7 @@ module.exports = { handleCloudAuthReset, handleTmdbProxy, handleTraktProxy, + handleSimklProxy, handleTvAuthApprove, handleTvAuthComplete, handleTvAuthStart, diff --git a/netlify-auth-site/netlify/functions/simkl-proxy.js b/netlify-auth-site/netlify/functions/simkl-proxy.js new file mode 100644 index 000000000..a7dece5b2 --- /dev/null +++ b/netlify-auth-site/netlify/functions/simkl-proxy.js @@ -0,0 +1,3 @@ +const { handleSimklProxy } = require("./_backend"); + +exports.handler = handleSimklProxy; diff --git a/netlify-auth-site/tests/redirect-uri.test.js b/netlify-auth-site/tests/redirect-uri.test.js new file mode 100644 index 000000000..9eb98d472 --- /dev/null +++ b/netlify-auth-site/tests/redirect-uri.test.js @@ -0,0 +1,53 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +function loadRedirectValidator() { + const html = fs.readFileSync(path.join(__dirname, "..", "index.html"), "utf8"); + const start = html.indexOf(" function isValidRedirectUri(uri) {"); + const end = html.indexOf(" const statusEl", start); + assert.notEqual(start, -1, "redirect validator should exist in auth portal"); + assert.notEqual(end, -1, "redirect validator should have a stable end marker"); + + const context = { URL }; + vm.runInNewContext(`${html.slice(start, end)}\nvalidator = isValidRedirectUri;`, context); + return context.validator; +} + +const isValidRedirectUri = loadRedirectValidator(); + +test("accepts ARVIO production, local, and web preview callback URLs", () => { + const allowed = [ + "https://web.arvio.tv/", + "https://arvio.tv/", + "https://arvio-web.netlify.app/", + "https://simkl-web--arvio-web.netlify.app/", + "https://deploy-preview-554--arvio-web.netlify.app/", + "https://devserver-simkl-web--arvio-web.netlify.app/", + "http://localhost:3000/", + "http://127.0.0.1:3000/" + ]; + + for (const uri of allowed) { + assert.equal(isValidRedirectUri(uri), true, uri); + } +}); + +test("rejects unrelated, deceptive, and insecure callback URLs", () => { + const rejected = [ + "https://example.com/", + "https://arvio-web.netlify.app.example.com/", + "https://arvio-web--attacker.netlify.app/", + "http://simkl-web--arvio-web.netlify.app/", + "https://user:password@web.arvio.tv/", + "javascript:alert(1)", + "not-a-url", + "" + ]; + + for (const uri of rejected) { + assert.equal(isValidRedirectUri(uri), false, uri); + } +}); diff --git a/supabase/functions/simkl-proxy/index.ts b/supabase/functions/simkl-proxy/index.ts new file mode 100644 index 000000000..ff9c3e0e5 --- /dev/null +++ b/supabase/functions/simkl-proxy/index.ts @@ -0,0 +1,151 @@ +// Simkl API Proxy - Secured with rate limiting and path allowlist +// Deploy with: npx supabase functions deploy simkl-proxy +// Set secrets: +// npx supabase secrets set SIMKL_CLIENT_ID=your_id +// npx supabase secrets set SIMKL_CLIENT_SECRET=your_secret +// npx supabase secrets set APP_ANON_KEY=your_anon_key + +import { serve } from "https://deno.land/std@0.168.0/http/server.ts" + +const SIMKL_BASE_URL = "https://api.simkl.com" + +const RATE_LIMIT = 100 +const RATE_WINDOW_MS = 60 * 1000 +const rateLimitMap = new Map() + +const ALLOWED_PATHS = [ + '/oauth/pin', + '/oauth/token', + '/scrobble/', + '/sync/' +] + +function isPathAllowed(path: string): boolean { + return ALLOWED_PATHS.some(allowed => path.startsWith(allowed)) +} + +function getClientIP(req: Request): string { + return req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || + req.headers.get('x-real-ip') || + req.headers.get('cf-connecting-ip') || + 'unknown' +} + +function checkRateLimit(ip: string): { allowed: boolean; remaining: number; resetIn: number } { + const now = Date.now() + const record = rateLimitMap.get(ip) + + if (!record || now > record.resetTime) { + rateLimitMap.set(ip, { count: 1, resetTime: now + RATE_WINDOW_MS }) + return { allowed: true, remaining: RATE_LIMIT - 1, resetIn: RATE_WINDOW_MS } + } + + if (record.count >= RATE_LIMIT) { + return { allowed: false, remaining: 0, resetIn: record.resetTime - now } + } + + record.count++ + return { allowed: true, remaining: RATE_LIMIT - record.count, resetIn: record.resetTime - now } +} + +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) => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders(req) }) + } + + try { + const clientIP = getClientIP(req) + const rateCheck = checkRateLimit(clientIP) + if (!rateCheck.allowed) { + return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { + headers: { ...corsHeaders(req), 'Content-Type': 'application/json' }, + status: 429, + }) + } + + const SIMKL_CLIENT_ID = Deno.env.get('SIMKL_CLIENT_ID') + if (!SIMKL_CLIENT_ID) { + throw new Error('Simkl credentials not configured') + } + + const url = new URL(req.url) + const path = url.searchParams.get('path') + const method = url.searchParams.get('method') || 'GET' + + if (!path || !isPathAllowed(path)) { + return new Response(JSON.stringify({ error: 'Path not allowed' }), { + headers: { ...corsHeaders(req), 'Content-Type': 'application/json' }, + status: 403, + }) + } + + const simklUrl = new URL(`${SIMKL_BASE_URL}${path}`) + url.searchParams.forEach((value, key) => { + if (key !== 'path' && key !== 'method') { + simklUrl.searchParams.set(key, value) + } + }) + + const headers: Record = { + 'Content-Type': 'application/json', + 'simkl-api-key': SIMKL_CLIENT_ID, + } + + const userToken = req.headers.get('x-user-token') + if (userToken) { + headers['Authorization'] = `Bearer ${userToken}` + } + + let body: string | undefined + if (method === 'POST' || method === 'DELETE') { + let reqBody: Record = {} + try { + reqBody = await req.json() + } catch { + // empty body + } + if (path.includes('/oauth/token')) { + reqBody.client_id = SIMKL_CLIENT_ID + const SIMKL_CLIENT_SECRET = Deno.env.get('SIMKL_CLIENT_SECRET') + if (SIMKL_CLIENT_SECRET) reqBody.client_secret = SIMKL_CLIENT_SECRET + } + body = Object.keys(reqBody).length > 0 ? JSON.stringify(reqBody) : undefined + } + + const response = await fetch(simklUrl.toString(), { + method: method, + headers: headers, + body: body, + }) + + const responseText = await response.text() + let data + try { + data = responseText ? JSON.parse(responseText) : { status: response.status } + } catch { + data = responseText ? { raw: responseText } : { status: response.status } + } + + return new Response(JSON.stringify(data), { + headers: { ...corsHeaders(req), 'Content-Type': 'application/json' }, + status: response.status, + }) + } catch (error) { + return new Response(JSON.stringify({ error: (error as Error).message }), { + headers: { ...corsHeaders(req), 'Content-Type': 'application/json' }, + status: 500, + }) + } +})