Skip to content
Merged
18 changes: 12 additions & 6 deletions netlify-auth-site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -543,12 +543,18 @@ <h1 class="title">Account Access</h1>
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;
}
Expand Down
92 changes: 90 additions & 2 deletions netlify-auth-site/netlify/functions/_backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2314,6 +2401,7 @@ module.exports = {
handleCloudAuthReset,
handleTmdbProxy,
handleTraktProxy,
handleSimklProxy,
handleTvAuthApprove,
handleTvAuthComplete,
handleTvAuthStart,
Expand Down
3 changes: 3 additions & 0 deletions netlify-auth-site/netlify/functions/simkl-proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const { handleSimklProxy } = require("./_backend");

exports.handler = handleSimklProxy;
53 changes: 53 additions & 0 deletions netlify-auth-site/tests/redirect-uri.test.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
151 changes: 151 additions & 0 deletions supabase/functions/simkl-proxy/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, { count: number; resetTime: number }>()

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<string, string> = {
'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<string, unknown> = {}
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,
})
}
})
Loading