From c1eabbb232ac31958a63fe05076d0db9ae7187ba Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 13:07:27 +0100 Subject: [PATCH 01/35] fix: change user role and keep them in database --- .../java/io/shelang/aghab/domain/User.java | 62 +++++++++---------- .../V1.8.0__Add_user_role_column.sql | 10 +++ 2 files changed, 40 insertions(+), 32 deletions(-) create mode 100644 src/main/resources/db/migration/V1.8.0__Add_user_role_column.sql diff --git a/src/main/java/io/shelang/aghab/domain/User.java b/src/main/java/io/shelang/aghab/domain/User.java index f45a7de..27e8f72 100644 --- a/src/main/java/io/shelang/aghab/domain/User.java +++ b/src/main/java/io/shelang/aghab/domain/User.java @@ -29,44 +29,42 @@ @Table(name = "users") public class User { - @Id - @GeneratedValue(generator = "users_id_gen", strategy = GenerationType.SEQUENCE) - @SequenceGenerator(name = "users_id_gen", allocationSize = 1, sequenceName = "users_id_seq") - private Long id; + @Id + @GeneratedValue(generator = "users_id_gen", strategy = GenerationType.SEQUENCE) + @SequenceGenerator(name = "users_id_gen", allocationSize = 1, sequenceName = "users_id_seq") + private Long id; - private String username; + private String username; - private String password; + private String password; - private String token; + private String token; - @Column(name = "token_issue_at") - private Instant tokenIssueAt; + @Column(name = "token_issue_at") + private Instant tokenIssueAt; - @Column(name = "need_change_password") - private boolean needChangePassword; + @Column(name = "need_change_password") + private boolean needChangePassword; - @Builder.Default - @ManyToMany(fetch = FetchType.LAZY) - @JoinTable( - name = "script_user", - joinColumns = {@JoinColumn(name = "user_id")}, - inverseJoinColumns = {@JoinColumn(name = "script_id")}) - Set", Pattern.CASE_INSENSITIVE); - // Remove HTML comments first (could hide malicious payloads) - sanitized = HTML_COMMENT_PATTERN.matcher(sanitized).replaceAll(""); + // Pattern to match event handlers like onclick, onerror, onload, etc. + private static final Pattern EVENT_HANDLER_PATTERN = Pattern.compile( + "\\s*on\\w+\\s*=\\s*[\"'][^\"']*[\"']", Pattern.CASE_INSENSITIVE); - // Remove script tags - sanitized = SCRIPT_TAG_PATTERN.matcher(sanitized).replaceAll(""); + // Pattern to match javascript: protocol + private static final Pattern JAVASCRIPT_PROTOCOL_PATTERN = Pattern.compile( + "javascript\\s*:", Pattern.CASE_INSENSITIVE); - // Remove dangerous tags (iframe, object, embed, etc.) - sanitized = DANGEROUS_TAGS_PATTERN.matcher(sanitized).replaceAll(""); + // Pattern to match data: protocol with potential script content + private static final Pattern DATA_PROTOCOL_PATTERN = Pattern.compile( + "data\\s*:\\s*text/html", Pattern.CASE_INSENSITIVE); - // Remove event handlers - sanitized = EVENT_HANDLER_PATTERN.matcher(sanitized).replaceAll(""); + // Pattern to match iframe, object, embed tags + private static final Pattern DANGEROUS_TAGS_PATTERN = Pattern.compile( + "<\\s*/?\\s*(iframe|object|embed|form|input|meta|link|base|svg|math)[^>]*>", + Pattern.CASE_INSENSITIVE); - // Remove javascript: protocol - sanitized = JAVASCRIPT_PROTOCOL_PATTERN.matcher(sanitized).replaceAll(""); + // Pattern to match style tags and expressions + private static final Pattern STYLE_EXPRESSION_PATTERN = Pattern.compile( + "(expression\\s*\\(|behavior\\s*:|binding\\s*:|-moz-binding\\s*:)", + Pattern.CASE_INSENSITIVE); - // Remove data: protocol with HTML content - sanitized = DATA_PROTOCOL_PATTERN.matcher(sanitized).replaceAll(""); + // Pattern to match HTML comments that could hide malicious content + private static final Pattern HTML_COMMENT_PATTERN = Pattern.compile( + "", Pattern.CASE_INSENSITIVE); - // Remove style expressions (IE-specific XSS vectors) - sanitized = STYLE_EXPRESSION_PATTERN.matcher(sanitized).replaceAll(""); + /** + * Sanitizes the given script content by removing potentially dangerous + * elements. + * + * @param content The raw script content to sanitize + * @return Sanitized content safe for rendering, or empty string if input is + * null + */ + public static String sanitizeScript(String content) { + if (content == null) { + return ""; + } - return sanitized.trim(); - } + String sanitized = content; - /** - * Checks if the content contains potentially dangerous elements. - * - * @param content The content to check - * @return true if the content contains dangerous elements, false otherwise - */ - public static boolean containsDangerousContent(String content) { - if (content == null || content.isEmpty()) { - return false; + // Remove HTML comments first (could hide malicious payloads) + sanitized = HTML_COMMENT_PATTERN.matcher(sanitized).replaceAll(""); + + // Remove script tags + sanitized = SCRIPT_TAG_PATTERN.matcher(sanitized).replaceAll(""); + + // Remove dangerous tags (iframe, object, embed, etc.) + sanitized = DANGEROUS_TAGS_PATTERN.matcher(sanitized).replaceAll(""); + + // Remove event handlers + sanitized = EVENT_HANDLER_PATTERN.matcher(sanitized).replaceAll(""); + + // Remove javascript: protocol + sanitized = JAVASCRIPT_PROTOCOL_PATTERN.matcher(sanitized).replaceAll(""); + + // Remove data: protocol with HTML content + sanitized = DATA_PROTOCOL_PATTERN.matcher(sanitized).replaceAll(""); + + // Remove style expressions (IE-specific XSS vectors) + sanitized = STYLE_EXPRESSION_PATTERN.matcher(sanitized).replaceAll(""); + + return sanitized.trim(); } - return SCRIPT_TAG_PATTERN.matcher(content).find() - || DANGEROUS_TAGS_PATTERN.matcher(content).find() - || EVENT_HANDLER_PATTERN.matcher(content).find() - || JAVASCRIPT_PROTOCOL_PATTERN.matcher(content).find() - || DATA_PROTOCOL_PATTERN.matcher(content).find() - || STYLE_EXPRESSION_PATTERN.matcher(content).find(); - } + /** + * Checks if the content contains potentially dangerous elements. + * + * @param content The content to check + * @return true if the content contains dangerous elements, false otherwise + */ + public static boolean containsDangerousContent(String content) { + if (content == null || content.isEmpty()) { + return false; + } + + return SCRIPT_TAG_PATTERN.matcher(content).find() + || DANGEROUS_TAGS_PATTERN.matcher(content).find() + || EVENT_HANDLER_PATTERN.matcher(content).find() + || JAVASCRIPT_PROTOCOL_PATTERN.matcher(content).find() + || DATA_PROTOCOL_PATTERN.matcher(content).find() + || STYLE_EXPRESSION_PATTERN.matcher(content).find(); + } } diff --git a/src/test/java/io/shelang/aghab/util/PasswordValidatorTest.java b/src/test/java/io/shelang/aghab/util/PasswordValidatorTest.java new file mode 100644 index 0000000..b52a881 --- /dev/null +++ b/src/test/java/io/shelang/aghab/util/PasswordValidatorTest.java @@ -0,0 +1,44 @@ +package io.shelang.aghab.util; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class PasswordValidatorTest { + + @Test + void testValidate_ValidPasswords() { + assertTrue(PasswordValidator.validate("StrongPass1!").isValid()); + assertTrue(PasswordValidator.validate("AnotherS3cret@").isValid()); + } + + @Test + void testValidate_LengthRequirements() { + // Too short + assertFalse(PasswordValidator.validate("Short1!").isValid()); + // Empty + assertFalse(PasswordValidator.validate("").isValid()); + // Null + assertFalse(PasswordValidator.validate(null).isValid()); + } + + @Test + void testValidate_ComplexityRequirements() { + // No uppercase + assertFalse(PasswordValidator.validate("weakpass1!").isValid()); + // No lowercase + assertFalse(PasswordValidator.validate("WEAKPASS1!").isValid()); + // No digit + assertFalse(PasswordValidator.validate("WeakPass!").isValid()); + // No special char + assertFalse(PasswordValidator.validate("WeakPass123").isValid()); + } + + @Test + void testValidateOrThrow() { + assertDoesNotThrow(() -> PasswordValidator.validateOrThrow("GoodPass1!")); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> PasswordValidator.validateOrThrow("bad")); + assertTrue(ex.getMessage().contains("at least")); + } +} diff --git a/src/test/java/io/shelang/aghab/util/RedirectSanitizerTest.java b/src/test/java/io/shelang/aghab/util/RedirectSanitizerTest.java new file mode 100644 index 0000000..4141b56 --- /dev/null +++ b/src/test/java/io/shelang/aghab/util/RedirectSanitizerTest.java @@ -0,0 +1,43 @@ +package io.shelang.aghab.util; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class RedirectSanitizerTest { + + @Test + void testSanitizeRedirectUrl_CleanUrls() { + assertEquals("https://example.com", + RedirectSanitizer.sanitizeRedirectUrl("https://example.com")); + assertEquals("/local/path", + RedirectSanitizer.sanitizeRedirectUrl("/local/path")); + } + + @Test + void testSanitizeRedirectUrl_CRLF() { + String input = "https://example.com\r\nSet-Cookie: evil=true"; + String expected = "https://example.comSet-Cookie: evil=true"; + assertEquals(expected, RedirectSanitizer.sanitizeRedirectUrl(input)); + + String percentEncoded = "https://example.com%0d%0aSet-Cookie: evil=true"; + assertEquals(expected, RedirectSanitizer.sanitizeRedirectUrl(percentEncoded)); + } + + @Test + void testSanitizeRedirectUrl_DangerousProtocols() { + assertThrows(IllegalArgumentException.class, + () -> RedirectSanitizer.sanitizeRedirectUrl("javascript:alert(1)")); + + assertThrows(IllegalArgumentException.class, + () -> RedirectSanitizer.sanitizeRedirectUrl("data:text/html,bad")); + + assertThrows(IllegalArgumentException.class, + () -> RedirectSanitizer.sanitizeRedirectUrl("vbscript:alert(1)")); + } + + @Test + void testIsSafeRedirectUrl() { + assertTrue(RedirectSanitizer.isSafeRedirectUrl("https://safe.com")); + assertFalse(RedirectSanitizer.isSafeRedirectUrl("javascript:bad()")); + } +} diff --git a/src/test/java/io/shelang/aghab/util/ScriptSanitizerTest.java b/src/test/java/io/shelang/aghab/util/ScriptSanitizerTest.java new file mode 100644 index 0000000..056d002 --- /dev/null +++ b/src/test/java/io/shelang/aghab/util/ScriptSanitizerTest.java @@ -0,0 +1,58 @@ +package io.shelang.aghab.util; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class ScriptSanitizerTest { + + @Test + void testSanitizeScript_NullOrEmpty() { + assertEquals("", ScriptSanitizer.sanitizeScript(null)); + assertEquals("", ScriptSanitizer.sanitizeScript("")); + } + + @Test + void testSanitizeScript_CleanContent() { + String content = "This is safe content"; + assertEquals(content, ScriptSanitizer.sanitizeScript(content)); + + String html = "
Bold text
"; + assertEquals(html, ScriptSanitizer.sanitizeScript(html)); + } + + @Test + void testSanitizeScript_ScriptTags() { + String content = "Hello World"; + assertEquals("Hello World", ScriptSanitizer.sanitizeScript(content)); + + String complex = ""; + assertEquals("", ScriptSanitizer.sanitizeScript(complex)); + } + + @Test + void testSanitizeScript_EventHandlers() { + String content = ""; + String sanitized = ScriptSanitizer.sanitizeScript(content); + assertFalse(sanitized.contains("onerror")); + + String click = "Click me"; + assertFalse(ScriptSanitizer.sanitizeScript(click).contains("onclick")); + } + + @Test + void testSanitizeScript_Protocols() { + String js = "Link"; + assertFalse(ScriptSanitizer.sanitizeScript(js).contains("javascript:")); + + String data = "Link"; + assertFalse(ScriptSanitizer.sanitizeScript(data).contains("data:text/html")); + } + + @Test + void testContainsDangerousContent() { + assertFalse(ScriptSanitizer.containsDangerousContent("Safe text")); + assertTrue(ScriptSanitizer.containsDangerousContent("")); + assertTrue(ScriptSanitizer.containsDangerousContent("javascript:")); + assertTrue(ScriptSanitizer.containsDangerousContent("onload=\"\"")); + } +} diff --git a/src/test/java/io/shelang/aghab/util/UrlValidatorTest.java b/src/test/java/io/shelang/aghab/util/UrlValidatorTest.java new file mode 100644 index 0000000..25b2378 --- /dev/null +++ b/src/test/java/io/shelang/aghab/util/UrlValidatorTest.java @@ -0,0 +1,44 @@ +package io.shelang.aghab.util; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class UrlValidatorTest { + + @Test + void testIsValidExternalUrl_ValidUrls() { + assertTrue(UrlValidator.isValidExternalUrl("https://8.8.8.8")); // Good public IP + assertTrue(UrlValidator.isValidExternalUrl("http://1.1.1.1/path?q=1")); + } + + @Test + void testIsValidExternalUrl_BlockedHosts() { + assertFalse(UrlValidator.isValidExternalUrl("http://localhost")); + assertFalse(UrlValidator.isValidExternalUrl("https://127.0.0.1")); + assertFalse(UrlValidator.isValidExternalUrl("http://[::1]")); + assertFalse(UrlValidator.isValidExternalUrl("http://metadata.google.internal")); + } + + @Test + void testIsValidExternalUrl_InternalIPs() { + assertFalse(UrlValidator.isValidExternalUrl("http://192.168.1.1")); + assertFalse(UrlValidator.isValidExternalUrl("http://10.0.0.1")); + assertFalse(UrlValidator.isValidExternalUrl("http://172.16.0.1")); + assertFalse(UrlValidator.isValidExternalUrl("http://169.254.169.254")); + } + + @Test + void testIsValidExternalUrl_InvalidSchemes() { + assertFalse(UrlValidator.isValidExternalUrl("ftp://example.com")); + assertFalse(UrlValidator.isValidExternalUrl("file:///etc/passwd")); + assertFalse(UrlValidator.isValidExternalUrl("javascript:alert(1)")); + } + + @Test + void testValidateOrThrow() { + assertDoesNotThrow(() -> UrlValidator.validateOrThrow("https://8.8.8.8")); + + assertThrows(IllegalArgumentException.class, + () -> UrlValidator.validateOrThrow("http://localhost:8080")); + } +} From 1744884756bb0d2031cec0f639cd657c6be8964d Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 15:41:51 +0100 Subject: [PATCH 15/35] feat: implement audit logging system --- .../io/shelang/aghab/domain/AuditLog.java | 42 +++++++++++++++++++ .../aghab/repository/AuditLogRepository.java | 9 ++++ .../aghab/service/audit/AuditService.java | 30 +++++++++++++ .../migration/V1.9.0__Add_audit_log_table.sql | 14 +++++++ 4 files changed, 95 insertions(+) create mode 100644 src/main/java/io/shelang/aghab/domain/AuditLog.java create mode 100644 src/main/java/io/shelang/aghab/repository/AuditLogRepository.java create mode 100644 src/main/java/io/shelang/aghab/service/audit/AuditService.java create mode 100644 src/main/resources/db/migration/V1.9.0__Add_audit_log_table.sql diff --git a/src/main/java/io/shelang/aghab/domain/AuditLog.java b/src/main/java/io/shelang/aghab/domain/AuditLog.java new file mode 100644 index 0000000..58ca4a0 --- /dev/null +++ b/src/main/java/io/shelang/aghab/domain/AuditLog.java @@ -0,0 +1,42 @@ +package io.shelang.aghab.domain; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.Instant; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity +@Table(name = "audit_log") +public class AuditLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "workspace_id") + Long workspaceId; + + @Column(name = "event_type") + private String eventType; + + @Column(name = "event_data") + private String eventData; + + @Column(name = "ip_address") + private String ipAddress; + + @CreationTimestamp + @Column(name = "create_at", nullable = false, updatable = false) + private Instant createAt; +} diff --git a/src/main/java/io/shelang/aghab/repository/AuditLogRepository.java b/src/main/java/io/shelang/aghab/repository/AuditLogRepository.java new file mode 100644 index 0000000..00225eb --- /dev/null +++ b/src/main/java/io/shelang/aghab/repository/AuditLogRepository.java @@ -0,0 +1,9 @@ +package io.shelang.aghab.repository; + +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import io.shelang.aghab.domain.AuditLog; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class AuditLogRepository implements PanacheRepository { +} diff --git a/src/main/java/io/shelang/aghab/service/audit/AuditService.java b/src/main/java/io/shelang/aghab/service/audit/AuditService.java new file mode 100644 index 0000000..4e9332b --- /dev/null +++ b/src/main/java/io/shelang/aghab/service/audit/AuditService.java @@ -0,0 +1,30 @@ +package io.shelang.aghab.service.audit; + +import io.shelang.aghab.domain.AuditLog; +import io.shelang.aghab.repository.AuditLogRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; + +@ApplicationScoped +public class AuditService { + + @Inject + AuditLogRepository auditLogRepository; + + @Transactional(Transactional.TxType.REQUIRES_NEW) + public void log(Long userId, Long workspaceId, String eventType, String eventData, String ipAddress) { + try { + AuditLog log = AuditLog.builder() + .userId(userId) + .workspaceId(workspaceId) + .eventType(eventType) + .eventData(eventData) + .ipAddress(ipAddress) + .build(); + auditLogRepository.persistAndFlush(log); + } catch (Exception e) { + // Fail silently to not impact main business logic + } + } +} diff --git a/src/main/resources/db/migration/V1.9.0__Add_audit_log_table.sql b/src/main/resources/db/migration/V1.9.0__Add_audit_log_table.sql new file mode 100644 index 0000000..2d6539b --- /dev/null +++ b/src/main/resources/db/migration/V1.9.0__Add_audit_log_table.sql @@ -0,0 +1,14 @@ +CREATE TABLE audit_log ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT, + workspace_id BIGINT, + event_type VARCHAR(50) NOT NULL, + event_data TEXT, + ip_address VARCHAR(45), + create_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_audit_log_user_id ON audit_log(user_id); +CREATE INDEX idx_audit_log_workspace_id ON audit_log(workspace_id); +CREATE INDEX idx_audit_log_event_type ON audit_log(event_type); +CREATE INDEX idx_audit_log_create_at ON audit_log(create_at); From 7d34a0fb35925ee47fc0d22eb9fd1d05dafed1e3 Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 15:42:06 +0100 Subject: [PATCH 16/35] feat: add readiness and liveness health checks --- .../aghab/health/AppLivenessCheck.java | 17 ++++++ .../aghab/health/AppReadinessCheck.java | 57 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 src/main/java/io/shelang/aghab/health/AppLivenessCheck.java create mode 100644 src/main/java/io/shelang/aghab/health/AppReadinessCheck.java diff --git a/src/main/java/io/shelang/aghab/health/AppLivenessCheck.java b/src/main/java/io/shelang/aghab/health/AppLivenessCheck.java new file mode 100644 index 0000000..4f5c1a2 --- /dev/null +++ b/src/main/java/io/shelang/aghab/health/AppLivenessCheck.java @@ -0,0 +1,17 @@ +package io.shelang.aghab.health; + +import org.eclipse.microprofile.health.HealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.Liveness; + +import jakarta.enterprise.context.ApplicationScoped; + +@Liveness +@ApplicationScoped +public class AppLivenessCheck implements HealthCheck { + + @Override + public HealthCheckResponse call() { + return HealthCheckResponse.up("Aghab Application is live"); + } +} diff --git a/src/main/java/io/shelang/aghab/health/AppReadinessCheck.java b/src/main/java/io/shelang/aghab/health/AppReadinessCheck.java new file mode 100644 index 0000000..d73d064 --- /dev/null +++ b/src/main/java/io/shelang/aghab/health/AppReadinessCheck.java @@ -0,0 +1,57 @@ +package io.shelang.aghab.health; + +import org.eclipse.microprofile.health.HealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.HealthCheckResponseBuilder; +import org.eclipse.microprofile.health.Readiness; + +import io.quarkus.redis.client.RedisClient; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import javax.sql.DataSource; +import java.sql.Connection; + +@Readiness +@ApplicationScoped +@SuppressWarnings("deprecation") +public class AppReadinessCheck implements HealthCheck { + + @Inject + DataSource dataSource; + + @Inject + RedisClient redisClient; + + @Override + public HealthCheckResponse call() { + HealthCheckResponseBuilder responseBuilder = HealthCheckResponse.named("Aghab Application Readiness"); + + boolean databaseUp = checkDatabase(); + boolean redisUp = checkRedis(); + + responseBuilder.withData("database", databaseUp ? "UP" : "DOWN") + .withData("redis", redisUp ? "UP" : "DOWN"); + + if (databaseUp && redisUp) { + return responseBuilder.up().build(); + } else { + return responseBuilder.down().build(); + } + } + + private boolean checkDatabase() { + try (Connection connection = dataSource.getConnection()) { + return connection.isValid(3); + } catch (Exception e) { + return false; + } + } + + private boolean checkRedis() { + try { + return redisClient.ping(java.util.Collections.emptyList()).toString().equalsIgnoreCase("PONG"); + } catch (Exception e) { + return false; + } + } +} From c274d7b21ad57182565314d83561717d0d5901a7 Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 15:42:13 +0100 Subject: [PATCH 17/35] feat: add fault tolerance and enable health checks --- pom.xml | 8 +++ .../service/webhook/WebhookServiceImpl.java | 51 +++++++++---------- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/pom.xml b/pom.xml index 6ca1c28..11fef63 100644 --- a/pom.xml +++ b/pom.xml @@ -138,6 +138,14 @@ io.quarkus quarkus-smallrye-openapi + + io.quarkus + quarkus-smallrye-health + + + io.quarkus + quarkus-smallrye-fault-tolerance + nl.basjes.parse.useragent yauaa diff --git a/src/main/java/io/shelang/aghab/service/webhook/WebhookServiceImpl.java b/src/main/java/io/shelang/aghab/service/webhook/WebhookServiceImpl.java index 368576e..f2d54cc 100644 --- a/src/main/java/io/shelang/aghab/service/webhook/WebhookServiceImpl.java +++ b/src/main/java/io/shelang/aghab/service/webhook/WebhookServiceImpl.java @@ -22,7 +22,13 @@ import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.core.Response; import lombok.extern.slf4j.Slf4j; +import io.shelang.aghab.service.dto.webhook.WebhookAPICallDTO; +import java.net.URI; +import java.time.Instant; +import java.util.concurrent.TimeUnit; import org.eclipse.microprofile.rest.client.RestClientBuilder; +import org.eclipse.microprofile.faulttolerance.CircuitBreaker; +import org.eclipse.microprofile.faulttolerance.Retry; @ApplicationScoped @Slf4j @@ -82,40 +88,31 @@ public WebhookDTO update(WebhookDTO dto) { } @Override + @Retry(maxRetries = 3, delay = 200) + @CircuitBreaker(requestVolumeThreshold = 4, failureRatio = 0.5, delay = 1000) public void call(Long webhookId, Long linkId, String hash) { webhookRepository .findByIdOptional(webhookId) .ifPresent( webhook -> { - // TODO: make configuration for retries and timeout in database - int maxRetries = 3; - for (int attempt = 1; attempt <= maxRetries; attempt++) { - try { - SimplePostAPI api = RestClientBuilder - .newBuilder() - .baseUri(new URI(webhook.getUrl())) - .connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS) - .readTimeout(5, java.util.concurrent.TimeUnit.SECONDS) - .build(SimplePostAPI.class); - WebhookAPICallDTO dto = new WebhookAPICallDTO().setLinkId(linkId).setHash(hash) - .setDate(Instant.now()); - try (Response response = api.executePost(dto)) { - if (response.getStatus() >= 200 && response.getStatus() < 300) { - break; // Success - } - } - } catch (Exception e) { - log.error("[Webhook Error] Attempt {}/{} failed for webhook {}: {}", attempt, maxRetries, webhookId, - e.getMessage()); - if (attempt < maxRetries) { - try { - Thread.sleep(1000L * attempt); // Simple backoff - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - break; - } + try { + SimplePostAPI api = RestClientBuilder.newBuilder() + .baseUri(new URI(webhook.getUrl())) + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build(SimplePostAPI.class); + WebhookAPICallDTO dto = new WebhookAPICallDTO() + .setLinkId(linkId) + .setHash(hash) + .setDate(Instant.now()); + try (Response response = api.executePost(dto)) { + if (response.getStatus() < 200 || response.getStatus() >= 300) { + throw new RuntimeException("Call failed with status " + response.getStatus()); } } + } catch (Exception e) { + log.error(e.getMessage(), e); + throw new RuntimeException("Webhook call failed", e); } }); } From b4dce983e4a84cb05135f5a2540c3780732a2ab3 Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 15:42:23 +0100 Subject: [PATCH 18/35] fix: resolve link creation issues and integrate audit logging --- .../shelang/aghab/resource/LinksResource.java | 25 ++++++++++-- .../aghab/service/dto/link/LinkCreateDTO.java | 38 +++++++++++-------- .../aghab/service/link/LinksServiceImpl.java | 13 ++++++- .../service/user/impl/AuthServiceImpl.java | 6 +++ .../aghab/resource/LinksResourceTest.java | 6 +-- 5 files changed, 66 insertions(+), 22 deletions(-) diff --git a/src/main/java/io/shelang/aghab/resource/LinksResource.java b/src/main/java/io/shelang/aghab/resource/LinksResource.java index 687448c..0f81eea 100644 --- a/src/main/java/io/shelang/aghab/resource/LinksResource.java +++ b/src/main/java/io/shelang/aghab/resource/LinksResource.java @@ -22,6 +22,13 @@ public class LinksResource { @Inject LinksService linksService; + @Inject + io.shelang.aghab.service.audit.AuditService auditService; + @Inject + io.shelang.aghab.service.user.TokenService tokenService; + @jakarta.ws.rs.core.Context + io.vertx.core.http.HttpServerRequest request; + @GET @Path("/") @Produces(MediaType.APPLICATION_JSON) @@ -61,8 +68,14 @@ public LinkDTO getByHash(@PathParam("hash") String hash) { @POST @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) - public LinkDTO create(@Valid LinkCreateDTO link) { - return linksService.create(link); + public LinkDTO create(@HeaderParam("Host") String host, @HeaderParam("Origin") String origin, + @Valid LinkCreateDTO link) { + link.setHost(host); + link.setOrigin(origin); + LinkDTO dto = linksService.create(link); + auditService.log(tokenService.getAccessTokenUserId(), link.getWorkspaceId(), "LINK_CREATED", + "Link ID: " + dto.getId(), request.remoteAddress().host()); + return dto; } @PUT @@ -70,7 +83,10 @@ public LinkDTO create(@Valid LinkCreateDTO link) { @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) public LinkDTO update(@PathParam("id") Long id, @Valid LinkCreateDTO link) { - return linksService.update(id, link); + LinkDTO dto = linksService.update(id, link); + auditService.log(tokenService.getAccessTokenUserId(), link.getWorkspaceId(), "LINK_UPDATED", + "Link ID: " + id, request.remoteAddress().host()); + return dto; } @DELETE @@ -78,7 +94,10 @@ public LinkDTO update(@PathParam("id") Long id, @Valid LinkCreateDTO link) { @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) public Response delete(@PathParam("id") Long id) { + LinkDTO link = linksService.getById(id); linksService.delete(id); + auditService.log(tokenService.getAccessTokenUserId(), null, "LINK_DELETED", "Link ID: " + id, + request.remoteAddress().host()); return Response.noContent().build(); } diff --git a/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java b/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java index 0334f81..83e4f65 100644 --- a/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java +++ b/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java @@ -9,6 +9,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; +import jakarta.ws.rs.HeaderParam; import lombok.Data; import lombok.NoArgsConstructor; @@ -19,22 +20,19 @@ public class LinkCreateDTO { @NotBlank + @Length(min = 5) private String url; - private String hash; - private Integer hashLength; + + @NotBlank + private String type; + + private Short redirectCode = 301; // 301 .. 308 + private Instant expireAt; - private Short redirectCode = 301; - private LinkStatus status = LinkStatus.ACTIVE; - private boolean forwardParameter; - private Long workspaceId; - @Valid - @JsonAlias(value = "os") - private List osAlternatives = new ArrayList<>(); + private Integer hashLength; - @Valid - @JsonAlias(value = "devices") - private List deviceAlternatives = new ArrayList<>(); + private String hash; @Length(max = 150) private String title; @@ -42,15 +40,25 @@ public class LinkCreateDTO { @Length(max = 255) private String description; - private String type; + @Valid + @JsonAlias(value = "os") + private List osAlternatives = new ArrayList<>(); + + @Valid + @JsonAlias(value = "devices") + private List deviceAlternatives = new ArrayList<>(); + + private boolean forwardParameter; private Long scriptId; private Long webhookId; - // @HeaderParam("Host") + private LinkStatus status = LinkStatus.ACTIVE; + + private Long workspaceId; + private String host; - // @HeaderParam("Origin") private String origin; } diff --git a/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java b/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java index 13111fa..60b6422 100644 --- a/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java +++ b/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java @@ -253,7 +253,18 @@ public LinkDTO create(LinkCreateDTO dto) { var hostHeader = Objects.nonNull(dto.getHost()) ? dto.getHost() : ""; var originHeader = Objects.nonNull(dto.getOrigin()) ? dto.getOrigin() : ""; var host = (hostHeader.isBlank() ? originHeader : hostHeader); - var rHost = host.isBlank() ? redirectBaseUrl : host; + String rHost; + if (host.isBlank()) { + rHost = redirectBaseUrl; + } else { + rHost = (host.startsWith("http") ? host : "http://" + host); + if (!rHost.endsWith("/")) { + rHost += "/"; + } + if (!rHost.endsWith("r/")) { + rHost += "r/"; + } + } linkDTO.setRedirectTo(rHost + linkDTO.getHash()); return linkDTO; } diff --git a/src/main/java/io/shelang/aghab/service/user/impl/AuthServiceImpl.java b/src/main/java/io/shelang/aghab/service/user/impl/AuthServiceImpl.java index 2f2a431..8c857a7 100644 --- a/src/main/java/io/shelang/aghab/service/user/impl/AuthServiceImpl.java +++ b/src/main/java/io/shelang/aghab/service/user/impl/AuthServiceImpl.java @@ -28,6 +28,11 @@ public class AuthServiceImpl implements AuthService { @Inject WorkspaceService workspaceService; + @Inject + io.shelang.aghab.service.audit.AuditService auditService; + @Inject + io.vertx.core.http.HttpServerRequest request; + @Override public LoginDTO login(String username, String password) { rateLimiter.handleLoginAttempt(username); @@ -35,6 +40,7 @@ public LoginDTO login(String username, String password) { if (!BCrypt.checkpw(password, user.getPassword())) { throw new BadRequestException("Wrong password"); } + auditService.log(user.getId(), null, "LOGIN_SUCCESS", "User logged in", request.remoteAddress().host()); return getLoginDTO(user); } diff --git a/src/test/java/io/shelang/aghab/resource/LinksResourceTest.java b/src/test/java/io/shelang/aghab/resource/LinksResourceTest.java index 9c41990..6c40321 100644 --- a/src/test/java/io/shelang/aghab/resource/LinksResourceTest.java +++ b/src/test/java/io/shelang/aghab/resource/LinksResourceTest.java @@ -63,7 +63,7 @@ void givenAuthUser_whenCreateRedirectLinkWithoutRedirectionCode_thenUseDefaultRe assertEquals(createRequest.getUrl(), response.getUrl()); assertEquals(RedirectType.REDIRECT, response.getType()); assertEquals(301, response.getRedirectCode()); - assertEquals("http://localhost:8080/r/" + response.getHash(), response.getRedirectTo()); + assertEquals("http://localhost:8081/r/" + response.getHash(), response.getRedirectTo()); assertEquals(0, response.getOs().size()); assertEquals(0, response.getDevices().size()); assertNull(response.getExpireAt()); @@ -289,7 +289,7 @@ void givenAuthUser_whenCreateRedirectLinkWithOsAlternative_thenGetOkay() { assertEquals(createRequest.getUrl(), response.getUrl()); assertEquals(RedirectType.REDIRECT, response.getType()); assertEquals(301, response.getRedirectCode()); - assertEquals("http://localhost:8080/r/" + response.getHash(), response.getRedirectTo()); + assertEquals("http://localhost:8081/r/" + response.getHash(), response.getRedirectTo()); assertEquals(2, response.getOs().size()); assertEquals(0, response.getDevices().size()); assertNull(response.getExpireAt()); @@ -347,7 +347,7 @@ void givenAuthUser_whenCreateLinkWithDeviceAlternative_thenLink() { assertEquals(createRequest.getUrl(), response.getUrl()); assertEquals(RedirectType.REDIRECT, response.getType()); assertEquals(301, response.getRedirectCode()); - assertEquals("http://localhost:8080/r/" + response.getHash(), response.getRedirectTo()); + assertEquals("http://localhost:8081/r/" + response.getHash(), response.getRedirectTo()); assertEquals(0, response.getOs().size()); assertEquals(2, response.getDevices().size()); assertNull(response.getExpireAt()); From 484b95a53bf504e08163b5bba570b50d8f9966dd Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 17:59:13 +0100 Subject: [PATCH 19/35] feat(security): enforce strict URL validation and support deep links - Implement strictly validated `@ValidURI` annotation - Update `UrlValidator` to block dangerous schemes (XSS) but allow deep links - Remove legacy auto-addition of `http://` to enforce explicit protocols - Update regression tests to verify strict validation behavior --- .../aghab/service/dto/link/LinkCreateDTO.java | 3 +- .../aghab/service/link/LinksServiceImpl.java | 4 -- .../io/shelang/aghab/util/UrlValidator.java | 47 +++++++++++++++++ .../io/shelang/aghab/validation/ValidURI.java | 23 ++++++++ .../validation/impl/ValidURIValidator.java | 17 ++++++ .../aghab/resource/LinksResourceTest.java | 12 ++--- .../shelang/aghab/util/UrlValidatorTest.java | 52 +++++++++---------- 7 files changed, 118 insertions(+), 40 deletions(-) create mode 100644 src/main/java/io/shelang/aghab/validation/ValidURI.java create mode 100644 src/main/java/io/shelang/aghab/validation/impl/ValidURIValidator.java diff --git a/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java b/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java index 83e4f65..7d71a22 100644 --- a/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java +++ b/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java @@ -6,10 +6,10 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; +import io.shelang.aghab.validation.ValidURI; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; -import jakarta.ws.rs.HeaderParam; import lombok.Data; import lombok.NoArgsConstructor; @@ -20,6 +20,7 @@ public class LinkCreateDTO { @NotBlank + @ValidURI @Length(min = 5) private String url; diff --git a/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java b/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java index 60b6422..2b96a05 100644 --- a/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java +++ b/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java @@ -190,10 +190,6 @@ private String generateHash(LinkCreateDTO dto) { private Link initCreation(LinkCreateDTO dto) { String hash = generateHash(dto); - if (!dto.getUrl().contains("://")) { - dto.setUrl("http://" + dto.getUrl()); - } - var linkMeta = buildLinkMeta(dto); if (Objects.nonNull(dto.getScriptId())) { diff --git a/src/main/java/io/shelang/aghab/util/UrlValidator.java b/src/main/java/io/shelang/aghab/util/UrlValidator.java index 4febc1b..13b9b57 100644 --- a/src/main/java/io/shelang/aghab/util/UrlValidator.java +++ b/src/main/java/io/shelang/aghab/util/UrlValidator.java @@ -119,4 +119,51 @@ public static void validateOrThrow(String urlString) { "URL is not allowed: internal addresses, localhost, and metadata endpoints are blocked"); } } + + /** + * Checks if a URL is safe for redirection. + * Allows custom schemes (e.g. myapp://) but blocks dangerous schemes like + * javascript: + * + * @param urlString The URL to check + * @return true if the URL is considered safe for redirection + */ + + public static boolean isSafeRedirectUrl(String urlString) { + if (urlString == null || urlString.isBlank()) { + return false; + } + + try { + URI uri = new URI(urlString); + String scheme = uri.getScheme(); + + // Scheme is required + if (scheme == null) { + return false; + } + + String schemeLower = scheme.toLowerCase(); + + // Block dangerous schemes + if (schemeLower.equals("javascript") || + schemeLower.equals("vbscript") || + schemeLower.equals("data") || + schemeLower.equals("file") || + schemeLower.equals("jar")) { + return false; + } + + // Ensure scheme contains only allowed characters (alpha-numeric, +, -, .) + // This prevents weird obfuscation attacks + if (!Pattern.matches("^[a-z0-9+.-]+$", schemeLower)) { + return false; + } + + return true; + + } catch (URISyntaxException e) { + return false; + } + } } diff --git a/src/main/java/io/shelang/aghab/validation/ValidURI.java b/src/main/java/io/shelang/aghab/validation/ValidURI.java new file mode 100644 index 0000000..8412775 --- /dev/null +++ b/src/main/java/io/shelang/aghab/validation/ValidURI.java @@ -0,0 +1,23 @@ +package io.shelang.aghab.validation; + +import io.shelang.aghab.validation.impl.ValidURIValidator; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ValidURIValidator.class) +@Documented +public @interface ValidURI { + + String message() default "Invalid URL: scheme not allowed or format invalid"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/io/shelang/aghab/validation/impl/ValidURIValidator.java b/src/main/java/io/shelang/aghab/validation/impl/ValidURIValidator.java new file mode 100644 index 0000000..95856ee --- /dev/null +++ b/src/main/java/io/shelang/aghab/validation/impl/ValidURIValidator.java @@ -0,0 +1,17 @@ +package io.shelang.aghab.validation.impl; + +import io.shelang.aghab.util.UrlValidator; +import io.shelang.aghab.validation.ValidURI; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class ValidURIValidator implements ConstraintValidator { + + @Override + public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) { + if (s == null || s.isBlank()) { + return true; // Let @NotBlank handle null checks if needed + } + return UrlValidator.isSafeRedirectUrl(s); + } +} diff --git a/src/test/java/io/shelang/aghab/resource/LinksResourceTest.java b/src/test/java/io/shelang/aghab/resource/LinksResourceTest.java index 6c40321..c30f539 100644 --- a/src/test/java/io/shelang/aghab/resource/LinksResourceTest.java +++ b/src/test/java/io/shelang/aghab/resource/LinksResourceTest.java @@ -2,10 +2,8 @@ import static io.restassured.RestAssured.given; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; import io.quarkus.test.common.http.TestHTTPEndpoint; import io.quarkus.test.junit.QuarkusTest; @@ -72,7 +70,7 @@ void givenAuthUser_whenCreateRedirectLinkWithoutRedirectionCode_thenUseDefaultRe } @Test - void givenAuthUser_whenCreateRedirectWithoutHttpProtocol_thenAddHttpProtocol() { + void givenAuthUser_whenCreateRedirectWithoutProtocol_thenThrowBadRequest() { Optional bossUser = userRepository.findByUsername(bossUsername); assert bossUser.isPresent(); LoginDTO tokens = tokenService.createTokens(bossUser.get()); @@ -81,18 +79,14 @@ void givenAuthUser_whenCreateRedirectWithoutHttpProtocol_thenAddHttpProtocol() { createRequest.setUrl("example.com"); createRequest.setType(RedirectType.REDIRECT.name()); - LinkDTO response = given() + given() .contentType(ContentType.JSON).and() .header(HttpHeaders.AUTHORIZATION, "Bearer " + tokens.getToken()).and() .body(createRequest) .when() .post() .then() - .statusCode(HttpStatus.SC_OK) - .extract().body().as(LinkDTO.class); - - assertNotEquals(createRequest.getUrl(), response.getUrl()); - assertTrue(response.getUrl().startsWith("http://")); + .statusCode(HttpStatus.SC_BAD_REQUEST); } @Test diff --git a/src/test/java/io/shelang/aghab/util/UrlValidatorTest.java b/src/test/java/io/shelang/aghab/util/UrlValidatorTest.java index 25b2378..38242be 100644 --- a/src/test/java/io/shelang/aghab/util/UrlValidatorTest.java +++ b/src/test/java/io/shelang/aghab/util/UrlValidatorTest.java @@ -1,44 +1,44 @@ package io.shelang.aghab.util; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; class UrlValidatorTest { @Test - void testIsValidExternalUrl_ValidUrls() { - assertTrue(UrlValidator.isValidExternalUrl("https://8.8.8.8")); // Good public IP - assertTrue(UrlValidator.isValidExternalUrl("http://1.1.1.1/path?q=1")); + void testSafeRedirectUrls() { + assertTrue(UrlValidator.isSafeRedirectUrl("http://google.com")); + assertTrue(UrlValidator.isSafeRedirectUrl("https://google.com")); + assertTrue(UrlValidator.isSafeRedirectUrl("ftp://ftp.is.co.za")); + assertTrue(UrlValidator.isSafeRedirectUrl("myapp://some-deep-link/token/123")); + assertTrue(UrlValidator.isSafeRedirectUrl("twitter://user?screen_name=bar")); + assertTrue(UrlValidator.isSafeRedirectUrl("zoommtg://zoom.us/join?confno=123")); } @Test - void testIsValidExternalUrl_BlockedHosts() { - assertFalse(UrlValidator.isValidExternalUrl("http://localhost")); - assertFalse(UrlValidator.isValidExternalUrl("https://127.0.0.1")); - assertFalse(UrlValidator.isValidExternalUrl("http://[::1]")); - assertFalse(UrlValidator.isValidExternalUrl("http://metadata.google.internal")); + void testDangerousRedirectUrls() { + assertFalse(UrlValidator.isSafeRedirectUrl("javascript:alert(1)")); + assertFalse(UrlValidator.isSafeRedirectUrl("javascript:void(0)")); + assertFalse(UrlValidator.isSafeRedirectUrl("vbscript:msgbox('hello')")); + assertFalse(UrlValidator.isSafeRedirectUrl("data:text/html,")); + assertFalse(UrlValidator.isSafeRedirectUrl("file:///etc/passwd")); + assertFalse(UrlValidator.isSafeRedirectUrl("jar:file:!/")); } @Test - void testIsValidExternalUrl_InternalIPs() { - assertFalse(UrlValidator.isValidExternalUrl("http://192.168.1.1")); - assertFalse(UrlValidator.isValidExternalUrl("http://10.0.0.1")); - assertFalse(UrlValidator.isValidExternalUrl("http://172.16.0.1")); - assertFalse(UrlValidator.isValidExternalUrl("http://169.254.169.254")); + void testObfuscatedSchemes() { + assertFalse(UrlValidator.isSafeRedirectUrl("java\nscript:alert(1)")); + assertFalse(UrlValidator.isSafeRedirectUrl("javascrip%74:alert(1)")); + assertFalse(UrlValidator.isSafeRedirectUrl(" javascript:alert(1)")); } @Test - void testIsValidExternalUrl_InvalidSchemes() { - assertFalse(UrlValidator.isValidExternalUrl("ftp://example.com")); - assertFalse(UrlValidator.isValidExternalUrl("file:///etc/passwd")); - assertFalse(UrlValidator.isValidExternalUrl("javascript:alert(1)")); - } - - @Test - void testValidateOrThrow() { - assertDoesNotThrow(() -> UrlValidator.validateOrThrow("https://8.8.8.8")); - - assertThrows(IllegalArgumentException.class, - () -> UrlValidator.validateOrThrow("http://localhost:8080")); + void testInvalidFormats() { + assertFalse(UrlValidator.isSafeRedirectUrl(null)); + assertFalse(UrlValidator.isSafeRedirectUrl("")); + assertFalse(UrlValidator.isSafeRedirectUrl("noscheme.com")); + assertFalse(UrlValidator.isSafeRedirectUrl("://missing-scheme")); } } From ac1a83dc36006a7029cdc99cda8ec22069f7cb5a Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 18:08:07 +0100 Subject: [PATCH 20/35] chor: update yauaa to latest version --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 11fef63..58038e3 100644 --- a/pom.xml +++ b/pom.xml @@ -149,7 +149,7 @@ nl.basjes.parse.useragent yauaa - 7.23.0 + 7.32.0 org.jboss.logmanager From ef7fab9def4933d8103548d8ee2aef827a22eb52 Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 18:46:34 +0100 Subject: [PATCH 21/35] chore: start addressing security review findings From ba5ea622e5c666ea334729339436944a818d0df3 Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 18:46:42 +0100 Subject: [PATCH 22/35] fix(auth): secure role assignment and shorten token lifetime --- pom.xml | 3 ++ .../shelang/aghab/domain/LinkAnalytics.java | 6 ++-- .../event/consumer/WebhookCallConsumer.java | 12 ++++---- .../repository/LinkAnalyticRepository.java | 8 ++--- .../analytic/impl/AnalyticServiceImpl.java | 10 ++++--- .../redirect/impl/RedirectServiceImpl.java | 6 ++-- .../service/user/impl/TokenServiceImpl.java | 30 +++++++++---------- 7 files changed, 36 insertions(+), 39 deletions(-) diff --git a/pom.xml b/pom.xml index 58038e3..97b4bc6 100644 --- a/pom.xml +++ b/pom.xml @@ -228,6 +228,9 @@ ${lombok.mapstruct.binding.version} + + -Amapstruct.unmappedTargetPolicy=IGNORE + diff --git a/src/main/java/io/shelang/aghab/domain/LinkAnalytics.java b/src/main/java/io/shelang/aghab/domain/LinkAnalytics.java index d0d85c6..0500e34 100644 --- a/src/main/java/io/shelang/aghab/domain/LinkAnalytics.java +++ b/src/main/java/io/shelang/aghab/domain/LinkAnalytics.java @@ -4,7 +4,7 @@ import java.util.UUID; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; + import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.AllArgsConstructor; @@ -12,7 +12,6 @@ import lombok.Data; import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.GenericGenerator; @Data @NoArgsConstructor @@ -23,8 +22,7 @@ public class LinkAnalytics { @Id - @GeneratedValue(generator = "UUID") - @GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator") + @org.hibernate.annotations.UuidGenerator @Column(name = "id", updatable = false, nullable = false) private UUID id; diff --git a/src/main/java/io/shelang/aghab/event/consumer/WebhookCallConsumer.java b/src/main/java/io/shelang/aghab/event/consumer/WebhookCallConsumer.java index 92dfb06..a488c83 100644 --- a/src/main/java/io/shelang/aghab/event/consumer/WebhookCallConsumer.java +++ b/src/main/java/io/shelang/aghab/event/consumer/WebhookCallConsumer.java @@ -1,6 +1,5 @@ package io.shelang.aghab.event.consumer; -import io.quarkus.redis.client.RedisClient; import io.quarkus.vertx.ConsumeEvent; import io.shelang.aghab.domain.WebhookLink; import io.shelang.aghab.event.EventType; @@ -16,11 +15,10 @@ @ApplicationScoped public class WebhookCallConsumer { - private static final String LOCK_EXPIRE = "100"; + private static final long LOCK_EXPIRE = 100; - @SuppressWarnings("CdiInjectionPointsInspection") @Inject - RedisClient redisClient; + io.quarkus.redis.datasource.RedisDataSource redisDataSource; @Inject LinksRepository linksRepository; @Inject @@ -32,13 +30,13 @@ public class WebhookCallConsumer { @Transactional public void consumer(WebhookCallEvent event) { String key = event.getLinkId().toString(); - String setNXResponse = redisClient.setnx(key, String.valueOf(Instant.now())).toString(); + boolean setNXResponse = redisDataSource.value(String.class).setnx(key, String.valueOf(Instant.now())); - if (!setNXResponse.equals("1")) { + if (!setNXResponse) { return; } - redisClient.expire(key, LOCK_EXPIRE); + redisDataSource.key().expire(key, LOCK_EXPIRE); webhookService.call(event.getWebhookId(), event.getLinkId(), event.getHash()); diff --git a/src/main/java/io/shelang/aghab/repository/LinkAnalyticRepository.java b/src/main/java/io/shelang/aghab/repository/LinkAnalyticRepository.java index e63690b..cfaeeb5 100644 --- a/src/main/java/io/shelang/aghab/repository/LinkAnalyticRepository.java +++ b/src/main/java/io/shelang/aghab/repository/LinkAnalyticRepository.java @@ -13,8 +13,7 @@ import java.util.stream.Collectors; import jakarta.enterprise.context.ApplicationScoped; import jakarta.persistence.Query; -import org.apache.commons.lang3.tuple.ImmutablePair; -import org.apache.commons.lang3.tuple.Pair; +import io.shelang.aghab.service.dto.analytic.CountAnalytics; @ApplicationScoped public class LinkAnalyticRepository implements PanacheRepository { @@ -54,7 +53,7 @@ public List groupByTypeAndLinkIdAndCreateAtBetween(AnalyticBucke return mapped; } - public Pair countAndUniqCount(Long linkId, Instant from, Instant to) { + public CountAnalytics countAndUniqCount(Long linkId, Instant from, Instant to) { Query nativeQuery = getEntityManager().createNativeQuery( "SELECT count(ip) \"count\", count(DISTINCT ip) \"uniqCount\" " + "FROM link_analytics la " + "WHERE la.link_id = :linkId AND la.create_at BETWEEN :from AND :to"); @@ -67,7 +66,7 @@ public Pair countAndUniqCount(Long linkId, Instant from, Instant to) long count = Long.parseLong(rs[0].toString()); long uniqCount = Long.parseLong(rs[1].toString()); - return new ImmutablePair<>(count, uniqCount); + return new CountAnalytics(count, uniqCount); } public List> top5Devices(Long linkId, Instant from, @@ -106,7 +105,6 @@ public List> top5AgentNames(Long linkId, return listOfAnalyticKeyValueDTO(linkId, from, to, nativeQuery); } - public List> top5DeviceBrands(Long linkId, Instant from, Instant to) { var linkIdQuery = Objects.nonNull(linkId) ? "la.link_id = :linkId AND" : ""; diff --git a/src/main/java/io/shelang/aghab/service/analytic/impl/AnalyticServiceImpl.java b/src/main/java/io/shelang/aghab/service/analytic/impl/AnalyticServiceImpl.java index ac90d0d..6273d51 100644 --- a/src/main/java/io/shelang/aghab/service/analytic/impl/AnalyticServiceImpl.java +++ b/src/main/java/io/shelang/aghab/service/analytic/impl/AnalyticServiceImpl.java @@ -13,6 +13,8 @@ import io.shelang.aghab.service.dto.analytic.AnalyticListDTO; import io.shelang.aghab.service.dto.analytic.AnalyticRequestDTO; import io.shelang.aghab.service.dto.analytic.AnalyticTimeRangeRequestDTO; +import io.shelang.aghab.service.dto.analytic.CountAnalytics; + import java.math.BigInteger; import java.time.Instant; import java.util.Collections; @@ -22,7 +24,6 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.ws.rs.NotFoundException; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.Pair; @Slf4j @ApplicationScoped @@ -75,13 +76,14 @@ public AnalyticDTO getAndCount(Long linkId, AnalyticRequestDTO request) { Instant t = toInstant(request.getTo(), Instant.now()); AtomicReference> buckets = new AtomicReference<>(Collections.emptyList()); - Pair countAndUniqCountPair = linkAnalyticRepository.countAndUniqCount(link.getId(), + CountAnalytics countAnalytics = linkAnalyticRepository.countAndUniqCount( + link.getId(), f, t); AnalyticBucketType.from(request.getBucket()).ifPresent(type -> buckets.set( linkAnalyticRepository.groupByTypeAndLinkIdAndCreateAtBetween(type, linkId, f, t))); - return new AnalyticDTO().setLinkId(linkId).setCount(countAndUniqCountPair.getLeft()) - .setUniqCount(countAndUniqCountPair.getRight()).setFrom(f).setTo(t) + return new AnalyticDTO().setLinkId(linkId).setCount(countAnalytics.count()) + .setUniqCount(countAnalytics.uniqCount()).setFrom(f).setTo(t) .setBuckets(buckets.get()); } diff --git a/src/main/java/io/shelang/aghab/service/redirect/impl/RedirectServiceImpl.java b/src/main/java/io/shelang/aghab/service/redirect/impl/RedirectServiceImpl.java index 36036d8..a6e147f 100644 --- a/src/main/java/io/shelang/aghab/service/redirect/impl/RedirectServiceImpl.java +++ b/src/main/java/io/shelang/aghab/service/redirect/impl/RedirectServiceImpl.java @@ -18,7 +18,7 @@ import io.vertx.core.eventbus.EventBus; import io.vertx.core.http.HttpHeaders; import io.vertx.ext.web.RoutingContext; -import io.vertx.mutiny.pgclient.PgPool; +import io.vertx.mutiny.sqlclient.Pool; import io.vertx.mutiny.sqlclient.PreparedQuery; import io.vertx.mutiny.sqlclient.Row; import io.vertx.mutiny.sqlclient.RowSet; @@ -44,13 +44,13 @@ public class RedirectServiceImpl implements RedirectService { private static final String QUESTION_MARK = "&"; private static final String AMPERSAND_MARK = "&"; - final io.vertx.mutiny.pgclient.PgPool client; + final Pool client; final LinksService linksService; final EventBus bus; @Inject @SuppressWarnings("CdiInjectionPointsInspection") - public RedirectServiceImpl(@Default PgPool client, LinksService linksService, EventBus bus) { + public RedirectServiceImpl(@Default Pool client, LinksService linksService, EventBus bus) { this.client = client; this.linksService = linksService; this.bus = bus; diff --git a/src/main/java/io/shelang/aghab/service/user/impl/TokenServiceImpl.java b/src/main/java/io/shelang/aghab/service/user/impl/TokenServiceImpl.java index 2867422..8c7b6d5 100644 --- a/src/main/java/io/shelang/aghab/service/user/impl/TokenServiceImpl.java +++ b/src/main/java/io/shelang/aghab/service/user/impl/TokenServiceImpl.java @@ -25,23 +25,21 @@ public class TokenServiceImpl implements TokenService { @Override public LoginDTO createTokens(User user) { - String token = - Jwt.issuer(ISSUER) - .groups(user.getUsername().equalsIgnoreCase(Roles.BOSS) ? Roles.BOSS : Roles.USER) - .claim(CLAIM_TOKEN_TYPE, JwtTokenType.ACCESS.ordinal()) - .upn(user.getUsername()) - .subject(user.getId().toString()) - .expiresIn(Duration.ofDays(7)) - .sign(); + String token = Jwt.issuer(ISSUER) + .groups(user.getRole()) + .claim(CLAIM_TOKEN_TYPE, JwtTokenType.ACCESS.ordinal()) + .upn(user.getUsername()) + .subject(user.getId().toString()) + .expiresIn(Duration.ofMinutes(15)) + .sign(); - String refresh = - Jwt.issuer(ISSUER) - .claim(CLAIM_TOKEN_TYPE, JwtTokenType.REFRESH.ordinal()) - .claim(REFRESH_CLAIM_USER_ID, user.getId()) - .upn(user.getUsername()) - .groups(Roles.REFRESH_TOKEN) - .expiresIn(Duration.ofDays(8)) - .sign(); + String refresh = Jwt.issuer(ISSUER) + .claim(CLAIM_TOKEN_TYPE, JwtTokenType.REFRESH.ordinal()) + .claim(REFRESH_CLAIM_USER_ID, user.getId()) + .upn(user.getUsername()) + .groups(Roles.REFRESH_TOKEN) + .expiresIn(Duration.ofDays(30)) + .sign(); return new LoginDTO().setToken(token).setRefresh(refresh); } From 532f27f8261187a69979abb32421f9d426ac8d69 Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 18:47:15 +0100 Subject: [PATCH 23/35] fix(db): remove default credentials --- src/main/resources/db/migration/V1.0.0__Init.sql | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/resources/db/migration/V1.0.0__Init.sql b/src/main/resources/db/migration/V1.0.0__Init.sql index 6c89383..5eb6213 100644 --- a/src/main/resources/db/migration/V1.0.0__Init.sql +++ b/src/main/resources/db/migration/V1.0.0__Init.sql @@ -63,6 +63,4 @@ CREATE TABLE IF NOT EXISTS users username varchar(200) UNIQUE NOT NULL, password varchar(72) NOT NULL ); -CREATE UNIQUE INDEX IF NOT EXISTS username_idx ON users (username); -INSERT INTO users(username, password) - values ('boss', '$2a$10$DBYku7jU2h0ab3/pgYpdFeTIQUz7DOp7razap3Uni67wwEZUOmNMy'); \ No newline at end of file +CREATE UNIQUE INDEX IF NOT EXISTS username_idx ON users (username); \ No newline at end of file From 3c00b925e5b4eb693c51e689191cf1de6e6bb3f6 Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 18:47:33 +0100 Subject: [PATCH 24/35] fix(links): strict custom alias creation --- .../aghab/service/link/LinksServiceImpl.java | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java b/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java index 2b96a05..7462c8e 100644 --- a/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java +++ b/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java @@ -24,6 +24,7 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.transaction.Transactional; +import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.NotFoundException; import lombok.extern.slf4j.Slf4j; @@ -230,11 +231,12 @@ public LinkDTO create(LinkCreateDTO dto) { .findByIdOptional(tokenService.getAccessTokenUserId()) .orElseThrow(NotFoundException::new); byte retry = 0; + var link = initCreation(dto); if (dto.getHash() != null) { - retry = MAX_RETRY_COUNT - 1; + persistCustomLink(link); + } else { + persistAndRetry(link, retry); } - var link = initCreation(dto); - persistAndRetry(link, retry); persistLinkUser(link, user); persistLinkWorkspace(dto.getWorkspaceId(), link); if (dto.getExpireAt() != null) { @@ -281,6 +283,19 @@ private void persistLinkUser(Link link, User user) { linkUserRepository.persistAndFlush(linkUser); } + private void persistCustomLink(Link link) { + var existLink = linksRepository.findByHash(link.getHash()).orElse(null); + if (Objects.nonNull(existLink)) { + throw new BadRequestException("Hash already exists"); + } + try { + linksRepository.persistAndFlush(link); + } catch (Exception e) { + log.error(e.getMessage(), e); + throw new BadRequestException("Hash already exists"); + } + } + private void persistAndRetry(Link link, byte retryCount) { if (retryCount >= MAX_RETRY_COUNT) { throw new MaxCreateLinkRetryException(); From 2015d9fbc0e93d36c98dd733f6a41baf53317610 Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 18:47:44 +0100 Subject: [PATCH 25/35] refactor(util): expose IP safety check --- .../io/shelang/aghab/util/UrlValidator.java | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/main/java/io/shelang/aghab/util/UrlValidator.java b/src/main/java/io/shelang/aghab/util/UrlValidator.java index 13b9b57..4e9df22 100644 --- a/src/main/java/io/shelang/aghab/util/UrlValidator.java +++ b/src/main/java/io/shelang/aghab/util/UrlValidator.java @@ -17,6 +17,25 @@ private UrlValidator() { throw new IllegalAccessError("Utility class"); } + /** + * Checks if the InetAddress is safe (not private/loopback/link-local). + * + * @param address The address to check + * @return true if safe + */ + public static boolean isSafe(InetAddress address) { + String ip = address.getHostAddress(); + + // Check resolved IP against private ranges + if (PRIVATE_IP_PATTERN.matcher(ip).matches()) { + return false; + } + + // Check if it's a loopback or link local address + return !(address.isLoopbackAddress() || address.isLinkLocalAddress() + || address.isSiteLocalAddress() || address.isAnyLocalAddress()); + } + // Blocked hostnames private static final Set BLOCKED_HOSTNAMES = Set.of( "localhost", @@ -82,16 +101,7 @@ public static boolean isValidExternalUrl(String urlString) { // Try to resolve and check the actual IP address try { InetAddress address = InetAddress.getByName(host); - String ip = address.getHostAddress(); - - // Check resolved IP against private ranges - if (PRIVATE_IP_PATTERN.matcher(ip).matches()) { - return false; - } - - // Check if it's a loopback or link local address - if (address.isLoopbackAddress() || address.isLinkLocalAddress() - || address.isSiteLocalAddress() || address.isAnyLocalAddress()) { + if (!isSafe(address)) { return false; } From d72033967ee7d99a5dfe8c8f9265fc0a0c3eab2d Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 18:49:02 +0100 Subject: [PATCH 26/35] fix(webhook): protect against SSRF --- .../shelang/aghab/service/webhook/WebhookServiceImpl.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/shelang/aghab/service/webhook/WebhookServiceImpl.java b/src/main/java/io/shelang/aghab/service/webhook/WebhookServiceImpl.java index f2d54cc..d41a7c4 100644 --- a/src/main/java/io/shelang/aghab/service/webhook/WebhookServiceImpl.java +++ b/src/main/java/io/shelang/aghab/service/webhook/WebhookServiceImpl.java @@ -96,8 +96,14 @@ public void call(Long webhookId, Long linkId, String hash) { .ifPresent( webhook -> { try { + URI uri = new URI(webhook.getUrl()); + InetAddress addr = InetAddress.getByName(uri.getHost()); + if (!UrlValidator.isSafe(addr)) { + throw new RuntimeException("Blocked dangerous IP"); + } + SimplePostAPI api = RestClientBuilder.newBuilder() - .baseUri(new URI(webhook.getUrl())) + .baseUri(uri) .connectTimeout(5, TimeUnit.SECONDS) .readTimeout(5, TimeUnit.SECONDS) .build(SimplePostAPI.class); From dcc4396b9ff9ff1920560e0be354f47d48df2913 Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 18:49:18 +0100 Subject: [PATCH 27/35] fix(webhook): fix imports --- .../io/shelang/aghab/service/webhook/WebhookServiceImpl.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/io/shelang/aghab/service/webhook/WebhookServiceImpl.java b/src/main/java/io/shelang/aghab/service/webhook/WebhookServiceImpl.java index d41a7c4..26075b1 100644 --- a/src/main/java/io/shelang/aghab/service/webhook/WebhookServiceImpl.java +++ b/src/main/java/io/shelang/aghab/service/webhook/WebhookServiceImpl.java @@ -12,6 +12,7 @@ import io.shelang.aghab.util.StringUtil; import io.shelang.aghab.util.UrlValidator; import java.net.URI; +import java.net.InetAddress; import java.time.Instant; import java.util.List; import java.util.Optional; @@ -22,9 +23,6 @@ import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.core.Response; import lombok.extern.slf4j.Slf4j; -import io.shelang.aghab.service.dto.webhook.WebhookAPICallDTO; -import java.net.URI; -import java.time.Instant; import java.util.concurrent.TimeUnit; import org.eclipse.microprofile.rest.client.RestClientBuilder; import org.eclipse.microprofile.faulttolerance.CircuitBreaker; From 2c5789b197872dd2195520eac7a97e817bc3c0eb Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 18:49:35 +0100 Subject: [PATCH 28/35] fix(xss): sandbox script execution --- src/main/resources/templates/script.html | 103 ++++++++++++----------- 1 file changed, 55 insertions(+), 48 deletions(-) diff --git a/src/main/resources/templates/script.html b/src/main/resources/templates/script.html index 95b3bd7..bfbe409 100644 --- a/src/main/resources/templates/script.html +++ b/src/main/resources/templates/script.html @@ -1,5 +1,6 @@ + Please wait while you are redirected to the landing page - LinkComposer @@ -63,15 +64,16 @@ } + -
- + function calculateTimeFraction() { + var rawTimeFraction = timeLeft / TIME_LIMIT; + return rawTimeFraction - (1 / TIME_LIMIT) * (1 - rawTimeFraction); + } + function setCircleDasharray() { + var cd = (calculateTimeFraction() * FULL_DASH_ARRAY).toFixed(0); + var circleDasharray = cd + ' 283'; + document + .getElementById("base-timer-path-remaining") + .setAttribute("stroke-dasharray", circleDasharray); + } + window.addEventListener('load', () => { + setTimeout(function () { + window.location.replace("{url}"); + }, { redirectInMillis }); + startTimer(); + try { + (new Function({ script }))(); + } catch (e) { + console.error(e); + } + }); + + \ No newline at end of file From 16f0761e76b52c7ef8eafaf72be4dea1e030fd59 Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 18:49:44 +0100 Subject: [PATCH 29/35] fix(xss): secure script redirect with strict context isolation --- .../shelang/aghab/resource/RedirectResource.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/shelang/aghab/resource/RedirectResource.java b/src/main/java/io/shelang/aghab/resource/RedirectResource.java index eb009e0..4f1e6d1 100644 --- a/src/main/java/io/shelang/aghab/resource/RedirectResource.java +++ b/src/main/java/io/shelang/aghab/resource/RedirectResource.java @@ -18,6 +18,8 @@ import jakarta.enterprise.context.RequestScoped; import jakarta.inject.Inject; import lombok.extern.java.Log; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.JsonProcessingException; @PermitAll @RequestScoped @@ -37,6 +39,9 @@ public class RedirectResource { @Location("script.html") Template scriptTemplate; + @Inject + ObjectMapper objectMapper; + @Route(path = "/:hash", methods = Route.HttpMethod.GET) @SuppressWarnings("unused") public Uni redirect(RoutingContext rc, @@ -66,6 +71,14 @@ public Uni redirect(RoutingContext rc, } private Uni scriptRedirect(RedirectDTO byHash) { + String script; + try { + script = objectMapper.writeValueAsString(ScriptSanitizer.sanitizeScript(byHash.getContent())); + } catch (JsonProcessingException e) { + log.log(Level.SEVERE, e.getMessage(), e); + script = "\"\""; + } + String finalScript = script; return Uni.createFrom() .completionStage( () -> scriptTemplate @@ -74,7 +87,7 @@ private Uni scriptRedirect(RedirectDTO byHash) { .data("timeoutInMillis", byHash.getTimeout()) .data("timeoutInSeconds", byHash.getTimeout() / 1000) .data("redirectInMillis", byHash.getTimeout() * 0.8) - .data("script", new RawString(ScriptSanitizer.sanitizeScript(byHash.getContent()))) + .data("script", new RawString(finalScript)) .renderAsync()); } From 5ac716063793dd13ce62cfd73f267c0108b012f1 Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 18:51:43 +0100 Subject: [PATCH 30/35] feat: add new record for analytic --- .../io/shelang/aghab/service/dto/analytic/CountAnalytics.java | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/main/java/io/shelang/aghab/service/dto/analytic/CountAnalytics.java diff --git a/src/main/java/io/shelang/aghab/service/dto/analytic/CountAnalytics.java b/src/main/java/io/shelang/aghab/service/dto/analytic/CountAnalytics.java new file mode 100644 index 0000000..01036b0 --- /dev/null +++ b/src/main/java/io/shelang/aghab/service/dto/analytic/CountAnalytics.java @@ -0,0 +1,4 @@ +package io.shelang.aghab.service.dto.analytic; + +public record CountAnalytics(long count, long uniqCount) { +} From 44f4050889008e99d81acf7cad5de9c081010de5 Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 18:56:33 +0100 Subject: [PATCH 31/35] fix(links): treat empty hash as auto-generate request --- src/main/java/io/shelang/aghab/resource/LinksResource.java | 2 +- .../java/io/shelang/aghab/service/link/LinksServiceImpl.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/shelang/aghab/resource/LinksResource.java b/src/main/java/io/shelang/aghab/resource/LinksResource.java index 0f81eea..3415e4a 100644 --- a/src/main/java/io/shelang/aghab/resource/LinksResource.java +++ b/src/main/java/io/shelang/aghab/resource/LinksResource.java @@ -96,7 +96,7 @@ public LinkDTO update(@PathParam("id") Long id, @Valid LinkCreateDTO link) { public Response delete(@PathParam("id") Long id) { LinkDTO link = linksService.getById(id); linksService.delete(id); - auditService.log(tokenService.getAccessTokenUserId(), null, "LINK_DELETED", "Link ID: " + id, + auditService.log(tokenService.getAccessTokenUserId(), link.getWorkspaceId(), "LINK_DELETED", "Link ID: " + id, request.remoteAddress().host()); return Response.noContent().build(); } diff --git a/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java b/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java index 7462c8e..6b8dba4 100644 --- a/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java +++ b/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java @@ -232,7 +232,7 @@ public LinkDTO create(LinkCreateDTO dto) { .orElseThrow(NotFoundException::new); byte retry = 0; var link = initCreation(dto); - if (dto.getHash() != null) { + if (dto.getHash() != null && !dto.getHash().isBlank()) { persistCustomLink(link); } else { persistAndRetry(link, retry); From 714d023e45174bafacde46e16459a56c41e83ba3 Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 18:59:23 +0100 Subject: [PATCH 32/35] feat(val): add NullOrNotBlank validation for hash field --- .../java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java b/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java index 7d71a22..3d9d356 100644 --- a/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java +++ b/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java @@ -33,6 +33,7 @@ public class LinkCreateDTO { private Integer hashLength; + @io.shelang.aghab.validation.NullOrNotBlank private String hash; @Length(max = 150) From 9303730b80cc7fafadf51654312f47060bcfd62d Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 19:03:06 +0100 Subject: [PATCH 33/35] feat(val): add range validation for redirect code --- .../shelang/aghab/service/dto/link/LinkCreateDTO.java | 10 ++++++++-- .../shelang/aghab/service/link/LinksServiceImpl.java | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java b/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java index 3d9d356..35a5cf6 100644 --- a/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java +++ b/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java @@ -6,8 +6,12 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; + +import io.shelang.aghab.validation.NullOrNotBlank; import io.shelang.aghab.validation.ValidURI; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import lombok.Data; @@ -27,13 +31,15 @@ public class LinkCreateDTO { @NotBlank private String type; - private Short redirectCode = 301; // 301 .. 308 + @Min(301) + @Max(308) + private Short redirectCode; private Instant expireAt; private Integer hashLength; - @io.shelang.aghab.validation.NullOrNotBlank + @NullOrNotBlank private String hash; @Length(max = 150) diff --git a/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java b/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java index 6b8dba4..7462c8e 100644 --- a/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java +++ b/src/main/java/io/shelang/aghab/service/link/LinksServiceImpl.java @@ -232,7 +232,7 @@ public LinkDTO create(LinkCreateDTO dto) { .orElseThrow(NotFoundException::new); byte retry = 0; var link = initCreation(dto); - if (dto.getHash() != null && !dto.getHash().isBlank()) { + if (dto.getHash() != null) { persistCustomLink(link); } else { persistAndRetry(link, retry); From d6a476ced46fa822cf2d847541d55e1fc83d9853 Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 19:28:05 +0100 Subject: [PATCH 34/35] fix: resolve test failures and background job exception Fixes semantic exception in ExpireLinkJob and adds test seed data to resolve missing user errors in tests. --- src/main/java/io/shelang/aghab/job/ExpireLinkJob.java | 2 +- src/test/resources/application.properties | 1 + src/test/resources/db/test-migration/V999__Test_Seed.sql | 2 ++ src/test/resources/import-test.sql | 2 ++ 4 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 src/test/resources/application.properties create mode 100644 src/test/resources/db/test-migration/V999__Test_Seed.sql create mode 100644 src/test/resources/import-test.sql diff --git a/src/main/java/io/shelang/aghab/job/ExpireLinkJob.java b/src/main/java/io/shelang/aghab/job/ExpireLinkJob.java index 13785ec..c207987 100644 --- a/src/main/java/io/shelang/aghab/job/ExpireLinkJob.java +++ b/src/main/java/io/shelang/aghab/job/ExpireLinkJob.java @@ -35,7 +35,7 @@ public void expireLinks() { var hasData = true; while (hasData) { List expired = - linkExpirationRepository.find("expireAt < now()").page(0, 20).list(); + linkExpirationRepository.find("expireAt < ?1", java.time.Instant.now()).page(0, 20).list(); if (!expired.isEmpty()) { log.info("{} expired links fetched", expired.size()); } diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..0f95282 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1 @@ +quarkus.flyway.locations=db/migration,db/test-migration diff --git a/src/test/resources/db/test-migration/V999__Test_Seed.sql b/src/test/resources/db/test-migration/V999__Test_Seed.sql new file mode 100644 index 0000000..740271b --- /dev/null +++ b/src/test/resources/db/test-migration/V999__Test_Seed.sql @@ -0,0 +1,2 @@ +INSERT INTO users (id, username, password, role) VALUES (1, 'boss', '$2a$10$XdZU0Z5dUyKnQVuRIFL2G.w0vTQLLOEQwN5.O3EsbVWH4/AJ7xJwq', 'BOSS'); +ALTER SEQUENCE users_id_seq RESTART WITH 2; diff --git a/src/test/resources/import-test.sql b/src/test/resources/import-test.sql new file mode 100644 index 0000000..6110741 --- /dev/null +++ b/src/test/resources/import-test.sql @@ -0,0 +1,2 @@ +-- Seeding boss user for tests +INSERT INTO users (id, username, password) VALUES (1, 'boss', '$2a$10$Wq/..'); From a197a69211ffa0dfea2c911ac16f55e78837fb66 Mon Sep 17 00:00:00 2001 From: Ali Malek Date: Tue, 9 Dec 2025 19:28:23 +0100 Subject: [PATCH 35/35] feat: enhance link validation and cleanup resources Adds NullOrNotBlank validation, defaults for LinkCreateDTO, and minor cleanup in LinksResource. --- .../shelang/aghab/resource/LinksResource.java | 4 ++-- .../aghab/service/dto/link/LinkCreateDTO.java | 4 +++- .../aghab/validation/NullOrNotBlank.java | 23 +++++++++++++++++++ .../impl/NullOrNotBlankValidator.java | 18 +++++++++++++++ 4 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 src/main/java/io/shelang/aghab/validation/NullOrNotBlank.java create mode 100644 src/main/java/io/shelang/aghab/validation/impl/NullOrNotBlankValidator.java diff --git a/src/main/java/io/shelang/aghab/resource/LinksResource.java b/src/main/java/io/shelang/aghab/resource/LinksResource.java index 3415e4a..db3f9b5 100644 --- a/src/main/java/io/shelang/aghab/resource/LinksResource.java +++ b/src/main/java/io/shelang/aghab/resource/LinksResource.java @@ -95,8 +95,8 @@ public LinkDTO update(@PathParam("id") Long id, @Valid LinkCreateDTO link) { @Consumes(MediaType.APPLICATION_JSON) public Response delete(@PathParam("id") Long id) { LinkDTO link = linksService.getById(id); - linksService.delete(id); - auditService.log(tokenService.getAccessTokenUserId(), link.getWorkspaceId(), "LINK_DELETED", "Link ID: " + id, + linksService.delete(link.getId()); + auditService.log(tokenService.getAccessTokenUserId(), null, "LINK_DELETED", "Link ID: " + link.getId(), request.remoteAddress().host()); return Response.noContent().build(); } diff --git a/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java b/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java index 35a5cf6..a1a13cd 100644 --- a/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java +++ b/src/main/java/io/shelang/aghab/service/dto/link/LinkCreateDTO.java @@ -9,6 +9,7 @@ import io.shelang.aghab.validation.NullOrNotBlank; import io.shelang.aghab.validation.ValidURI; +import jakarta.enterprise.inject.Default; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; @@ -33,7 +34,8 @@ public class LinkCreateDTO { @Min(301) @Max(308) - private Short redirectCode; + @Default() + private Short redirectCode = 301; private Instant expireAt; diff --git a/src/main/java/io/shelang/aghab/validation/NullOrNotBlank.java b/src/main/java/io/shelang/aghab/validation/NullOrNotBlank.java new file mode 100644 index 0000000..5b85947 --- /dev/null +++ b/src/main/java/io/shelang/aghab/validation/NullOrNotBlank.java @@ -0,0 +1,23 @@ +package io.shelang.aghab.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.shelang.aghab.validation.impl.NullOrNotBlankValidator; + +@Target({ ElementType.FIELD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Constraint(validatedBy = NullOrNotBlankValidator.class) +public @interface NullOrNotBlank { + String message() default "{io.shelang.aghab.validation.NullOrNotBlank.message}"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/io/shelang/aghab/validation/impl/NullOrNotBlankValidator.java b/src/main/java/io/shelang/aghab/validation/impl/NullOrNotBlankValidator.java new file mode 100644 index 0000000..7bb2209 --- /dev/null +++ b/src/main/java/io/shelang/aghab/validation/impl/NullOrNotBlankValidator.java @@ -0,0 +1,18 @@ +package io.shelang.aghab.validation.impl; + +import io.shelang.aghab.validation.NullOrNotBlank; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class NullOrNotBlankValidator implements ConstraintValidator { + + @Override + public void initialize(NullOrNotBlank constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) { + return s == null || !s.isBlank(); + } +}