Skip to content

Commit df23bbf

Browse files
MarineLeMguillaumejparisCopilot
authored
[backend] fix: verify opencti requests with jwks (#4973)
Signed-off-by: Marine LM <marine.lemezo@filigran.io> Co-authored-by: Guillaume <guillaume.paris@filigran.io> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent b57fc2f commit df23bbf

11 files changed

Lines changed: 282 additions & 4 deletions

File tree

openaev-api/src/main/java/io/openaev/config/AppSecurityConfig.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import io.openaev.config.security.OpenSamlConfig;
99
import io.openaev.config.security.SecurityService;
1010
import io.openaev.database.model.User;
11+
import io.openaev.security.OpenCTIJwtAuthenticationFilter;
1112
import io.openaev.security.SsoRefererAuthenticationFailureHandler;
1213
import io.openaev.security.SsoRefererAuthenticationSuccessHandler;
1314
import io.openaev.security.TokenAuthenticationFilter;
@@ -61,6 +62,8 @@ public class AppSecurityConfig {
6162
@Bean
6263
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
6364
http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
65+
.addFilterBefore(
66+
openCTIJwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
6467
.requestCache(Customizer.withDefaults())
6568
.requestCache(cache -> cache.requestCache(new HttpSessionRequestCache()))
6669
.csrf(AbstractHttpConfigurer::disable)
@@ -131,6 +134,11 @@ public TokenAuthenticationFilter tokenAuthenticationFilter() {
131134
return new TokenAuthenticationFilter();
132135
}
133136

137+
@Bean
138+
public OpenCTIJwtAuthenticationFilter openCTIJwtAuthenticationFilter() {
139+
return new OpenCTIJwtAuthenticationFilter();
140+
}
141+
134142
public User userOauth2Management(ClientRegistration clientRegistration, OAuth2User user) {
135143
String emailAttribute = user.getAttribute("email");
136144
String registrationId = clientRegistration.getRegistrationId();

openaev-api/src/main/java/io/openaev/opencti/client/mutations/Ping.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public class Ping implements Mutation {
2121
mutation PingConnector($id: ID!, $state: String, $connectorInfo: ConnectorInfoInput ) {
2222
pingConnector(id: $id, state: $state, connectorInfo: $connectorInfo) {
2323
id
24+
jwks
2425
connector_state
2526
connector_info {
2627
run_and_terminate
@@ -57,6 +58,9 @@ public static class PingConnectorContent {
5758
@JsonProperty("connector_state")
5859
private ObjectNode connectorState;
5960

61+
@JsonProperty("jwks")
62+
private String jwks;
63+
6064
@JsonProperty("connector_info")
6165
private ConnectorInfo connectorInfo;
6266

openaev-api/src/main/java/io/openaev/opencti/client/mutations/RegisterConnector.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ mutation RegisterConnector($input: RegisterConnectorInput) {
2323
registerConnector(input: $input) {
2424
id
2525
connector_state
26+
jwks
2627
config {
2728
connection {
2829
host
@@ -95,6 +96,9 @@ public static class RegisterConnectorContent {
9596
@JsonProperty("connector_state")
9697
private ObjectNode connectorState;
9798

99+
@JsonProperty("jwks")
100+
private String jwks;
101+
98102
@JsonProperty("config")
99103
private ConfigNode config;
100104

openaev-api/src/main/java/io/openaev/opencti/connectors/impl/SecurityCoverageConnector.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import java.util.ArrayList;
1010
import java.util.List;
1111
import lombok.Getter;
12+
import lombok.Setter;
1213
import org.springframework.beans.factory.annotation.Autowired;
1314
import org.springframework.boot.context.properties.ConfigurationProperties;
1415
import org.springframework.stereotype.Component;
@@ -25,6 +26,7 @@ public class SecurityCoverageConnector extends ConnectorBase {
2526

2627
private final ConnectorType type = ConnectorType.INTERNAL_ENRICHMENT;
2728
private final String name = "OpenAEV Coverage";
29+
@Setter private volatile String jwks;
2830

2931
public SecurityCoverageConnector() {
3032
this.setScope(new ArrayList<>(List.of("security-coverage")));

openaev-api/src/main/java/io/openaev/opencti/connectors/service/OpenCTIConnectorService.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@ public class OpenCTIConnectorService {
2222
private final OpenCTIService openCTIService;
2323

2424
@NotNull
25-
private Optional<ConnectorBase> getConnectorBase() {
25+
public Optional<SecurityCoverageConnector> getConnectorBase() {
2626
// don't examine the bundle
2727
// pick the first occurrence of the correct connector type
2828
// it's not supported yet to have more than one active connector of each type
2929
return connectors.stream()
3030
.filter(c -> c instanceof SecurityCoverageConnector && c.shouldRegister())
31+
.map(c -> (SecurityCoverageConnector) c)
3132
.findFirst();
3233
}
3334

@@ -56,7 +57,7 @@ public void registerOrPingAllConnectors() {
5657
}
5758

5859
public void pushSecurityCoverageStixBundle(Bundle bundle) throws ConnectorError, IOException {
59-
Optional<ConnectorBase> connector = getConnectorBase();
60+
Optional<SecurityCoverageConnector> connector = getConnectorBase();
6061

6162
if (connector.isEmpty()) {
6263
throw new ConnectorError(
@@ -67,7 +68,7 @@ public void pushSecurityCoverageStixBundle(Bundle bundle) throws ConnectorError,
6768
}
6869

6970
public void acknowledgeReceivedOfCoverage(String workId, String message) {
70-
Optional<ConnectorBase> connector = getConnectorBase();
71+
Optional<SecurityCoverageConnector> connector = getConnectorBase();
7172

7273
if (connector.isPresent()) {
7374
try {
@@ -79,7 +80,7 @@ public void acknowledgeReceivedOfCoverage(String workId, String message) {
7980
}
8081

8182
public void acknowledgeProcessedOfCoverage(String workId, String message, Boolean inError) {
82-
Optional<ConnectorBase> connector = getConnectorBase();
83+
Optional<SecurityCoverageConnector> connector = getConnectorBase();
8384

8485
if (connector.isPresent()) {
8586
try {

openaev-api/src/main/java/io/openaev/opencti/service/OpenCTIService.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import io.openaev.opencti.client.response.fields.Error;
1212
import io.openaev.opencti.config.OpenCTIConfig;
1313
import io.openaev.opencti.connectors.ConnectorBase;
14+
import io.openaev.opencti.connectors.impl.SecurityCoverageConnector;
1415
import io.openaev.opencti.connectors.service.PrivilegeService;
1516
import io.openaev.opencti.errors.ConnectorError;
1617
import io.openaev.stix.objects.Bundle;
@@ -32,6 +33,12 @@ public class OpenCTIService {
3233
private final ObjectMapper mapper;
3334
private final PrivilegeService privilegeService;
3435

36+
private void applyJwksIfApplicable(ConnectorBase connector, String jwks) {
37+
if (connector instanceof SecurityCoverageConnector scc) {
38+
scc.setJwks(jwks);
39+
}
40+
}
41+
3542
public RegisterConnector.ResponsePayload registerConnector(ConnectorBase connector)
3643
throws IOException, ConnectorError {
3744

@@ -60,6 +67,9 @@ public RegisterConnector.ResponsePayload registerConnector(ConnectorBase connect
6067
"Registered connector {} with OpenCTI at {}", connector.getName(), connector.getApiUrl());
6168
// side effect on transient state
6269
connector.setRegistered(true);
70+
if (payload.getRegisterConnectorContent() != null) {
71+
applyJwksIfApplicable(connector, payload.getRegisterConnectorContent().getJwks());
72+
}
6373
return payload;
6474
}
6575
}
@@ -91,6 +101,9 @@ public Ping.ResponsePayload pingConnector(ConnectorBase connector)
91101
Ping.ResponsePayload payload = mapper.convertValue(r.getData(), Ping.ResponsePayload.class);
92102
log.info(
93103
"Pinged connector {} with OpenCTI at {}", connector.getName(), connector.getApiUrl());
104+
if (payload.getPingConnectorContent() != null) {
105+
applyJwksIfApplicable(connector, payload.getPingConnectorContent().getJwks());
106+
}
94107
return payload;
95108
}
96109
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package io.openaev.security;
2+
3+
import io.jsonwebtoken.Jwts;
4+
import io.jsonwebtoken.security.Jwks;
5+
import io.openaev.opencti.connectors.impl.SecurityCoverageConnector;
6+
import io.openaev.opencti.connectors.service.OpenCTIConnectorService;
7+
import io.openaev.service.UserService;
8+
import jakarta.servlet.FilterChain;
9+
import jakarta.servlet.ServletException;
10+
import jakarta.servlet.http.HttpServletRequest;
11+
import jakarta.servlet.http.HttpServletResponse;
12+
import java.io.IOException;
13+
import java.util.Optional;
14+
import lombok.extern.slf4j.Slf4j;
15+
import org.springframework.beans.factory.annotation.Autowired;
16+
import org.springframework.web.filter.OncePerRequestFilter;
17+
18+
@Slf4j
19+
public class OpenCTIJwtAuthenticationFilter extends OncePerRequestFilter {
20+
private UserService userService;
21+
private OpenCTIConnectorService openCTIConnectorService;
22+
23+
@Autowired
24+
public void setUserService(UserService userService) {
25+
this.userService = userService;
26+
}
27+
28+
@Autowired
29+
public void setOpenCTIConnectorService(OpenCTIConnectorService openCTIConnectorService) {
30+
this.openCTIConnectorService = openCTIConnectorService;
31+
}
32+
33+
@Override
34+
protected boolean shouldNotFilter(HttpServletRequest request) {
35+
// only runs for /api/stix/** — skipped for everything else
36+
return !request.getRequestURI().startsWith("/api/stix/");
37+
}
38+
39+
/**
40+
* Function used to validate JWT token with OpenCTI jwks
41+
*
42+
* @param jwt JWT token to validate
43+
* @throws Exception if token not valid
44+
*/
45+
public void validateOpenCTIJwt(String jwt) throws Exception {
46+
Optional<SecurityCoverageConnector> openCTIConnector =
47+
openCTIConnectorService.getConnectorBase();
48+
if (openCTIConnector.isEmpty()) {
49+
throw new ServletException("Connector not found");
50+
}
51+
52+
Jwts.parser()
53+
.requireIssuer("opencti")
54+
.requireSubject("connector")
55+
.keyLocator(
56+
header -> {
57+
String kid = (String) header.get("kid");
58+
return Jwks.setParser()
59+
.build()
60+
.parse(openCTIConnector.get().getJwks())
61+
.getKeys()
62+
.stream()
63+
.filter(k -> kid.equals(k.getId()))
64+
.findFirst()
65+
.orElseThrow()
66+
.toKey();
67+
})
68+
.build()
69+
.parseSignedClaims(jwt);
70+
}
71+
72+
@Override
73+
protected void doFilterInternal(
74+
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
75+
throws ServletException, IOException {
76+
77+
String authHeader = request.getHeader("Authorization");
78+
if (authHeader == null || !authHeader.startsWith("Bearer")) {
79+
filterChain.doFilter(request, response);
80+
return;
81+
}
82+
String token = authHeader.substring("Bearer ".length()).trim();
83+
84+
try {
85+
validateOpenCTIJwt(token);
86+
this.userService.createAdminSession();
87+
} catch (Exception e) {
88+
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
89+
return;
90+
}
91+
92+
filterChain.doFilter(request, response);
93+
}
94+
}

openaev-api/src/main/java/io/openaev/service/UserService.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.util.*;
2525
import lombok.RequiredArgsConstructor;
2626
import org.springframework.beans.factory.annotation.Autowired;
27+
import org.springframework.beans.factory.annotation.Value;
2728
import org.springframework.cache.Cache;
2829
import org.springframework.cache.CacheManager;
2930
import org.springframework.security.core.Authentication;
@@ -49,6 +50,9 @@
4950
public class UserService {
5051
@Resource private SessionManager sessionManager;
5152

53+
@Value("${openbas.admin.email:${openaev.admin.email:#{null}}}")
54+
private String adminEmail;
55+
5256
/** Password encoder using Argon2 algorithm (Spring Security 5.8 defaults). */
5357
private final Argon2PasswordEncoder passwordEncoder =
5458
Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
@@ -129,6 +133,12 @@ public void createUserSession(User user) {
129133
SecurityContextHolder.setContext(context);
130134
}
131135

136+
/** Creates admin security session */
137+
public void createAdminSession() {
138+
User adminUser = this.userRepository.findByEmailIgnoreCase(this.adminEmail).orElseThrow();
139+
this.createUserSession(adminUser);
140+
}
141+
132142
/**
133143
* Encodes a plaintext password using Argon2.
134144
*

0 commit comments

Comments
 (0)