diff --git a/README.md b/README.md index c36663498..51f36be52 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,19 @@ The app uses [`kubo-rpc-client`](https://github.com/ipfs/js-kubo-rpc-client) to The app is built with [`create-react-app`](https://github.com/facebook/create-react-app). Please read the [docs](https://github.com/facebook/create-react-app/blob/main/packages/cra-template/template/README.md#table-of-contents). +## Security Considerations + +### Subdomain Gateway Security + +When configuring subdomain gateways in the settings, consider the following security best practices: + +- **Use HTTPS**: Always prefer HTTPS gateways over HTTP to ensure encrypted communication +- **Avoid suspicious domains**: Be cautious with free domains (`.tk`, `.ml`, `.ga`, `.cf`) or very short domain names that may be typosquatting attempts +- **Separate domains**: For production applications, consider using a completely separate domain for your IPFS gateway rather than a subdomain of your main application domain to avoid same-site security issues +- **Well-known providers**: Prefer established IPFS gateway providers like `dweb.link`, `ipfs.io`, or `gateway.pinata.cloud` + +The WebUI includes security validation that will warn you about potentially risky gateway configurations and provide a security score to help you make informed decisions. + ## Maintenance diff --git a/src/bundles/gateway.js b/src/bundles/gateway.js index fa8019bf1..95f2581aa 100644 --- a/src/bundles/gateway.js +++ b/src/bundles/gateway.js @@ -51,6 +51,109 @@ export const checkValidHttpUrl = (value) => { return url.protocol === 'http:' || url.protocol === 'https:' } +/** + * Security validation for subdomain gateway URLs + * @param {string} gatewayUrl - The gateway URL to validate + * @returns {{isValid: boolean, warnings: string[], errors: string[], securityScore: number}} - Validation result with security warnings + */ +export const validateSubdomainGatewaySecurity = (gatewayUrl) => { + const warnings = [] + const errors = [] + + try { + const url = new URL(gatewayUrl) + + // Check for HTTPS + if (url.protocol !== 'https:') { + warnings.push('Gateway should use HTTPS for security') + } + + // Check for localhost/private IPs in production + const hostname = url.hostname.toLowerCase() + const isLocalhost = hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname.startsWith('192.168.') || + hostname.startsWith('10.') || + hostname.startsWith('172.') + + if (isLocalhost) { + warnings.push('Using localhost/private IP gateway may not be accessible to other users') + } + + // Check for potentially risky subdomain patterns + if (hostname.includes('ipfs.') && !hostname.startsWith('ipfs.')) { + warnings.push('Subdomain gateway with "ipfs." in the middle may cause confusion') + } + + // Check for suspicious domains + const suspiciousPatterns = [ + /\.tk$/, /\.ml$/, /\.ga$/, /\.cf$/, // Free domains often used for malicious purposes + /\.onion$/, // Tor hidden services + /localhost\./, /127\.0\.0\.1\./, /0\.0\.0\.0\./ // Localhost subdomains + ] + + for (const pattern of suspiciousPatterns) { + if (pattern.test(hostname)) { + warnings.push('Gateway domain may be unreliable or potentially malicious') + break + } + } + + // Check for very short domains (potential typosquatting) + const domainParts = hostname.split('.') + if (domainParts.length >= 2) { + const tld = domainParts[domainParts.length - 1] + const domain = domainParts[domainParts.length - 2] + if (domain.length <= 2 && tld.length <= 2) { + warnings.push('Very short domain name may be a typo or malicious') + } + } + } catch (error) { + errors.push('Invalid URL format') + } + + return { + isValid: errors.length === 0, + warnings, + errors, + securityScore: calculateSecurityScore(gatewayUrl, warnings, errors) + } +} + +/** + * Calculate a security score for the gateway URL + * @param {string} gatewayUrl - The gateway URL + * @param {string[]} warnings - Array of warnings + * @param {string[]} errors - Array of errors + * @returns {number} - Security score from 0-100 + */ +const calculateSecurityScore = (gatewayUrl, warnings, errors) => { + if (errors.length > 0) return 0 + + let score = 100 + + // Deduct points for warnings + score -= warnings.length * 10 + + // Bonus points for HTTPS + if (gatewayUrl.startsWith('https://')) { + score += 5 + } + + // Bonus points for well-known domains + const wellKnownDomains = [ + 'dweb.link', 'ipfs.io', 'gateway.pinata.cloud', + 'cloudflare-ipfs.com', 'ipfs.fleek.co' + ] + + const hostname = new URL(gatewayUrl).hostname.toLowerCase() + if (wellKnownDomains.some(domain => hostname.includes(domain))) { + score += 10 + } + + return Math.max(0, Math.min(100, score)) +} + /** * Check if any hashes from IMG_ARRAY can be loaded from the provided gatewayUrl * @param {string} gatewayUrl - The gateway URL to check @@ -150,13 +253,24 @@ async function checkViaImgUrl (imgUrl) { * Checks if a given gateway URL is functioning correctly by verifying image loading and redirection. * * @param {string} gatewayUrl - The URL of the gateway to be checked. - * @returns {Promise} A promise that resolves to true if the gateway is functioning correctly, otherwise false. + * @returns {Promise<{isValid: boolean, securityWarnings: string[], errors: string[], securityScore?: number}>} A promise that resolves to an object with validation results including security warnings. */ export async function checkSubdomainGateway (gatewayUrl) { if (gatewayUrl === DEFAULT_SUBDOMAIN_GATEWAY || gatewayUrl === TEST_SUBDOMAIN_GATEWAY) { // avoid sending probe requests to the default gateway every time Settings page is opened // also skip validation for test gateways - return true + return { isValid: true, securityWarnings: [], errors: [] } + } + + // Perform security validation first + const securityValidation = validateSubdomainGatewaySecurity(gatewayUrl) + if (!securityValidation.isValid) { + return { + isValid: securityValidation.isValid, + securityWarnings: securityValidation.warnings, + errors: securityValidation.errors, + securityScore: securityValidation.securityScore + } } /** @type {URL} */ let imgSubdomainUrl @@ -164,22 +278,47 @@ export async function checkSubdomainGateway (gatewayUrl) { let imgRedirectedPathUrl try { const gwUrl = new URL(gatewayUrl) - imgSubdomainUrl = new URL(`${gwUrl.protocol}//${IMG_HASH_1PX}.ipfs.${gwUrl.hostname}/?now=${Date.now()}&filename=1x1.png#x-ipfs-companion-no-redirect`) + // Check if the gateway URL already has an IPFS subdomain + // If hostname starts with 'ipfs.', we need to handle it differently + const hasIpfsSubdomain = gwUrl.hostname.startsWith('ipfs.') + + if (hasIpfsSubdomain) { + // For gateways like ipfs.example.com, construct: hash.ipfs.example.com + imgSubdomainUrl = new URL(`${gwUrl.protocol}//${IMG_HASH_1PX}.${gwUrl.hostname}/?now=${Date.now()}&filename=1x1.png#x-ipfs-companion-no-redirect`) + } else { + // For gateways like example.com, construct: hash.ipfs.example.com + imgSubdomainUrl = new URL(`${gwUrl.protocol}//${IMG_HASH_1PX}.ipfs.${gwUrl.hostname}/?now=${Date.now()}&filename=1x1.png#x-ipfs-companion-no-redirect`) + } + imgRedirectedPathUrl = new URL(`${gwUrl.protocol}//${gwUrl.hostname}/ipfs/${IMG_HASH_1PX}?now=${Date.now()}&filename=1x1.png#x-ipfs-companion-no-redirect`) } catch (err) { console.error('Invalid URL:', err) - return false + return { + isValid: false, + securityWarnings: securityValidation.warnings, + errors: ['Invalid URL format: ' + (err instanceof Error ? err.message : String(err))], + securityScore: securityValidation.securityScore + } + } + try { + await checkViaImgUrl(imgSubdomainUrl) + await expectSubdomainRedirect(imgRedirectedPathUrl) + console.log(`Gateway at '${gatewayUrl}' is functioning correctly (verified image loading and redirection)`) + return { + isValid: true, + securityWarnings: securityValidation.warnings, + errors: [], + securityScore: securityValidation.securityScore + } + } catch (err) { + console.error(err) + return { + isValid: false, + securityWarnings: securityValidation.warnings, + errors: ['Gateway validation failed: ' + (err instanceof Error ? err.message : String(err))], + securityScore: securityValidation.securityScore + } } - return await checkViaImgUrl(imgSubdomainUrl) - .then(async () => expectSubdomainRedirect(imgRedirectedPathUrl)) - .then(() => { - console.log(`Gateway at '${gatewayUrl}' is functioning correctly (verified image loading and redirection)`) - return true - }) - .catch((err) => { - console.error(err) - return false - }) } const bundle = { diff --git a/src/components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js b/src/components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js index 92620c23c..ed4d17a15 100644 --- a/src/components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js +++ b/src/components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js @@ -6,18 +6,26 @@ import { checkValidHttpUrl, checkSubdomainGateway, DEFAULT_SUBDOMAIN_GATEWAY } f const PublicSubdomainGatewayForm = ({ t, doUpdatePublicSubdomainGateway, publicSubdomainGateway }) => { const [value, setValue] = useState(publicSubdomainGateway) - const initialIsValidGatewayUrl = !checkValidHttpUrl(value) + const initialIsValidGatewayUrl = checkValidHttpUrl(value) const [isValidGatewayUrl, setIsValidGatewayUrl] = useState(initialIsValidGatewayUrl) + const [validationResult, setValidationResult] = useState(null) // Updates the border of the input to indicate validity useEffect(() => { const validateUrl = async () => { try { - const isValid = await checkSubdomainGateway(value) - setIsValidGatewayUrl(isValid) + const result = await checkSubdomainGateway(value) + setIsValidGatewayUrl(result.isValid) + setValidationResult(result) } catch (error) { console.error('Error checking subdomain gateway:', error) setIsValidGatewayUrl(false) + setValidationResult({ + isValid: false, + securityWarnings: [], + errors: ['Validation error: ' + (error instanceof Error ? error.message : String(error))], + securityScore: 0 + }) } } @@ -31,10 +39,18 @@ const PublicSubdomainGatewayForm = ({ t, doUpdatePublicSubdomainGateway, publicS let isValid = false try { - isValid = await checkSubdomainGateway(value) - setIsValidGatewayUrl(true) + const result = await checkSubdomainGateway(value) + isValid = result.isValid + setIsValidGatewayUrl(isValid) + setValidationResult(result) } catch (e) { setIsValidGatewayUrl(false) + setValidationResult({ + isValid: false, + securityWarnings: [], + errors: ['Validation error: ' + (e instanceof Error ? e.message : String(e))], + securityScore: 0 + }) return } @@ -44,6 +60,7 @@ const PublicSubdomainGatewayForm = ({ t, doUpdatePublicSubdomainGateway, publicS const onReset = async (event) => { event.preventDefault() setValue(DEFAULT_SUBDOMAIN_GATEWAY) + setValidationResult(null) doUpdatePublicSubdomainGateway(DEFAULT_SUBDOMAIN_GATEWAY) } @@ -65,6 +82,43 @@ const PublicSubdomainGatewayForm = ({ t, doUpdatePublicSubdomainGateway, publicS onKeyPress={onKeyPress} value={value} /> + + {/* Security Warnings and Errors */} + {validationResult && ( +
+ {/* Security Score */} + {validationResult.securityScore !== undefined && ( +
= 80 ? 'green' : validationResult.securityScore >= 60 ? 'yellow' : 'red'}`}> + Security Score: {validationResult.securityScore}/100 +
+ )} + + {/* Errors */} + {validationResult.errors && validationResult.errors.length > 0 && ( +
+ Errors: +
    + {validationResult.errors.map((error, index) => ( +
  • {error}
  • + ))} +
+
+ )} + + {/* Security Warnings */} + {validationResult.securityWarnings && validationResult.securityWarnings.length > 0 && ( +
+ Security Warnings: +
    + {validationResult.securityWarnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+
+ )} +
+ )} +