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:
2025-12-05 14:21:52 -05:00
parent 55e4c5ff0c
commit e18aa3f42c
44 changed files with 3512 additions and 892 deletions

View File

@@ -2,16 +2,74 @@
* API endpoint to serve cached images from the database
* Serves avatars, emojis, attachments, etc. from local cache
*
* Note: This endpoint is intentionally unauthenticated because:
* 1. Image tags don't reliably send auth cookies on initial load
* 2. Image IDs are not guessable (you need access to the messages API first)
* 3. The underlying Discord images are semi-public anyway
* Security: Uses HMAC signatures to prevent enumeration of image IDs.
* Images can only be accessed with a valid signature generated server-side.
*/
import sql from '../../../utils/db.js';
import crypto from 'crypto';
import {
checkRateLimit,
recordRequest,
} from '../../../utils/rateLimit.js';
// Secret for signing image IDs - prevents enumeration attacks
const IMAGE_CACHE_SECRET = import.meta.env.IMAGE_CACHE_SECRET;
if (!IMAGE_CACHE_SECRET) {
console.error('CRITICAL: IMAGE_CACHE_SECRET environment variable is not set!');
}
/**
* Generate HMAC signature for an image ID
* @param {string|number} imageId - The image ID to sign
* @returns {string} - The hex signature
*/
export function signImageId(imageId) {
if (!IMAGE_CACHE_SECRET) {
throw new Error('IMAGE_CACHE_SECRET not configured');
}
const hmac = crypto.createHmac('sha256', IMAGE_CACHE_SECRET);
hmac.update(String(imageId));
return hmac.digest('hex').substring(0, 16); // Short signature is sufficient
}
/**
* Verify HMAC signature for an image ID
* @param {string|number} imageId - The image ID
* @param {string} signature - The signature to verify
* @returns {boolean} - Whether signature is valid
*/
function verifyImageSignature(imageId, signature) {
if (!IMAGE_CACHE_SECRET || !signature) return false;
const expected = signImageId(imageId);
// Timing-safe comparison
try {
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
} catch {
return false;
}
}
export async function GET({ request }) {
// Rate limit check - higher limit for images but still protected
const rateCheck = checkRateLimit(request, {
limit: 100,
windowMs: 1000,
burstLimit: 500,
burstWindowMs: 10_000,
});
if (!rateCheck.allowed) {
return new Response('Rate limit exceeded', {
status: 429,
headers: { 'Retry-After': '1' },
});
}
recordRequest(request, 1000);
const url = new URL(request.url);
const imageId = url.searchParams.get('id');
const signature = url.searchParams.get('sig');
const sourceUrl = url.searchParams.get('url');
if (!imageId && !sourceUrl) {
@@ -23,11 +81,16 @@ export async function GET({ request }) {
return new Response('Invalid image id', { status: 400 });
}
// Require valid signature for ID-based lookups to prevent enumeration
if (imageId && !verifyImageSignature(imageId, signature)) {
return new Response('Invalid or missing signature', { status: 403 });
}
try {
let image;
if (imageId) {
// Look up by image_id
// Look up by image_id (signature already verified above)
const result = await sql`
SELECT image_data, content_type, source_url
FROM image_cache
@@ -35,7 +98,7 @@ export async function GET({ request }) {
`;
image = result[0];
} else {
// Look up by source_url
// Look up by source_url - no signature needed as URL itself is the identifier
const result = await sql`
SELECT image_data, content_type, source_url
FROM image_cache