wekan/models/lib/attachmentUrlValidation.js
Lauri Ojansivu 62216e36c1 Fix GHSL-2026-045_Wekan.
Thanks to GHSL and xet7 !
2026-02-19 23:47:56 +02:00

167 lines
4.1 KiB
JavaScript

import { Meteor } from 'meteor/meteor';
let dnsModule;
let netModule;
let lookupSync;
if (Meteor.isServer) {
dnsModule = require('dns');
netModule = require('net');
lookupSync = Meteor.wrapAsync(dnsModule.lookup);
}
const BLOCKED_HOSTNAMES = new Set([
'localhost',
'localhost.localdomain',
'ip6-localhost',
'ip6-loopback',
'0',
'0.0.0.0',
]);
const IPV4_RANGES = [
['0.0.0.0', '0.255.255.255'],
['10.0.0.0', '10.255.255.255'],
['100.64.0.0', '100.127.255.255'],
['127.0.0.0', '127.255.255.255'],
['169.254.0.0', '169.254.255.255'],
['172.16.0.0', '172.31.255.255'],
['192.0.0.0', '192.0.0.255'],
['192.0.2.0', '192.0.2.255'],
['192.168.0.0', '192.168.255.255'],
['198.18.0.0', '198.19.255.255'],
['198.51.100.0', '198.51.100.255'],
['203.0.113.0', '203.0.113.255'],
['224.0.0.0', '239.255.255.255'],
['240.0.0.0', '255.255.255.255'],
].map(([start, end]) => ({
start: ipv4ToInt(start),
end: ipv4ToInt(end),
}));
function ipv4ToInt(ip) {
const parts = ip.split('.').map(part => parseInt(part, 10));
if (parts.length !== 4 || parts.some(part => Number.isNaN(part))) {
return null;
}
return parts.reduce((acc, part) => (acc << 8) + part, 0) >>> 0;
}
function isIpv4Blocked(ip) {
const value = ipv4ToInt(ip);
if (value === null) {
return true;
}
return IPV4_RANGES.some(range => value >= range.start && value <= range.end);
}
function isIpv6Blocked(ip) {
const normalized = ip.split('%')[0].toLowerCase();
if (normalized === '::' || normalized === '::1' || /^0(:0){1,7}$/.test(normalized)) {
return true;
}
if (normalized.startsWith('::ffff:')) {
const ipv4 = normalized.replace('::ffff:', '');
return isIpv4Blocked(ipv4);
}
if (normalized.startsWith('2001:db8')) {
return true;
}
const firstGroupRaw = normalized.split(':')[0];
const firstGroup = firstGroupRaw === '' ? '0' : firstGroupRaw;
const firstValue = parseInt(firstGroup, 16);
if (Number.isNaN(firstValue)) {
return true;
}
if (firstValue >= 0xfc00 && firstValue <= 0xfdff) {
return true;
}
if (firstValue >= 0xfe80 && firstValue <= 0xfebf) {
return true;
}
if (firstValue >= 0xff00) {
return true;
}
return false;
}
function isIpBlocked(ip) {
if (!netModule) {
return false;
}
const version = netModule.isIP(ip);
if (version === 4) {
return isIpv4Blocked(ip);
}
if (version === 6) {
return isIpv6Blocked(ip);
}
return true;
}
function resolveHostname(hostname) {
if (!lookupSync) {
return [];
}
try {
const results = lookupSync(hostname, { all: true });
if (Array.isArray(results)) {
return results.map(result => result.address);
}
if (results && results.address) {
return [results.address];
}
return [];
} catch (error) {
return null;
}
}
export function validateAttachmentUrl(urlString) {
if (!urlString || typeof urlString !== 'string') {
return { valid: false, reason: 'Empty URL' };
}
let parsed;
try {
parsed = new URL(urlString);
} catch (error) {
return { valid: false, reason: 'Invalid URL format' };
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
return { valid: false, reason: 'Only HTTP and HTTPS protocols are allowed' };
}
const hostname = parsed.hostname;
if (!hostname) {
return { valid: false, reason: 'Missing hostname' };
}
const lowerHostname = hostname.toLowerCase();
if (BLOCKED_HOSTNAMES.has(lowerHostname) || lowerHostname.endsWith('.localhost')) {
return { valid: false, reason: 'Localhost is not allowed' };
}
if (!Meteor.isServer || !netModule) {
return { valid: true };
}
if (netModule.isIP(lowerHostname)) {
return isIpBlocked(lowerHostname)
? { valid: false, reason: 'IP address is not allowed' }
: { valid: true };
}
const addresses = resolveHostname(lowerHostname);
if (!addresses || addresses.length === 0) {
return { valid: false, reason: 'Hostname did not resolve' };
}
const blockedAddress = addresses.find(address => isIpBlocked(address));
if (blockedAddress) {
return { valid: false, reason: 'Resolved IP address is not allowed' };
}
return { valid: true };
}