← back to Jill Website
src/services/linkValidationService.ts
125 lines
import axios, { AxiosError } from 'axios';
export interface LinkCheckResult {
url: string;
isValid: boolean;
httpStatus?: number;
errorMessage?: string;
responseTimeMs: number;
}
export class LinkValidationService {
private readonly timeout: number = 10000; // 10 seconds
private readonly maxRedirects: number = 5;
/**
* Check if a single URL is valid and accessible
*/
async checkLink(url: string): Promise<LinkCheckResult> {
const startTime = Date.now();
try {
// P0 SSRF fix 2026-05-04: was POSTable to {url:'http://169.254.169.254/...'}
// or any private IP. Reject non-http(s) protocols + private/loopback/link-local
// hostnames. Note: this is a hostname check; full DNS-resolve-then-recheck
// would be needed to fully defeat DNS rebinding (deferred — admin-only path).
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`unsupported protocol: ${parsed.protocol}`);
}
const h = parsed.hostname.toLowerCase();
const PRIVATE = /^(localhost|127\.|10\.|169\.254\.|192\.168\.|::1$|\[::1\]$|fc[0-9a-f]{2}:|fd[0-9a-f]{2}:)/i;
const CGNAT = /^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./;
const TWELVE = /^172\.(1[6-9]|2[0-9]|3[01])\./;
if (PRIVATE.test(h) || CGNAT.test(h) || TWELVE.test(h)) {
throw new Error(`private/loopback host blocked: ${h}`);
}
const response = await axios.head(url, {
timeout: this.timeout,
maxRedirects: this.maxRedirects,
validateStatus: (status) => status < 500, // Accept all statuses < 500
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; LinkValidator/1.0)',
},
});
const responseTimeMs = Date.now() - startTime;
const isValid = response.status >= 200 && response.status < 400;
return {
url,
isValid,
httpStatus: response.status,
responseTimeMs,
};
} catch (error) {
const responseTimeMs = Date.now() - startTime;
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
return {
url,
isValid: false,
httpStatus: axiosError.response?.status,
errorMessage: this.getErrorMessage(axiosError),
responseTimeMs,
};
}
return {
url,
isValid: false,
errorMessage: error instanceof Error ? error.message : 'Unknown error',
responseTimeMs,
};
}
}
/**
* Check multiple links in parallel
*/
async checkLinks(urls: string[]): Promise<LinkCheckResult[]> {
const promises = urls.map((url) => this.checkLink(url));
return Promise.all(promises);
}
/**
* Extract user-friendly error message from Axios error
*/
private getErrorMessage(error: AxiosError): string {
if (error.code === 'ECONNABORTED') {
return 'Connection timeout';
}
if (error.code === 'ENOTFOUND') {
return 'Domain not found';
}
if (error.code === 'ECONNREFUSED') {
return 'Connection refused';
}
if (error.code === 'ETIMEDOUT') {
return 'Request timeout';
}
if (error.response) {
return `HTTP ${error.response.status}: ${error.response.statusText}`;
}
return error.message || 'Network error';
}
/**
* Validate URL format without making HTTP request
*/
isValidUrlFormat(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
}
// Export singleton instance
export const linkValidationService = new LinkValidationService();