fix(security): harden sanitize, flatten, redaction, signature validation, CSP, proxies and CI

This commit is contained in:
alam00000 committed 2026-10-19 14:45:00 +05:30
1 parent b0a186839a
commit edd959f345
62 files changed
+4813 -926

No files matched your search

+1 -1
View File
@@ -83,7 +83,7 @@ npx wrangler deploy -c wasm-wrangler.toml
## Security Features
- **Origin validation**: Only allows requests from configured origins
- **Rate limiting**: 100 requests/minute per IP (requires KV namespace)
- **Rate limiting**: 100 requests/minute per IP, per /64 for IPv6 (requires KV namespace; fails open on KV errors)
- **File type restrictions**: Only WASM-related files (.js, .wasm, .data, etc.)
- **Size limits**: Max 100MB per file
- **Caching**: Reduces origin requests and improves performance
+166 -76
View File
@@ -81,53 +81,134 @@ function isAllowedOrigin(origin) {
return ALLOWED_ORIGINS.includes(origin);
}
function parseIPv4(address) {
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(address);
if (!match) return null;
const octets = match.slice(1).map(Number);
if (octets.some((octet) => octet > 255)) return null;
return octets.reduce((value, octet) => value * 256 + octet, 0);
}
function parseIPv6(address) {
let text = address.toLowerCase();
const embedded = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(text);
if (embedded) {
const ipv4 = parseIPv4(embedded[2]);
if (ipv4 === null) return null;
text = `${embedded[1]}${Math.floor(ipv4 / 65536).toString(16)}:${(ipv4 % 65536).toString(16)}`;
}
const sections = text.split('::');
if (sections.length > 2) return null;
const head = sections[0] ? sections[0].split(':') : [];
const tail =
sections.length === 2 && sections[1] ? sections[1].split(':') : [];
const missing = 8 - head.length - tail.length;
if (sections.length === 1 ? missing !== 0 : missing < 1) return null;
const groups = [...head, ...Array(missing).fill('0'), ...tail];
if (!groups.every((group) => /^[0-9a-f]{1,4}$/.test(group))) return null;
return groups.map((group) => parseInt(group, 16));
}
const BLOCKED_IPV4_RANGES = [
'0.0.0.0/8',
'10.0.0.0/8',
'100.64.0.0/10',
'127.0.0.0/8',
'169.254.0.0/16',
'172.16.0.0/12',
'192.0.0.0/24',
'192.0.2.0/24',
'192.168.0.0/16',
'198.18.0.0/15',
'198.51.100.0/24',
'203.0.113.0/24',
'224.0.0.0/4',
'240.0.0.0/4',
].map((cidr) => {
const [address, prefixLength] = cidr.split('/');
return { base: parseIPv4(address), size: 2 ** (32 - Number(prefixLength)) };
});
const BLOCKED_IPV6_RANGES = [
'::/128',
'::1/128',
'::/96',
'::ffff:0:0/96',
'64:ff9b::/96',
'64:ff9b:1::/48',
'100::/64',
'2001::/32',
'2001:db8::/32',
'2002::/16',
'fc00::/7',
'fe80::/10',
'fec0::/10',
'ff00::/8',
].map((cidr) => {
const [address, prefixLength] = cidr.split('/');
return { groups: parseIPv6(address), prefixLength: Number(prefixLength) };
});
function isBlockedIPv4(value) {
return BLOCKED_IPV4_RANGES.some(
({ base, size }) => value >= base && value < base + size
);
}
function isBlockedIPv6(groups) {
return BLOCKED_IPV6_RANGES.some((range) =>
range.groups.every((group, index) => {
const bitsInGroup = Math.min(
16,
Math.max(0, range.prefixLength - index * 16)
);
const groupMask = (0xffff << (16 - bitsInGroup)) & 0xffff;
return (groups[index] & groupMask) === (group & groupMask);
})
);
}
function isBlockedIp(address) {
const ipv4 = parseIPv4(address);
if (ipv4 !== null) return isBlockedIPv4(ipv4);
const ipv6Groups = parseIPv6(address);
if (ipv6Groups !== null) return isBlockedIPv6(ipv6Groups);
return true;
}
function isIpLiteral(host) {
return /^[\d.]+$/.test(host) || host.includes(':');
}
function getRateLimitKey(ip) {
const ipv6Groups = ip.includes(':') ? parseIPv6(ip) : null;
if (!ipv6Groups) return ip;
return `${ipv6Groups
.slice(0, 4)
.map((group) => group.toString(16))
.join(':')}::/64`;
}
function isPrivateOrReservedHost(hostname) {
if (
/^10\./.test(hostname) ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
/^192\.168\./.test(hostname) ||
/^169\.254\./.test(hostname) || // link-local (cloud metadata)
/^100\.(6[4-9]|[7-9]\d|1[0-1]\d|12[0-7])\./.test(hostname) || // CGNAT
/^127\./.test(hostname) ||
/^0\./.test(hostname)
) {
const host = hostname
.toLowerCase()
.replace(/^\[|\]$/g, '')
.replace(/\.$/, '');
if (['localhost', 'localhost.localdomain'].includes(host)) {
return true;
}
if (/^\d+$/.test(hostname)) {
return true;
}
const lower = hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (
lower === '::1' ||
lower.startsWith('::ffff:') ||
lower.startsWith('fe80') ||
lower.startsWith('fc') ||
lower.startsWith('fd') ||
lower.startsWith('ff')
) {
return true;
}
const blockedNames = [
'localhost',
'localhost.localdomain',
'0.0.0.0',
'[::1]',
];
if (blockedNames.includes(hostname.toLowerCase())) {
return true;
}
return false;
return isIpLiteral(host) && isBlockedIp(host);
}
async function hostnameResolvesToPrivate(hostname) {
const clean = hostname.replace(/^\[|\]$/g, '');
if (/^\d+\.\d+\.\d+\.\d+$/.test(clean) || clean.includes(':')) {
return isPrivateOrReservedHost(clean);
}
const clean = hostname
.toLowerCase()
.replace(/^\[|\]$/g, '')
.replace(/\.$/, '');
if (isPrivateOrReservedHost(clean)) return true;
if (isIpLiteral(clean)) return false;
const ips = [];
for (const type of ['A', 'AAAA']) {
try {
@@ -138,14 +219,16 @@ async function hostnameResolvesToPrivate(hostname) {
if (!res.ok) return true;
const data = await res.json();
for (const ans of data.Answer || []) {
if (ans && ans.data) ips.push(String(ans.data).replace(/\.$/, ''));
if (ans && (ans.type === 1 || ans.type === 28) && ans.data) {
ips.push(String(ans.data));
}
}
} catch {
return true;
}
}
if (ips.length === 0) return true;
return ips.some((ip) => isPrivateOrReservedHost(ip));
return ips.some((ip) => isBlockedIp(ip));
}
function isValidCertificateUrl(urlString) {
@@ -372,49 +455,56 @@ export default {
}
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
const rateLimitKey = `ratelimit:${clientIP}`;
const rateLimitKey = `ratelimit:${getRateLimitKey(clientIP)}`;
const now = Date.now();
if (env.RATE_LIMIT_KV) {
const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, {
type: 'json',
});
const requests = rateLimitData?.requests || [];
try {
const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, {
type: 'json',
});
const requests = rateLimitData?.requests || [];
const recentRequests = requests.filter(
(t) => now - t < RATE_LIMIT_WINDOW_MS
);
const recentRequests = requests.filter(
(t) => now - t < RATE_LIMIT_WINDOW_MS
);
if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) {
return new Response(
JSON.stringify({
error: 'Rate limit exceeded',
message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute. Please try again later.`,
retryAfter: Math.ceil(
(recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000
),
}),
{
status: 429,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
'Retry-After': Math.ceil(
if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) {
return new Response(
JSON.stringify({
error: 'Rate limit exceeded',
message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute. Please try again later.`,
retryAfter: Math.ceil(
(recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000
).toString(),
},
),
}),
{
status: 429,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
'Retry-After': Math.ceil(
(recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000
).toString(),
},
}
);
}
recentRequests.push(now);
await env.RATE_LIMIT_KV.put(
rateLimitKey,
JSON.stringify({ requests: recentRequests }),
{
expirationTtl: 120,
}
);
} catch (error) {
console.error(
'[CORS Proxy] Rate limit KV error, allowing request:',
error
);
}
recentRequests.push(now);
await env.RATE_LIMIT_KV.put(
rateLimitKey,
JSON.stringify({ requests: recentRequests }),
{
expirationTtl: 120,
}
);
} else {
console.warn(
'[CORS Proxy] RATE_LIMIT_KV not configured — rate limiting is disabled'
+97 -36
View File
@@ -39,9 +39,38 @@ const ALLOWED_EXTENSIONS = [
'.mem',
'.asm.js',
'.worker.js',
'.html',
'.whl',
];
const SAFE_SUBPATH = /^\/[A-Za-z0-9._/-]*$/;
function isSafeSubpath(subpath) {
return (
SAFE_SUBPATH.test(subpath) &&
!subpath.split('/').some((segment) => segment === '.' || segment === '..')
);
}
function getRateLimitKey(ip) {
if (!ip.includes(':') || ip.includes('.')) return ip;
const sections = ip.toLowerCase().split('::');
if (sections.length > 2) return ip;
const leadingGroups = sections[0] ? sections[0].split(':') : [];
const trailingGroups = sections[1] ? sections[1].split(':') : [];
const missing = 8 - leadingGroups.length - trailingGroups.length;
if (sections.length === 1 ? missing !== 0 : missing < 1) return ip;
const groups = [
...leadingGroups,
...Array(missing).fill('0'),
...trailingGroups,
];
if (!groups.every((group) => /^[0-9a-f]{1,4}$/.test(group))) return ip;
return `${groups
.slice(0, 4)
.map((group) => parseInt(group, 16).toString(16))
.join(':')}::/64`;
}
function isAllowedOrigin(origin) {
if (!origin) return true; // Allow no-origin requests (e.g., direct browser navigation)
return ALLOWED_ORIGINS.some((allowed) =>
@@ -66,6 +95,7 @@ function corsHeaders(origin) {
'Access-Control-Expose-Headers':
'Content-Length, Content-Range, Content-Type',
'Access-Control-Max-Age': '86400',
'X-Content-Type-Options': 'nosniff',
};
}
@@ -89,7 +119,6 @@ function getContentType(pathname) {
'.so': 'application/octet-stream',
'.zip': 'application/zip',
'.mem': 'application/octet-stream',
'.html': 'text/html',
};
return contentTypes[ext] || 'application/octet-stream';
}
@@ -115,7 +144,34 @@ async function proxyRequest(request, env, sourceBaseUrl, subpath, origin) {
? sourceBaseUrl.slice(0, -1)
: sourceBaseUrl;
const normalizedPath = subpath.startsWith('/') ? subpath : `/${subpath}`;
const targetUrl = `${normalizedBase}${normalizedPath}`;
let targetUrl = null;
if (isSafeSubpath(normalizedPath)) {
try {
const candidate = new URL(`${normalizedBase}${normalizedPath}`).href;
if (candidate.startsWith(new URL(`${normalizedBase}/`).href)) {
targetUrl = candidate;
}
} catch {
targetUrl = null;
}
}
if (!targetUrl) {
return new Response(
JSON.stringify({
error: 'Invalid path',
message: 'The requested path is not allowed.',
}),
{
status: 400,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
}
);
}
if (!isAllowedFile(normalizedPath)) {
return new Response(
@@ -151,8 +207,6 @@ async function proxyRequest(request, env, sourceBaseUrl, subpath, origin) {
JSON.stringify({
error: 'Failed to fetch resource',
status: response.status,
statusText: response.statusText,
targetUrl: targetUrl,
}),
{
status: response.status,
@@ -204,14 +258,14 @@ async function proxyRequest(request, env, sourceBaseUrl, subpath, origin) {
'Content-Type': getContentType(normalizedPath),
'Content-Length': bodyData.byteLength.toString(),
'Cache-Control': `public, max-age=${CACHE_TTL_SECONDS}`,
'X-Proxied-From': new URL(targetUrl).hostname,
},
});
} catch (error) {
console.error('[WASM Proxy] Fetch error:', error);
return new Response(
JSON.stringify({
error: 'Proxy error',
message: error.message,
message: 'Failed to fetch the requested resource',
}),
{
status: 500,
@@ -260,42 +314,49 @@ export default {
if (env.RATE_LIMIT_KV) {
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
const rateLimitKey = `wasm-ratelimit:${clientIP}`;
const rateLimitKey = `wasm-ratelimit:${getRateLimitKey(clientIP)}`;
const now = Date.now();
const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, {
type: 'json',
});
const requests = rateLimitData?.requests || [];
const recentRequests = requests.filter(
(t) => now - t < RATE_LIMIT_WINDOW_MS
);
try {
const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, {
type: 'json',
});
const requests = rateLimitData?.requests || [];
const recentRequests = requests.filter(
(t) => now - t < RATE_LIMIT_WINDOW_MS
);
if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) {
return new Response(
JSON.stringify({
error: 'Rate limit exceeded',
message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute.`,
}),
if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) {
return new Response(
JSON.stringify({
error: 'Rate limit exceeded',
message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute.`,
}),
{
status: 429,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
'Retry-After': '60',
},
}
);
}
recentRequests.push(now);
await env.RATE_LIMIT_KV.put(
rateLimitKey,
JSON.stringify({ requests: recentRequests }),
{
status: 429,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
'Retry-After': '60',
},
expirationTtl: 120,
}
);
} catch (error) {
console.error(
'[WASM Proxy] Rate limit KV error, allowing request:',
error
);
}
recentRequests.push(now);
await env.RATE_LIMIT_KV.put(
rateLimitKey,
JSON.stringify({ requests: recentRequests }),
{
expirationTtl: 120,
}
);
}
if (pathname.startsWith('/pymupdf/')) {