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,
+ })
+ }
+})