From 5f4d53518a1b0f2b132e6d0b10bbb766278d7cc1 Mon Sep 17 00:00:00 2001 From: deekshu15 Date: Tue, 17 Mar 2026 18:49:54 +0530 Subject: [PATCH 1/2] feat: implement configurable API rate limiting middleware (#110) --- medplat-web/pom.xml | 6 ++ .../medplat/config/RateLimitConfig.java | 19 +++++ .../medplat/config/RateLimitingFilter.java | 80 +++++++++++++++++++ .../src/main/resources/root-config.properties | 4 +- 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitConfig.java create mode 100644 medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitingFilter.java diff --git a/medplat-web/pom.xml b/medplat-web/pom.xml index 9a95bf8aa..c52a55d80 100644 --- a/medplat-web/pom.xml +++ b/medplat-web/pom.xml @@ -268,6 +268,12 @@ xstream 1.4.20 + + + com.bucket4j + bucket4j-core + 8.1.1 + diff --git a/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitConfig.java b/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitConfig.java new file mode 100644 index 000000000..881804dfc --- /dev/null +++ b/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitConfig.java @@ -0,0 +1,19 @@ +package com.argusoft.medplat.config; + +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RateLimitConfig { + + @Bean + public FilterRegistrationBean rateLimitingFilter() { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter(new RateLimitingFilter()); + registration.addUrlPatterns("/api/*"); + registration.setName("rateLimitingFilter"); + registration.setOrder(1); + return registration; + } +} \ No newline at end of file diff --git a/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitingFilter.java b/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitingFilter.java new file mode 100644 index 000000000..5d6724099 --- /dev/null +++ b/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitingFilter.java @@ -0,0 +1,80 @@ +package com.argusoft.medplat.config; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.Refill; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +@Order(1) +public class RateLimitingFilter implements Filter { + + private static final Logger log = LoggerFactory.getLogger(RateLimitingFilter.class); + + private final Map buckets = new ConcurrentHashMap<>(); + + private final int globalLimit = Integer.parseInt( + System.getenv().getOrDefault("RATE_LIMIT_GLOBAL", "100")); + + private final int sensitiveLimit = Integer.parseInt( + System.getenv().getOrDefault("RATE_LIMIT_SENSITIVE", "10")); + + private Bucket createBucket(int limit) { + return Bucket.builder() + .addLimit(Bandwidth.classic(limit, + Refill.intervally(limit, Duration.ofMinutes(1)))) + .build(); + } + + private boolean isSensitivePath(String path) { + return path.contains("/login") || + path.contains("/users") || + path.contains("/oauth") || + path.contains("/password"); + } + + @Override + public void doFilter(ServletRequest req, ServletResponse res, + FilterChain chain) throws IOException, ServletException { + + HttpServletRequest request = (HttpServletRequest) req; + HttpServletResponse response = (HttpServletResponse) res; + + String ip = request.getRemoteAddr(); + String path = request.getRequestURI(); + boolean sensitive = isSensitivePath(path); + + int limit = sensitive ? sensitiveLimit : globalLimit; + String bucketKey = ip + ":" + (sensitive ? "sensitive" : "global"); + + Bucket bucket = buckets.computeIfAbsent(bucketKey, k -> createBucket(limit)); + + if (bucket.tryConsume(1)) { + chain.doFilter(req, res); + } else { + log.warn("[RATE LIMIT VIOLATED] IP={} | Path={} | Sensitive={}", ip, path, sensitive); + response.setStatus(429); + response.setContentType("application/json"); + response.getWriter().write( + "{\"status\": 429, " + + "\"error\": \"Too Many Requests\", " + + "\"message\": \"Rate limit exceeded. Please try again later.\"}" + ); + } + } +} diff --git a/medplat-web/src/main/resources/root-config.properties b/medplat-web/src/main/resources/root-config.properties index baf0bf7e2..ac07e4ae9 100644 --- a/medplat-web/src/main/resources/root-config.properties +++ b/medplat-web/src/main/resources/root-config.properties @@ -1 +1,3 @@ -appName=medplat \ No newline at end of file +appName=medplat +RATE_LIMIT_GLOBAL=100 +RATE_LIMIT_SENSITIVE=10 \ No newline at end of file From 8a684235bcc24b34431022195e764f1e912ee20e Mon Sep 17 00:00:00 2001 From: deekshu15 Date: Wed, 18 Mar 2026 16:56:42 +0530 Subject: [PATCH 2/2] fix: store rate limit config and logs in database per reviewer feedback --- .../.gradle/7.2/fileChanges/last-build.bin | Bin 0 -> 1 bytes .../.gradle/7.2/fileHashes/fileHashes.lock | Bin 0 -> 17 bytes medplat-android/.gradle/7.2/gc.properties | 0 .../.gradle/checksums/checksums.lock | Bin 0 -> 17 bytes medplat-android/.gradle/vcs-1/gc.properties | 0 .../argusoft/medplat/config/RateLimitDao.java | 60 ++++++++++++++++++ .../medplat/config/RateLimitViolationLog.java | 46 ++++++++++++++ .../medplat/config/RateLimitingFilter.java | 43 +++++++++---- .../V21__rate_limit_config_and_logs.sql | 33 ++++++++++ 9 files changed, 169 insertions(+), 13 deletions(-) create mode 100644 medplat-android/.gradle/7.2/fileChanges/last-build.bin create mode 100644 medplat-android/.gradle/7.2/fileHashes/fileHashes.lock create mode 100644 medplat-android/.gradle/7.2/gc.properties create mode 100644 medplat-android/.gradle/checksums/checksums.lock create mode 100644 medplat-android/.gradle/vcs-1/gc.properties create mode 100644 medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitDao.java create mode 100644 medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitViolationLog.java create mode 100644 medplat-web/src/main/resources/db/migration/V21__rate_limit_config_and_logs.sql diff --git a/medplat-android/.gradle/7.2/fileChanges/last-build.bin b/medplat-android/.gradle/7.2/fileChanges/last-build.bin new file mode 100644 index 0000000000000000000000000000000000000000..f76dd238ade08917e6712764a16a22005a50573d GIT binary patch literal 1 IcmZPo000310RR91 literal 0 HcmV?d00001 diff --git a/medplat-android/.gradle/7.2/fileHashes/fileHashes.lock b/medplat-android/.gradle/7.2/fileHashes/fileHashes.lock new file mode 100644 index 0000000000000000000000000000000000000000..eeea1d5d2ff2cd8af70a785ecccca3605a01058d GIT binary patch literal 17 TcmZQxeQNuq8G3qq8K3|FJplx> literal 0 HcmV?d00001 diff --git a/medplat-android/.gradle/7.2/gc.properties b/medplat-android/.gradle/7.2/gc.properties new file mode 100644 index 000000000..e69de29bb diff --git a/medplat-android/.gradle/checksums/checksums.lock b/medplat-android/.gradle/checksums/checksums.lock new file mode 100644 index 0000000000000000000000000000000000000000..f8abfb7718c8132acc3d8d9c817353bafac2b1f0 GIT binary patch literal 17 TcmZP$eb4H@ano9V1}FdkF query = sessionFactory.getCurrentSession().createNativeQuery(sql); + query.setParameter("key", key); + String result = query.uniqueResult(); + return result != null ? result : defaultValue; + } catch (Exception e) { + return defaultValue; + } + } + + // Read sensitive endpoints list from DB + public List getSensitiveEndpoints() { + try { + String sql = "SELECT endpoint_pattern FROM rate_limit_sensitive_endpoints"; + NativeQuery query = sessionFactory.getCurrentSession().createNativeQuery(sql); + return query.list(); + } catch (Exception e) { + return List.of("/login", "/users", "/oauth", "/password"); + } + } + + // Save violation log to DB + public void saveViolationLog(String ipAddress, String endpoint, boolean isSensitive) { + try { + String sql = "INSERT INTO rate_limit_violation_log " + + "(ip_address, endpoint, is_sensitive, violated_at) " + + "VALUES (:ip, :endpoint, :isSensitive, :violatedAt)"; + NativeQuery query = sessionFactory.getCurrentSession().createNativeQuery(sql); + query.setParameter("ip", ipAddress); + query.setParameter("endpoint", endpoint); + query.setParameter("isSensitive", isSensitive); + query.setParameter("violatedAt", new Date()); + query.executeUpdate(); + } catch (Exception e) { + // fallback log to console if DB fails + System.err.println("[RATE LIMIT LOG FAILED] " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitViolationLog.java b/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitViolationLog.java new file mode 100644 index 000000000..3af105735 --- /dev/null +++ b/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitViolationLog.java @@ -0,0 +1,46 @@ +package com.argusoft.medplat.config; + +import javax.persistence.*; +import java.util.Date; + +@Entity +@Table(name = "rate_limit_violation_log") +public class RateLimitViolationLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "ip_address") + private String ipAddress; + + @Column(name = "endpoint") + private String endpoint; + + @Column(name = "is_sensitive") + private Boolean isSensitive; + + @Column(name = "violated_at") + @Temporal(TemporalType.TIMESTAMP) + private Date violatedAt; + + public RateLimitViolationLog() {} + + public RateLimitViolationLog(String ipAddress, String endpoint, Boolean isSensitive) { + this.ipAddress = ipAddress; + this.endpoint = endpoint; + this.isSensitive = isSensitive; + this.violatedAt = new Date(); + } + + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } + public String getIpAddress() { return ipAddress; } + public void setIpAddress(String ipAddress) { this.ipAddress = ipAddress; } + public String getEndpoint() { return endpoint; } + public void setEndpoint(String endpoint) { this.endpoint = endpoint; } + public Boolean getIsSensitive() { return isSensitive; } + public void setIsSensitive(Boolean isSensitive) { this.isSensitive = isSensitive; } + public Date getViolatedAt() { return violatedAt; } + public void setViolatedAt(Date violatedAt) { this.violatedAt = violatedAt; } +} \ No newline at end of file diff --git a/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitingFilter.java b/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitingFilter.java index 5d6724099..444235d68 100644 --- a/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitingFilter.java +++ b/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitingFilter.java @@ -5,6 +5,7 @@ import io.github.bucket4j.Refill; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; @@ -17,6 +18,7 @@ import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.time.Duration; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -26,13 +28,10 @@ public class RateLimitingFilter implements Filter { private static final Logger log = LoggerFactory.getLogger(RateLimitingFilter.class); - private final Map buckets = new ConcurrentHashMap<>(); - - private final int globalLimit = Integer.parseInt( - System.getenv().getOrDefault("RATE_LIMIT_GLOBAL", "100")); + @Autowired + private RateLimitDao rateLimitDao; - private final int sensitiveLimit = Integer.parseInt( - System.getenv().getOrDefault("RATE_LIMIT_SENSITIVE", "10")); + private final Map buckets = new ConcurrentHashMap<>(); private Bucket createBucket(int limit) { return Bucket.builder() @@ -42,10 +41,23 @@ private Bucket createBucket(int limit) { } private boolean isSensitivePath(String path) { - return path.contains("/login") || - path.contains("/users") || - path.contains("/oauth") || - path.contains("/password"); + try { + List sensitiveEndpoints = rateLimitDao.getSensitiveEndpoints(); + return sensitiveEndpoints.stream().anyMatch(path::contains); + } catch (Exception e) { + // fallback if DB not available + return path.contains("/login") || path.contains("/users") || + path.contains("/oauth") || path.contains("/password"); + } + } + + private int getLimit(String key, String defaultValue) { + try { + String value = rateLimitDao.getConfigValue(key, defaultValue); + return Integer.parseInt(value); + } catch (Exception e) { + return Integer.parseInt(defaultValue); + } } @Override @@ -59,15 +71,20 @@ public void doFilter(ServletRequest req, ServletResponse res, String path = request.getRequestURI(); boolean sensitive = isSensitivePath(path); - int limit = sensitive ? sensitiveLimit : globalLimit; - String bucketKey = ip + ":" + (sensitive ? "sensitive" : "global"); + int limit = sensitive + ? getLimit("RATE_LIMIT_SENSITIVE", "10") + : getLimit("RATE_LIMIT_GLOBAL", "100"); + String bucketKey = ip + ":" + (sensitive ? "sensitive" : "global"); Bucket bucket = buckets.computeIfAbsent(bucketKey, k -> createBucket(limit)); if (bucket.tryConsume(1)) { chain.doFilter(req, res); } else { + // Log violation to DB log.warn("[RATE LIMIT VIOLATED] IP={} | Path={} | Sensitive={}", ip, path, sensitive); + rateLimitDao.saveViolationLog(ip, path, sensitive); + response.setStatus(429); response.setContentType("application/json"); response.getWriter().write( @@ -77,4 +94,4 @@ public void doFilter(ServletRequest req, ServletResponse res, ); } } -} +} \ No newline at end of file diff --git a/medplat-web/src/main/resources/db/migration/V21__rate_limit_config_and_logs.sql b/medplat-web/src/main/resources/db/migration/V21__rate_limit_config_and_logs.sql new file mode 100644 index 000000000..a12c00c56 --- /dev/null +++ b/medplat-web/src/main/resources/db/migration/V21__rate_limit_config_and_logs.sql @@ -0,0 +1,33 @@ +-- Table to store rate limit configurations +CREATE TABLE IF NOT EXISTS rate_limit_config ( + id SERIAL PRIMARY KEY, + config_key VARCHAR(100) NOT NULL UNIQUE, + config_value VARCHAR(100) NOT NULL, + description VARCHAR(255) +); + +-- Default values +INSERT INTO rate_limit_config (config_key, config_value, description) +VALUES + ('RATE_LIMIT_GLOBAL', '100', 'Max requests per minute per IP for all endpoints'), + ('RATE_LIMIT_SENSITIVE', '10', 'Max requests per minute per IP for sensitive endpoints') +ON CONFLICT (config_key) DO NOTHING; + +-- Sensitive endpoints stored in DB +CREATE TABLE IF NOT EXISTS rate_limit_sensitive_endpoints ( + id SERIAL PRIMARY KEY, + endpoint_pattern VARCHAR(255) NOT NULL UNIQUE +); + +INSERT INTO rate_limit_sensitive_endpoints (endpoint_pattern) +VALUES ('/login'), ('/users'), ('/oauth'), ('/password') +ON CONFLICT (endpoint_pattern) DO NOTHING; + +-- Table to log rate limit violations +CREATE TABLE IF NOT EXISTS rate_limit_violation_log ( + id SERIAL PRIMARY KEY, + ip_address VARCHAR(100), + endpoint VARCHAR(255), + is_sensitive BOOLEAN, + violated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +);