feat(api): implement rate limiting and SSRF protection across endpoints
- Added rate limiting to `reaction-users`, `search`, and `image-proxy` APIs to prevent abuse. - Introduced SSRF protection in `image-proxy` to block requests to private IP ranges. - Enhanced `link-preview` to use `linkedom` for HTML parsing and improved meta tag extraction. - Refactored authentication checks in various pages to utilize middleware for cleaner code. - Improved JWT key loading with error handling and security warnings for production. - Updated `authFetch` utility to handle token refresh more efficiently with deduplication. - Enhanced rate limiting utility to trust proxy headers from known sources. - Numerous layout / design changes
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Server-side link preview API endpoint
|
||||
* Fetches Open Graph / meta data for URLs to prevent user IP exposure
|
||||
* Server-side link preview API endpoint (Node.js / Astro)
|
||||
* Uses linkedom for reliable HTML parsing and automatic entity decoding
|
||||
* Returns signed proxy URLs for images from untrusted domains
|
||||
*/
|
||||
|
||||
@@ -12,61 +12,29 @@ import {
|
||||
createNonceCookie,
|
||||
} from '../../utils/rateLimit.js';
|
||||
import { signImageUrl } from './image-proxy.js';
|
||||
import { parseHTML } from 'linkedom';
|
||||
|
||||
// Trusted domains that can be loaded client-side (embed-safe providers)
|
||||
// Trusted domains that can be loaded client-side
|
||||
const TRUSTED_DOMAINS = new Set([
|
||||
'youtube.com',
|
||||
'www.youtube.com',
|
||||
'youtu.be',
|
||||
'img.youtube.com',
|
||||
'i.ytimg.com',
|
||||
'instagram.com',
|
||||
'www.instagram.com',
|
||||
'twitter.com',
|
||||
'x.com',
|
||||
'www.twitter.com',
|
||||
'pbs.twimg.com',
|
||||
'abs.twimg.com',
|
||||
'twitch.tv',
|
||||
'www.twitch.tv',
|
||||
'clips.twitch.tv',
|
||||
'spotify.com',
|
||||
'open.spotify.com',
|
||||
'soundcloud.com',
|
||||
'www.soundcloud.com',
|
||||
'vimeo.com',
|
||||
'www.vimeo.com',
|
||||
'imgur.com',
|
||||
'i.imgur.com',
|
||||
'giphy.com',
|
||||
'media.giphy.com',
|
||||
'tenor.com',
|
||||
'media.tenor.com',
|
||||
'youtube.com', 'www.youtube.com', 'youtu.be', 'img.youtube.com', 'i.ytimg.com',
|
||||
'instagram.com', 'www.instagram.com',
|
||||
'twitter.com', 'x.com', 'www.twitter.com', 'pbs.twimg.com', 'abs.twimg.com',
|
||||
'twitch.tv', 'www.twitch.tv', 'clips.twitch.tv',
|
||||
'spotify.com', 'open.spotify.com',
|
||||
'soundcloud.com', 'www.soundcloud.com',
|
||||
'vimeo.com', 'www.vimeo.com',
|
||||
'imgur.com', 'i.imgur.com',
|
||||
'giphy.com', 'media.giphy.com',
|
||||
'tenor.com', 'media.tenor.com',
|
||||
'gfycat.com',
|
||||
'reddit.com',
|
||||
'www.reddit.com',
|
||||
'v.redd.it',
|
||||
'i.redd.it',
|
||||
'preview.redd.it',
|
||||
'github.com',
|
||||
'gist.github.com',
|
||||
'raw.githubusercontent.com',
|
||||
'avatars.githubusercontent.com',
|
||||
'user-images.githubusercontent.com',
|
||||
'codepen.io',
|
||||
'codesandbox.io',
|
||||
'streamable.com',
|
||||
'medal.tv',
|
||||
'discord.com',
|
||||
'cdn.discordapp.com',
|
||||
'media.discordapp.net',
|
||||
'picsum.photos',
|
||||
'images.unsplash.com',
|
||||
'reddit.com', 'www.reddit.com', 'v.redd.it', 'i.redd.it', 'preview.redd.it',
|
||||
'github.com', 'gist.github.com', 'raw.githubusercontent.com', 'avatars.githubusercontent.com', 'user-images.githubusercontent.com',
|
||||
'codepen.io', 'codesandbox.io',
|
||||
'streamable.com', 'medal.tv',
|
||||
'discord.com', 'cdn.discordapp.com', 'media.discordapp.net',
|
||||
'picsum.photos', 'images.unsplash.com',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if a URL is from a trusted domain
|
||||
*/
|
||||
function isTrustedDomain(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
@@ -76,22 +44,13 @@ function isTrustedDomain(url) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a safe image URL - either direct (if trusted) or signed proxy URL
|
||||
*/
|
||||
async function getSafeImageUrl(imageUrl) {
|
||||
if (!imageUrl) return null;
|
||||
if (isTrustedDomain(imageUrl)) {
|
||||
return imageUrl; // Trusted, return as-is
|
||||
}
|
||||
// Create signed proxy URL
|
||||
if (isTrustedDomain(imageUrl)) return imageUrl;
|
||||
const signature = await signImageUrl(imageUrl);
|
||||
return `/api/image-proxy?url=${encodeURIComponent(imageUrl)}&sig=${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Open Graph and meta tags from HTML
|
||||
*/
|
||||
function parseMetaTags(html, url) {
|
||||
const meta = {
|
||||
url,
|
||||
@@ -104,101 +63,69 @@ function parseMetaTags(html, url) {
|
||||
themeColor: null,
|
||||
};
|
||||
|
||||
// Helper to extract content from meta tags
|
||||
const getMetaContent = (pattern) => {
|
||||
const match = html.match(pattern);
|
||||
return match ? decodeHTMLEntities(match[1]) : null;
|
||||
};
|
||||
const decode = str => str?.replace(/&(#(?:x[0-9a-fA-F]+|\d+)|[a-zA-Z]+);/g,
|
||||
(_, e) => e[0]==='#' ? String.fromCharCode(e[1]==='x'?parseInt(e.slice(2),16):parseInt(e.slice(1),10))
|
||||
: ({amp:'&',lt:'<',gt:'>',quot:'"',apos:"'"}[e]||_));
|
||||
|
||||
// Open Graph tags
|
||||
meta.title = getMetaContent(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i);
|
||||
|
||||
meta.description = getMetaContent(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i);
|
||||
|
||||
meta.image = getMetaContent(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i);
|
||||
|
||||
meta.siteName = getMetaContent(/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:site_name["']/i);
|
||||
|
||||
meta.type = getMetaContent(/<meta[^>]+property=["']og:type["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:type["']/i);
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
meta.video = getMetaContent(/<meta[^>]+property=["']og:video(?::url)?["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:video(?::url)?["']/i);
|
||||
|
||||
// Twitter cards fallback
|
||||
if (!meta.title) {
|
||||
meta.title = getMetaContent(/<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:title["']/i);
|
||||
}
|
||||
if (!meta.description) {
|
||||
meta.description = getMetaContent(/<meta[^>]+name=["']twitter:description["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:description["']/i);
|
||||
}
|
||||
if (!meta.image) {
|
||||
meta.image = getMetaContent(/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:image["']/i);
|
||||
}
|
||||
// Open Graph / Twitter / fallback
|
||||
meta.title =
|
||||
decode(
|
||||
document.querySelector('meta[property="og:title"]')?.getAttribute('content') ||
|
||||
document.querySelector('meta[name="twitter:title"]')?.getAttribute('content') ||
|
||||
document.querySelector('title')?.textContent || null
|
||||
);
|
||||
|
||||
// Theme color
|
||||
meta.themeColor = getMetaContent(/<meta[^>]+name=["']theme-color["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']theme-color["']/i);
|
||||
meta.description =
|
||||
decode(
|
||||
document.querySelector('meta[property="og:description"]')?.getAttribute('content') ||
|
||||
document.querySelector('meta[name="twitter:description"]')?.getAttribute('content') ||
|
||||
document.querySelector('meta[name="description"]')?.getAttribute('content') || null
|
||||
);
|
||||
|
||||
// Fallback to standard meta tags and title
|
||||
if (!meta.title) {
|
||||
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||
meta.title = titleMatch ? decodeHTMLEntities(titleMatch[1]) : null;
|
||||
}
|
||||
if (!meta.description) {
|
||||
meta.description = getMetaContent(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i);
|
||||
}
|
||||
meta.image =
|
||||
decode(
|
||||
document.querySelector('meta[property="og:image"]')?.getAttribute('content') ||
|
||||
document.querySelector('meta[name="twitter:image"]')?.getAttribute('content') || null
|
||||
);
|
||||
|
||||
meta.siteName =
|
||||
decode(
|
||||
document.querySelector('meta[property="og:site_name"]')?.getAttribute('content') ||
|
||||
new URL(url).hostname.replace(/^www\./, '')
|
||||
);
|
||||
|
||||
meta.type =
|
||||
decode(
|
||||
document.querySelector('meta[property="og:type"]')?.getAttribute('content') || null
|
||||
);
|
||||
|
||||
meta.video =
|
||||
decode(
|
||||
document.querySelector('meta[property="og:video"]')?.getAttribute('content') || null
|
||||
);
|
||||
|
||||
meta.themeColor =
|
||||
decode(
|
||||
document.querySelector('meta[name="theme-color"]')?.getAttribute('content') || null
|
||||
);
|
||||
|
||||
// Resolve relative image URLs
|
||||
if (meta.image && !meta.image.startsWith('http')) {
|
||||
try {
|
||||
const baseUrl = new URL(url);
|
||||
meta.image = new URL(meta.image, baseUrl.origin).href;
|
||||
meta.image = decode(new URL(meta.image, new URL(url).origin).href);
|
||||
} catch {
|
||||
meta.image = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get site name from domain if not found
|
||||
if (!meta.siteName) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
meta.siteName = parsed.hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode HTML entities
|
||||
*/
|
||||
function decodeHTMLEntities(text) {
|
||||
if (!text) return text;
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(///g, '/')
|
||||
.replace(/&#(\d+);/g, (_, num) => String.fromCharCode(parseInt(num, 10)))
|
||||
.replace(/&#x([a-fA-F0-9]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
||||
}
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit check
|
||||
// Rate limit
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 10,
|
||||
windowMs: 1000,
|
||||
@@ -208,25 +135,18 @@ export async function GET({ request }) {
|
||||
|
||||
let cookieId = getCookieId(request);
|
||||
const hadCookie = !!cookieId;
|
||||
if (!cookieId) {
|
||||
cookieId = generateNonce();
|
||||
}
|
||||
if (!cookieId) cookieId = generateNonce();
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
const errorMsg = rateCheck.isFlooding
|
||||
? { error: 'Too many requests - please slow down' }
|
||||
: { error: 'Rate limit exceeded' };
|
||||
const response = new Response(JSON.stringify(errorMsg), {
|
||||
const resp = new Response(JSON.stringify(errorMsg), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '1',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
if (!hadCookie) resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
return resp;
|
||||
}
|
||||
|
||||
recordRequest(request, 1000);
|
||||
@@ -241,13 +161,11 @@ export async function GET({ request }) {
|
||||
});
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
// Validate URL
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(targetUrl);
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||
throw new Error('Invalid protocol');
|
||||
}
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) throw new Error();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid URL' }), {
|
||||
status: 400,
|
||||
@@ -255,9 +173,8 @@ export async function GET({ request }) {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if it's a trusted domain (client can fetch directly)
|
||||
const trusted = isTrustedDomain(targetUrl);
|
||||
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
@@ -286,71 +203,44 @@ export async function GET({ request }) {
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
// Handle image URLs directly - return safe (possibly proxied) URL
|
||||
|
||||
// Handle direct image
|
||||
if (contentType.startsWith('image/')) {
|
||||
const safeImageUrl = await getSafeImageUrl(targetUrl);
|
||||
const result = {
|
||||
url: targetUrl,
|
||||
type: 'image',
|
||||
image: safeImageUrl,
|
||||
trusted,
|
||||
};
|
||||
const result = { url: targetUrl, type: 'image', image: safeImageUrl, trusted };
|
||||
const resp = new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
if (!hadCookie) resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
return resp;
|
||||
}
|
||||
|
||||
// Handle video URLs directly (no proxy for video - too large)
|
||||
// Handle direct video
|
||||
if (contentType.startsWith('video/')) {
|
||||
// Only allow trusted video sources
|
||||
if (!trusted) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Untrusted video source',
|
||||
}), {
|
||||
return new Response(JSON.stringify({ error: 'Untrusted video source' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const result = {
|
||||
url: targetUrl,
|
||||
type: 'video',
|
||||
video: targetUrl,
|
||||
trusted,
|
||||
};
|
||||
const result = { url: targetUrl, type: 'video', video: targetUrl, trusted };
|
||||
const resp = new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
if (!hadCookie) resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
return resp;
|
||||
}
|
||||
|
||||
// Only parse HTML
|
||||
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'URL is not an HTML page',
|
||||
contentType
|
||||
}), {
|
||||
return new Response(JSON.stringify({ error: 'URL is not an HTML page', contentType }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Read only the first 50KB to get meta tags (they're usually in <head>)
|
||||
// Read first 50KB
|
||||
const reader = response.body.getReader();
|
||||
let html = '';
|
||||
let bytesRead = 0;
|
||||
@@ -361,37 +251,27 @@ export async function GET({ request }) {
|
||||
if (done) break;
|
||||
html += new TextDecoder().decode(value);
|
||||
bytesRead += value.length;
|
||||
// Stop early if we've passed </head>
|
||||
if (html.includes('</head>')) break;
|
||||
}
|
||||
reader.cancel();
|
||||
|
||||
const meta = parseMetaTags(html, targetUrl);
|
||||
meta.trusted = trusted;
|
||||
|
||||
// Convert image URL to safe URL (proxy if untrusted)
|
||||
if (meta.image) {
|
||||
meta.image = await getSafeImageUrl(meta.image);
|
||||
}
|
||||
|
||||
// Convert image to safe URL
|
||||
if (meta.image) meta.image = await getSafeImageUrl(meta.image);
|
||||
|
||||
const resp = new Response(JSON.stringify(meta), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
if (!hadCookie) resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
return resp;
|
||||
|
||||
} catch (err) {
|
||||
console.error('[link-preview] Error fetching URL:', err.message);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Failed to fetch preview',
|
||||
message: err.message
|
||||
}), {
|
||||
// Don't expose internal error details to client
|
||||
return new Response(JSON.stringify({ error: 'Failed to fetch preview' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user