From d6c624b1932669d63143f7d5213610fab13341b7 Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 6 Jul 2026 01:38:43 -0500 Subject: [PATCH] validate URL reachability before creating short URL --- .../com/tinyurl/config/AppProperties.java | 4 +- .../controller/GlobalExceptionHandler.java | 11 ++- .../exception/UrlUnreachableException.java | 7 ++ .../service/UrlReachabilityChecker.java | 5 ++ .../service/UrlReachabilityCheckerImpl.java | 82 +++++++++++++++++++ .../com/tinyurl/service/UrlServiceImpl.java | 8 +- tinyurl/src/main/resources/application.yaml | 2 + .../tinyurl/service/UrlServiceImplTest.java | 7 +- 8 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 tinyurl/src/main/java/com/tinyurl/exception/UrlUnreachableException.java create mode 100644 tinyurl/src/main/java/com/tinyurl/service/UrlReachabilityChecker.java create mode 100644 tinyurl/src/main/java/com/tinyurl/service/UrlReachabilityCheckerImpl.java diff --git a/tinyurl/src/main/java/com/tinyurl/config/AppProperties.java b/tinyurl/src/main/java/com/tinyurl/config/AppProperties.java index c2dbacf..f39b75b 100644 --- a/tinyurl/src/main/java/com/tinyurl/config/AppProperties.java +++ b/tinyurl/src/main/java/com/tinyurl/config/AppProperties.java @@ -10,7 +10,9 @@ public record AppProperties( String frontendUrl, Integer defaultExpiryDays, Integer shortCodeMinLength, - Cors cors + Cors cors, + UrlValidation urlValidation ) { public record Cors(List allowedOrigins) {} + public record UrlValidation(boolean enabled) {} } diff --git a/tinyurl/src/main/java/com/tinyurl/controller/GlobalExceptionHandler.java b/tinyurl/src/main/java/com/tinyurl/controller/GlobalExceptionHandler.java index 4c90c97..15b19d1 100644 --- a/tinyurl/src/main/java/com/tinyurl/controller/GlobalExceptionHandler.java +++ b/tinyurl/src/main/java/com/tinyurl/controller/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import com.tinyurl.dto.ErrorResponse; import com.tinyurl.exception.GoneException; import com.tinyurl.exception.NotFoundException; +import com.tinyurl.exception.UrlUnreachableException; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import jakarta.validation.ConstraintViolationException; @@ -70,6 +71,13 @@ public ResponseEntity handleNotFound(NotFoundException ex) { .body(new ErrorResponse("NOT_FOUND", ex.getMessage())); } + @ExceptionHandler(UrlUnreachableException.class) + public ResponseEntity handleUrlUnreachable(UrlUnreachableException ex) { + incrementErrorMetric(HttpStatus.UNPROCESSABLE_ENTITY, "URL_UNREACHABLE"); + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) + .body(new ErrorResponse("URL_UNREACHABLE", messageForCode("URL_UNREACHABLE"))); + } + @ExceptionHandler(GoneException.class) public ResponseEntity handleGone(GoneException ex) { incrementErrorMetric(HttpStatus.GONE, "GONE"); @@ -99,7 +107,7 @@ private String normalizeErrorCode(String errorCode) { } // Map to bounded set of known error codes return switch (errorCode) { - case "INVALID_URL", "INVALID_EXPIRY", "INVALID_REQUEST" -> errorCode; + case "INVALID_URL", "INVALID_EXPIRY", "INVALID_REQUEST", "URL_UNREACHABLE" -> errorCode; case "SERVICE_UNAVAILABLE", "NOT_FOUND", "GONE", "INTERNAL_ERROR" -> errorCode; default -> "UNKNOWN_ERROR"; }; @@ -109,6 +117,7 @@ private String messageForCode(String code) { return switch (code) { case "INVALID_URL" -> "URL must be a valid HTTP or HTTPS address (max 2048 characters)."; case "INVALID_EXPIRY" -> "Expiry must be a positive integer not greater than 3650 days."; + case "URL_UNREACHABLE" -> "The provided URL could not be reached. Please check the URL and try again."; default -> "Request validation failed."; }; } diff --git a/tinyurl/src/main/java/com/tinyurl/exception/UrlUnreachableException.java b/tinyurl/src/main/java/com/tinyurl/exception/UrlUnreachableException.java new file mode 100644 index 0000000..ae992b9 --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/exception/UrlUnreachableException.java @@ -0,0 +1,7 @@ +package com.tinyurl.exception; + +public class UrlUnreachableException extends RuntimeException { + public UrlUnreachableException(String message) { + super(message); + } +} diff --git a/tinyurl/src/main/java/com/tinyurl/service/UrlReachabilityChecker.java b/tinyurl/src/main/java/com/tinyurl/service/UrlReachabilityChecker.java new file mode 100644 index 0000000..4122d7b --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/service/UrlReachabilityChecker.java @@ -0,0 +1,5 @@ +package com.tinyurl.service; + +public interface UrlReachabilityChecker { + void check(String url); +} diff --git a/tinyurl/src/main/java/com/tinyurl/service/UrlReachabilityCheckerImpl.java b/tinyurl/src/main/java/com/tinyurl/service/UrlReachabilityCheckerImpl.java new file mode 100644 index 0000000..b3c97e5 --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/service/UrlReachabilityCheckerImpl.java @@ -0,0 +1,82 @@ +package com.tinyurl.service; + +import com.tinyurl.exception.UrlUnreachableException; +import java.io.IOException; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +@Service +public class UrlReachabilityCheckerImpl implements UrlReachabilityChecker { + + private static final Logger log = LoggerFactory.getLogger(UrlReachabilityCheckerImpl.class); + + private final HttpClient httpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .connectTimeout(Duration.ofSeconds(5)) + .build(); + + @Override + public void check(String url) { + try { + guardSsrf(url); + int status = sendHead(url); + if (status == 405) { + status = sendGet(url); + } + if (status == 404 || status == 410) { + throw new UrlUnreachableException("URL_UNREACHABLE"); + } + } catch (UrlUnreachableException e) { + throw e; + } catch (Exception e) { + log.warn("Reachability check failed for host {} — failing open: {}", URI.create(url).getHost(), e.getMessage()); + } + } + + private void guardSsrf(String url) throws Exception { + String host = URI.create(url).getHost(); + if (host == null || host.isBlank()) { + throw new UrlUnreachableException("URL_UNREACHABLE"); + } + InetAddress address = InetAddress.getByName(host); + if (address.isLoopbackAddress() + || address.isSiteLocalAddress() + || address.isLinkLocalAddress() + || address.isAnyLocalAddress() + || isIpv6UniqueLocal(address)) { + throw new UrlUnreachableException("URL_UNREACHABLE"); + } + } + + private boolean isIpv6UniqueLocal(InetAddress address) { + if (!(address instanceof Inet6Address)) { + return false; + } + // FC00::/7 — unique local addresses (covers FC00:: and FD00:: ranges) + return (address.getAddress()[0] & 0xFE) == 0xFC; + } + + private int sendHead(String url) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .method("HEAD", HttpRequest.BodyPublishers.noBody()) + .timeout(Duration.ofSeconds(5)) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.discarding()).statusCode(); + } + + private int sendGet(String url) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .GET() + .timeout(Duration.ofSeconds(5)) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.discarding()).statusCode(); + } +} diff --git a/tinyurl/src/main/java/com/tinyurl/service/UrlServiceImpl.java b/tinyurl/src/main/java/com/tinyurl/service/UrlServiceImpl.java index 2065bc6..d666262 100644 --- a/tinyurl/src/main/java/com/tinyurl/service/UrlServiceImpl.java +++ b/tinyurl/src/main/java/com/tinyurl/service/UrlServiceImpl.java @@ -22,16 +22,22 @@ public class UrlServiceImpl implements UrlService { private final UrlRepository urlRepository; private final Base62Encoder base62Encoder; private final AppProperties appProperties; + private final UrlReachabilityChecker reachabilityChecker; - public UrlServiceImpl(UrlRepository urlRepository, Base62Encoder base62Encoder, AppProperties appProperties) { + public UrlServiceImpl(UrlRepository urlRepository, Base62Encoder base62Encoder, + AppProperties appProperties, UrlReachabilityChecker reachabilityChecker) { this.urlRepository = urlRepository; this.base62Encoder = base62Encoder; this.appProperties = appProperties; + this.reachabilityChecker = reachabilityChecker; } @Override public UrlMapping shortenUrl(CreateUrlRequest request, String creatorIp, String creatorUserAgent, String referer) { validateUrl(request.url()); + if (appProperties.urlValidation() != null && appProperties.urlValidation().enabled()) { + reachabilityChecker.check(request.url()); + } boolean hasExplicitExpiry = request.expiresInDays() != null; int expiresInDays = normalizeExpiry(request.expiresInDays()); diff --git a/tinyurl/src/main/resources/application.yaml b/tinyurl/src/main/resources/application.yaml index eaa61eb..848887f 100644 --- a/tinyurl/src/main/resources/application.yaml +++ b/tinyurl/src/main/resources/application.yaml @@ -48,3 +48,5 @@ tinyurl: cors: allowed-origins: - "http://localhost:4200" + url-validation: + enabled: true diff --git a/tinyurl/src/test/java/com/tinyurl/service/UrlServiceImplTest.java b/tinyurl/src/test/java/com/tinyurl/service/UrlServiceImplTest.java index 845deed..7b8ab03 100644 --- a/tinyurl/src/test/java/com/tinyurl/service/UrlServiceImplTest.java +++ b/tinyurl/src/test/java/com/tinyurl/service/UrlServiceImplTest.java @@ -34,11 +34,16 @@ class UrlServiceImplTest { @Mock private Base62Encoder base62Encoder; + @Mock + private UrlReachabilityChecker reachabilityChecker; + private UrlServiceImpl service; @BeforeEach void setUp() { - service = new UrlServiceImpl(urlRepository, base62Encoder, new AppProperties("http://localhost", "http://localhost:4200", 180, 6, null)); + service = new UrlServiceImpl(urlRepository, base62Encoder, + new AppProperties("http://localhost", "http://localhost:4200", 180, 6, null, null), + reachabilityChecker); } @Test