Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<a href="https://ipshipyard.com/"><img align="right" src="https://github.com/user-attachments/assets/39ed3504-bb71-47f6-9bf8-cb9a1698f272" /></a>
Expand Down
167 changes: 153 additions & 14 deletions src/bundles/gateway.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
Comment on lines +71 to +81
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not block users from being able to use LAN / private network gateways without DNS.

Please remove this


// 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
}
}
Comment on lines +88 to +100
Copy link
Member

@lidel lidel Oct 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a place to make judgement about domain being safe or not.
ipfs.io itself is not considered "safe" by most of software.

We should only test if domain is a valid subdomain gateway and if it correctly implements URL convention and CORS headers.

Please remove this.


// 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')
}
}
Comment on lines +102 to +110
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary complexity

} 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'
]
Comment on lines +143 to +147
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not hardcode domains, especially dead ones like cloudflare-ipfs.com


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
Expand Down Expand Up @@ -150,36 +253,72 @@ 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<boolean>} 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
/** @type {URL} */
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`)
}
Comment on lines +286 to +291
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace entire if with just:

imgSubdomainUrl = new URL(`${gwUrl.protocol}//${IMG_HASH_1PX}.${gwUrl.hostname}/?now=${Date.now()}&filename=1x1.png#x-ipfs-companion-no-redirect`)

The ipfs. subdomain part is entirely useless and annoying.


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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}
}

Expand All @@ -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
}

Expand All @@ -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)
}

Expand All @@ -65,6 +82,43 @@ const PublicSubdomainGatewayForm = ({ t, doUpdatePublicSubdomainGateway, publicS
onKeyPress={onKeyPress}
value={value}
/>

{/* Security Warnings and Errors */}
{validationResult && (
<div className='mb3'>
{/* Security Score */}
{validationResult.securityScore !== undefined && (
<div className={`f6 mb2 ${validationResult.securityScore >= 80 ? 'green' : validationResult.securityScore >= 60 ? 'yellow' : 'red'}`}>
Security Score: {validationResult.securityScore}/100
</div>
)}

{/* Errors */}
{validationResult.errors && validationResult.errors.length > 0 && (
<div className='f6 red mb2'>
<strong>Errors:</strong>
<ul className='ma0 pa0 pl3'>
{validationResult.errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}

{/* Security Warnings */}
{validationResult.securityWarnings && validationResult.securityWarnings.length > 0 && (
<div className='f6 yellow mb2'>
<strong>Security Warnings:</strong>
<ul className='ma0 pa0 pl3'>
{validationResult.securityWarnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
</div>
)}

<div className='tr'>
<Button
id='public-subdomain-gateway-reset-button'
Expand Down