mirror of
https://github.com/wekan/wekan.git
synced 2026-02-21 15:34:07 +01:00
168 lines
4.1 KiB
JavaScript
168 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 };
|
||
|
|
}
|