Skip to content
Merged
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
4 changes: 3 additions & 1 deletion tinyurl/src/main/java/com/tinyurl/config/AppProperties.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ public record AppProperties(
String frontendUrl,
Integer defaultExpiryDays,
Integer shortCodeMinLength,
Cors cors
Cors cors,
UrlValidation urlValidation
) {
public record Cors(List<String> allowedOrigins) {}
public record UrlValidation(boolean enabled) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -70,6 +71,13 @@ public ResponseEntity<ErrorResponse> handleNotFound(NotFoundException ex) {
.body(new ErrorResponse("NOT_FOUND", ex.getMessage()));
}

@ExceptionHandler(UrlUnreachableException.class)
public ResponseEntity<ErrorResponse> 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<ErrorResponse> handleGone(GoneException ex) {
incrementErrorMetric(HttpStatus.GONE, "GONE");
Expand Down Expand Up @@ -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";
};
Expand All @@ -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.";
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.tinyurl.exception;

public class UrlUnreachableException extends RuntimeException {
public UrlUnreachableException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.tinyurl.service;

public interface UrlReachabilityChecker {
void check(String url);
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
2 changes: 2 additions & 0 deletions tinyurl/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,5 @@ tinyurl:
cors:
allowed-origins:
- "http://localhost:4200"
url-validation:
enabled: true
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading