Skip to content

Commit daf84be

Browse files
committed
implemented saving request source addresses filtered by netmask
1 parent 6625b92 commit daf84be

File tree

13 files changed

+328
-18
lines changed

13 files changed

+328
-18
lines changed

captchaservice-backend/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,11 @@
313313
<artifactId>altcha</artifactId>
314314
<version>1.1.2</version>
315315
</dependency>
316+
<dependency>
317+
<groupId>commons-net</groupId>
318+
<artifactId>commons-net</artifactId>
319+
<version>3.11.1</version>
320+
</dependency>
316321
</dependencies>
317322

318323
<scm>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package de.muenchen.captchaservice.common;
2+
3+
import org.springframework.http.HttpStatus;
4+
import org.springframework.web.server.ResponseStatusException;
5+
6+
public class InvalidSourceAddressException extends ResponseStatusException {
7+
public InvalidSourceAddressException(final String sourceAddress) {
8+
super(HttpStatus.BAD_REQUEST, "Source address " + sourceAddress + " is not valid.");
9+
}
10+
}
Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,31 @@
11
package de.muenchen.captchaservice.configuration.captcha;
22

3+
import jakarta.validation.constraints.Max;
34
import jakarta.validation.constraints.Min;
45
import jakarta.validation.constraints.NotNull;
56

67
import java.util.List;
78

8-
public record CaptchaSite(String siteKey, String secret, @Min(1) Integer maxVerifiesPerPayload, @NotNull List<DifficultyItem> difficultyMap) {
9-
public CaptchaSite(final String siteKey, final String secret, final Integer maxVerifiesPerPayload, final List<DifficultyItem> difficultyMap) {
9+
public record CaptchaSite(
10+
String siteKey,
11+
String secret,
12+
@Min(1) Integer maxVerifiesPerPayload,
13+
@NotNull List<DifficultyItem> difficultyMap,
14+
15+
@Min(0) @Max(32) Integer sourceAddressIpv4Cidr,
16+
@Min(0) @Max(128) Integer sourceAddressIpv6Cidr) {
17+
public CaptchaSite(
18+
final String siteKey,
19+
final String secret,
20+
final Integer maxVerifiesPerPayload,
21+
final List<DifficultyItem> difficultyMap,
22+
final Integer sourceAddressIpv4Cidr,
23+
final Integer sourceAddressIpv6Cidr) {
1024
this.siteKey = siteKey;
1125
this.secret = secret;
1226
this.maxVerifiesPerPayload = maxVerifiesPerPayload != null ? maxVerifiesPerPayload : 1;
1327
this.difficultyMap = List.copyOf(difficultyMap);
28+
this.sourceAddressIpv4Cidr = sourceAddressIpv4Cidr != null ? sourceAddressIpv4Cidr : 32;
29+
this.sourceAddressIpv6Cidr = sourceAddressIpv6Cidr != null ? sourceAddressIpv6Cidr : 128;
1430
}
1531
}

captchaservice-backend/src/main/java/de/muenchen/captchaservice/controller/captcha/CaptchaController.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@
66
import de.muenchen.captchaservice.controller.captcha.response.PostChallengeResponse;
77
import de.muenchen.captchaservice.controller.captcha.response.PostVerifyResponse;
88
import de.muenchen.captchaservice.data.SourceAddress;
9+
import de.muenchen.captchaservice.service.captcha.CaptchaService;
910
import de.muenchen.captchaservice.service.siteauth.SiteAuthService;
11+
import de.muenchen.captchaservice.service.sourceaddress.SourceAddressService;
1012
import jakarta.validation.Valid;
1113
import lombok.RequiredArgsConstructor;
1214
import lombok.SneakyThrows;
1315
import org.altcha.altcha.Altcha;
14-
import org.springframework.web.bind.annotation.*;
15-
import de.muenchen.captchaservice.service.captcha.CaptchaService;
16+
import org.springframework.web.bind.annotation.PostMapping;
17+
import org.springframework.web.bind.annotation.RequestBody;
18+
import org.springframework.web.bind.annotation.RequestMapping;
19+
import org.springframework.web.bind.annotation.RestController;
1620

1721
@RestController
1822
@RequestMapping("/api/v1/captcha")
@@ -21,6 +25,7 @@ public class CaptchaController {
2125

2226
private final CaptchaService captchaService;
2327
private final SiteAuthService siteAuthService;
28+
private final SourceAddressService sourceAddressService;
2429

2530
@PostMapping("/challenge")
2631
@SneakyThrows
@@ -29,7 +34,7 @@ public PostChallengeResponse postChallenge(@Valid @RequestBody final PostChallen
2934
throw new UnauthorizedException("Wrong credentials.");
3035
}
3136

32-
final SourceAddress sourceAddress = SourceAddress.parse(request.getClientAddress());
37+
final SourceAddress sourceAddress = sourceAddressService.parse(request.getSiteKey(), request.getClientAddress());
3338
final Altcha.Challenge challenge = captchaService.createChallenge(request.getSiteKey(), sourceAddress);
3439
return new PostChallengeResponse(challenge);
3540
}

captchaservice-backend/src/main/java/de/muenchen/captchaservice/data/SourceAddress.java

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,12 @@
33
import lombok.AllArgsConstructor;
44
import org.apache.commons.codec.digest.DigestUtils;
55

6-
import java.net.InetAddress;
7-
import java.net.UnknownHostException;
8-
96
@AllArgsConstructor
107
public class SourceAddress {
118

12-
final private InetAddress inetAddress;
13-
14-
public static SourceAddress parse(final String sourceAddress) throws UnknownHostException {
15-
return new SourceAddress(InetAddress.getByName(sourceAddress));
16-
}
9+
final private String sourceAddress;
1710

1811
public String getHash() {
19-
return DigestUtils.sha256Hex(inetAddress.toString());
12+
return DigestUtils.sha256Hex(sourceAddress);
2013
}
2114
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package de.muenchen.captchaservice.service.sourceaddress;
2+
3+
import de.muenchen.captchaservice.configuration.captcha.CaptchaProperties;
4+
import de.muenchen.captchaservice.configuration.captcha.CaptchaSite;
5+
import de.muenchen.captchaservice.data.SourceAddress;
6+
import de.muenchen.captchaservice.util.networkaddresscalculator.NetworkAddressCalculator;
7+
import lombok.AllArgsConstructor;
8+
import org.springframework.stereotype.Service;
9+
10+
import java.net.InetAddress;
11+
import java.net.UnknownHostException;
12+
13+
@Service
14+
@AllArgsConstructor
15+
public class SourceAddressService {
16+
private final CaptchaProperties captchaProperties;
17+
18+
public SourceAddress parse(final String siteKey, final String sourceAddress) {
19+
CaptchaSite site = captchaProperties.sites().get(siteKey);
20+
String networkAddressString;
21+
try {
22+
InetAddress addr = InetAddress.getByName(sourceAddress);
23+
if (addr instanceof java.net.Inet4Address) {
24+
networkAddressString = NetworkAddressCalculator.getNetworkAddress(sourceAddress, site.sourceAddressIpv4Cidr());
25+
} else if (addr instanceof java.net.Inet6Address) {
26+
networkAddressString = NetworkAddressCalculator.getNetworkAddress(sourceAddress, site.sourceAddressIpv6Cidr());
27+
} else {
28+
throw new IllegalArgumentException("Unsupported IP address type: " + addr.getClass().getName());
29+
}
30+
} catch (UnknownHostException e) {
31+
throw new IllegalArgumentException("Invalid IP address provided: " + sourceAddress, e);
32+
}
33+
return new SourceAddress(networkAddressString);
34+
}
35+
36+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package de.muenchen.captchaservice.util.networkaddresscalculator;
2+
3+
import lombok.Getter;
4+
5+
public class InvalidAddressException extends RuntimeException {
6+
@Getter
7+
private final String address;
8+
9+
public InvalidAddressException(String message, String address, Exception cause) {
10+
super(message, cause);
11+
this.address = address;
12+
}
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package de.muenchen.captchaservice.util.networkaddresscalculator;
2+
3+
import lombok.Getter;
4+
5+
public class InvalidNetSizeException extends RuntimeException {
6+
@Getter
7+
private final int netSize;
8+
9+
public InvalidNetSizeException(String message, int netSize) {
10+
super(message);
11+
this.netSize = netSize;
12+
}
13+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package de.muenchen.captchaservice.util.networkaddresscalculator;
2+
3+
import java.net.InetAddress;
4+
import java.net.UnknownHostException;
5+
6+
public class NetworkAddressCalculator {
7+
8+
/**
9+
* Calculates the network address for a given IP address and netmask size (CIDR prefix).
10+
* Supports both IPv4 and IPv6 addresses.
11+
*
12+
* @param address The IP address string (e.g., "192.168.1.1", "2001:db8::1").
13+
* @param netSize The CIDR netmask size (e.g., 24 for IPv4, 64 for IPv6).
14+
* @return The network address string (e.g., "192.168.1.0", "2001:db8::").
15+
* @throws InvalidAddressException If the IP address is invalid.
16+
* @throws InvalidNetSizeException If the `netSize` is out of range for the given IP version.
17+
*/
18+
public static String getNetworkAddress(String address, int netSize) {
19+
try {
20+
InetAddress inetAddress = InetAddress.getByName(address);
21+
byte[] addressBytes = inetAddress.getAddress();
22+
int addressLengthBits = addressBytes.length * 8;
23+
24+
if (netSize < 0 || netSize > addressLengthBits) {
25+
throw new InvalidNetSizeException(
26+
"Net size " + netSize + " is out of range for a " +
27+
(addressLengthBits == 32 ? "IPv4" : "IPv6") + " address (0-" + addressLengthBits + ")",
28+
netSize);
29+
}
30+
31+
byte[] netAddressBytes = new byte[addressBytes.length];
32+
33+
// Apply the netmask bit by bit
34+
for (int i = 0; i < addressBytes.length; i++) {
35+
int byteNetSizeRemaining = netSize - (i * 8);
36+
if (byteNetSizeRemaining >= 8) {
37+
netAddressBytes[i] = addressBytes[i];
38+
} else if (byteNetSizeRemaining > 0) {
39+
int mask = 0xFF << (8 - byteNetSizeRemaining);
40+
netAddressBytes[i] = (byte) (addressBytes[i] & mask);
41+
} else {
42+
netAddressBytes[i] = 0;
43+
}
44+
}
45+
46+
InetAddress netInetAddress = InetAddress.getByAddress(netAddressBytes);
47+
return netInetAddress.getHostAddress();
48+
49+
} catch (UnknownHostException e) {
50+
throw new InvalidAddressException("Invalid address: " + address, address, e);
51+
}
52+
}
53+
}

captchaservice-backend/src/test/java/de/muenchen/captchaservice/controller/captcha/CaptchaControllerTest.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
import de.muenchen.captchaservice.TestConstants;
55
import de.muenchen.captchaservice.controller.captcha.request.PostChallengeRequest;
66
import de.muenchen.captchaservice.controller.captcha.request.PostVerifyRequest;
7+
import de.muenchen.captchaservice.repository.CaptchaRequestRepository;
78
import de.muenchen.captchaservice.util.DatabaseTestUtil;
89
import lombok.SneakyThrows;
910
import org.altcha.altcha.Altcha;
11+
import org.apache.commons.codec.digest.DigestUtils;
1012
import org.junit.jupiter.api.Test;
1113
import org.mockito.ArgumentMatchers;
1214
import org.mockito.MockedStatic;
@@ -25,7 +27,8 @@
2527
import static de.muenchen.captchaservice.TestConstants.SPRING_NO_SECURITY_PROFILE;
2628
import static de.muenchen.captchaservice.TestConstants.SPRING_TEST_PROFILE;
2729
import static org.hamcrest.Matchers.is;
28-
import static org.junit.jupiter.api.Assertions.*;
30+
import static org.junit.jupiter.api.Assertions.assertEquals;
31+
import static org.junit.jupiter.api.Assertions.fail;
2932
import static org.mockito.ArgumentMatchers.argThat;
3033
import static org.mockito.ArgumentMatchers.eq;
3134
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -65,6 +68,8 @@ class CaptchaControllerTest {
6568

6669
@Autowired
6770
private DatabaseTestUtil databaseTestUtil;
71+
@Autowired
72+
private CaptchaRequestRepository captchaRequestRepository;
6873

6974
@Test
7075
void postChallenge_basic() {
@@ -110,6 +115,7 @@ void postChallenge_basic() {
110115
@Test
111116
@SneakyThrows
112117
void postChallenge_validIpv4() {
118+
databaseTestUtil.clearDatabase();
113119
final PostChallengeRequest request = new PostChallengeRequest(TEST_SITE_KEY, TEST_SITE_SECRET, "1.2.3.4");
114120
final String requestBody = objectMapper.writeValueAsString(request);
115121
mockMvc.perform(
@@ -118,19 +124,22 @@ void postChallenge_validIpv4() {
118124
.contentType(MediaType.APPLICATION_JSON))
119125
.andExpect(status().isOk())
120126
.andExpect(content().contentType(MediaType.APPLICATION_JSON));
127+
assertEquals(1, captchaRequestRepository.countBySourceAddressHashIgnoreCase(DigestUtils.sha256Hex("1.2.3.4")));
121128
}
122129

123130
@Test
124131
@SneakyThrows
125132
void postChallenge_validIpv6() {
126-
final PostChallengeRequest request = new PostChallengeRequest(TEST_SITE_KEY, TEST_SITE_SECRET, "2001:db8::");
133+
databaseTestUtil.clearDatabase();
134+
final PostChallengeRequest request = new PostChallengeRequest(TEST_SITE_KEY, TEST_SITE_SECRET, "ea28:0fb8:e3f6:2836:dd46:0946:0589:72c2");
127135
final String requestBody = objectMapper.writeValueAsString(request);
128136
mockMvc.perform(
129137
post("/api/v1/captcha/challenge")
130138
.content(requestBody)
131139
.contentType(MediaType.APPLICATION_JSON))
132140
.andExpect(status().isOk())
133141
.andExpect(content().contentType(MediaType.APPLICATION_JSON));
142+
assertEquals(1, captchaRequestRepository.countBySourceAddressHashIgnoreCase(DigestUtils.sha256Hex("ea28:fb8:e3f6:2836:0:0:0:0")));
134143
}
135144

136145
@Test

0 commit comments

Comments
 (0)