Files
paste.es/api/lib/utils.ts
Malin bc9f96cbd4 feat: rebrand Hemmelig to paste.es for cloudhost.es
- Set Spanish as default language with ephemeral/encrypted privacy focus
- Translate all user-facing strings and legal pages to Spanish
- Replace Norwegian flag with Spanish flag in footer
- Remove Hemmelig/terces.cloud links, add cloudhost.es sponsorship
- Rewrite PrivacyPage: zero data collection, ephemeral design emphasis
- Rewrite TermsPage: Spanish law, RGPD, paste.es/CloudHost.es references
- Update PWA manifest, HTML meta tags, package.json branding
- Rename webhook headers to X-Paste-Event / X-Paste-Signature
- Update API docs title and contact to paste.es / cloudhost.es

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 09:30:19 +01:00

131 lines
3.7 KiB
TypeScript

import dns from 'dns/promises';
import { type Context } from 'hono';
import { isIP } from 'is-ip';
/**
* Handle not found error from Prisma
* @param error Error from Prisma operation
* @param c Hono context
* @returns JSON error response
*/
export const handleNotFound = (error: Error & { code?: string }, c: Context) => {
// Handle record not found error (Prisma P2025)
if (error?.code === 'P2025') {
return c.json({ error: 'Not found' }, 404);
}
// Handle other errors
return c.json(
{
error: 'Failed to process the operation',
},
500
);
};
/**
* Get client IP from request headers
* @param c Hono context
* @returns Client IP address
*/
export const getClientIp = (c: Context): string => {
const forwardedFor = c.req.header('x-forwarded-for');
if (forwardedFor) {
return forwardedFor.split(',')[0].trim();
}
return (
c.req.header('x-real-ip') ||
c.req.header('cf-connecting-ip') ||
c.req.header('client-ip') ||
c.req.header('x-client-ip') ||
c.req.header('x-cluster-client-ip') ||
c.req.header('forwarded-for') ||
c.req.header('forwarded') ||
c.req.header('via') ||
'127.0.0.1'
);
};
// Patterns for private/internal IP addresses
const privateIpPatterns = [
// Localhost variants
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
/^0\.0\.0\.0$/,
// Private IPv4 ranges
/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
/^192\.168\.\d{1,3}\.\d{1,3}$/,
/^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/,
// Link-local IPv4
/^169\.254\.\d{1,3}\.\d{1,3}$/,
// IPv6 localhost
/^::1$/,
/^\[::1\]$/,
// IPv6 link-local
/^fe80:/i,
// IPv6 private (unique local addresses)
/^fc00:/i,
/^fd[0-9a-f]{2}:/i,
];
// Patterns for special domains that should always be blocked
const blockedHostnamePatterns = [
/^localhost$/,
/\.local$/,
/\.internal$/,
/\.localhost$/,
/\.localdomain$/,
];
/**
* Check if an IP address is private/internal
* @param ip IP address to check
* @returns true if IP is private/internal
*/
const isPrivateIp = (ip: string): boolean => {
return privateIpPatterns.some((pattern) => pattern.test(ip));
};
/**
* Check if a URL points to a private/internal address (SSRF protection)
* Resolves DNS to check actual IP addresses, preventing DNS rebinding attacks.
* @param url URL string to validate
* @returns Promise<true> if URL is safe (not internal), Promise<false> if it's a private/internal address
*/
export const isPublicUrl = async (url: string): Promise<boolean> => {
try {
const parsed = new URL(url);
const hostname = parsed.hostname.toLowerCase();
// Block special domain patterns (e.g., .local, .localhost)
if (blockedHostnamePatterns.some((pattern) => pattern.test(hostname))) {
return false;
}
// If hostname is already an IP address, check it directly
if (isIP(hostname)) {
return !isPrivateIp(hostname);
}
// Resolve DNS to get actual IP addresses
let addresses: string[] = [];
try {
const ipv4Addresses = await dns.resolve4(hostname).catch(() => []);
const ipv6Addresses = await dns.resolve6(hostname).catch(() => []);
addresses = [...ipv4Addresses, ...ipv6Addresses];
} catch {
// DNS resolution failed - reject for safety
return false;
}
// Require at least one resolvable address
if (addresses.length === 0) {
return false;
}
// Check all resolved IPs - reject if ANY resolve to private addresses
return !addresses.some((ip) => isPrivateIp(ip));
} catch {
return false;
}
};