Skip to content

Commit 44d7287

Browse files
committed
fix: Require API key for single scan endpoint
Signed-off-by: Tetiana Naumenko <t.naumenko@samsung.com>
1 parent 8eb908f commit 44d7287

12 files changed

Lines changed: 269 additions & 16 deletions

File tree

‎.env.example‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,8 @@ MYSQL_ROOT_PASSWORD=
99
# on first startup with access limited to the `lpvs` schema).
1010
MYSQL_USER=lpvs
1111
MYSQL_PASSWORD=
12+
13+
# Optional API key for the single scan endpoint `POST /scan/{org}/{repo}/{pr}`,
14+
# sent by callers in the `X-LPVS-Api-Key` header. Leave empty to disable the
15+
# endpoint. Generate a strong random value, e.g. `openssl rand -hex 32`.
16+
LPVS_API_KEY=

‎doc/docs/user-guide/config/options.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ for more details. Default: `LPVS`.
4444
- `lpvs.*`: These settings include various configurations specific to the LPVS application like core pool size,
4545
number of scan attempts, and version.
4646

47+
- `lpvs.api.key`: This setting specifies the API key for the single scan endpoint
48+
`POST /scan/{gitHubOrg}/{gitHubRepo}/{prNumber}`. Callers must send it in the `X-LPVS-Api-Key` header.
49+
Use a strong random value, e.g. generated with `openssl rand -hex 32`. Default: empty, which disables the endpoint.
50+
4751
- `spring.jpa.properties.hibernate.default_schema`: This setting specifies the default schema name that Hibernate
4852
should use. Default: `lpvs`.
4953

@@ -113,6 +117,7 @@ Alternatively, you can provide the necessary values for several properties using
113117
- `LPVS_GITHUB_API_URL`: Equivalent to the property `github.api.url`.
114118
- `LPVS_GITHUB_SECRET`: Equivalent to the property `github.secret`.
115119
- `LPVS_LICENSE_CONFLICT`: Equivalent to the property `license_conflict`.
120+
- `LPVS_API_KEY`: Equivalent to the property `lpvs.api.key`. Takes precedence over the property if both are set.
116121
- `LPVS_DATASOURCE_USERNAME`: Equivalent to the property `spring.datasource.username`. Required — the
117122
application will not start without it (or an equivalent override) being set.
118123
- `LPVS_DATASOURCE_PASSWORD`: Equivalent to the property `spring.datasource.password`. Required — the

‎doc/lpvs-api.yaml‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ paths:
7878
tags:
7979
- GitHub Pull Request Single Scan API
8080
summary: GitHub Pull Request Single Scan
81-
description: Endpoint for performing a single scan operation based on GitHub organization, repository, and pull request number.
81+
description: >-
82+
Endpoint for performing a single scan operation based on GitHub organization, repository, and pull request number.
83+
Requires the API key configured in `LPVS_API_KEY` (`lpvs.api.key`). If no API key is configured, the endpoint is disabled.
84+
security:
85+
- ApiKeyAuth: []
8286
parameters:
8387
- in: path
8488
name: gitHubOrg
@@ -130,6 +134,12 @@ paths:
130134
$ref: '#/components/schemas/HealthResponse'
131135

132136
components:
137+
securitySchemes:
138+
ApiKeyAuth:
139+
type: apiKey
140+
in: header
141+
name: X-LPVS-Api-Key
142+
133143
schemas:
134144
WebhookRequest:
135145
type: object

‎docker-compose-quick.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ services:
1010
ports:
1111
- "7896:7896"
1212
environment:
13+
## API key for the single scan endpoint (optional, endpoint is disabled if empty)
14+
- LPVS_API_KEY=${LPVS_API_KEY:-}
1315
## Database Configuration
1416
- spring.datasource.url=jdbc:mysql://mysqldb:3306/lpvs
1517
- spring.datasource.username=${MYSQL_USER:?Set MYSQL_USER in .env (see .env.example)}

‎docker-compose.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ services:
1010
ports:
1111
- "7896:7896"
1212
environment:
13+
## API key for the single scan endpoint (optional, endpoint is disabled if empty)
14+
- LPVS_API_KEY=${LPVS_API_KEY:-}
1315
## Database Configuration
1416
- spring.datasource.url=jdbc:mysql://mysqldb:3306/lpvs
1517
- spring.datasource.username=${MYSQL_USER:?Set MYSQL_USER in .env (see .env.example)}

‎pom.xml‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,6 @@
5656
<groupId>org.springframework.boot</groupId>
5757
<artifactId>spring-boot-starter-data-jpa</artifactId>
5858
</dependency>
59-
<dependency>
60-
<groupId>org.springframework.boot</groupId>
61-
<artifactId>spring-boot-starter-oauth2-client</artifactId>
62-
</dependency>
6359
<dependency>
6460
<groupId>org.springframework.boot</groupId>
6561
<artifactId>spring-boot-starter-security</artifactId>

‎src/main/java/com/lpvs/config/SecurityConfig.java‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88

99
import org.springframework.context.annotation.Bean;
1010
import org.springframework.context.annotation.Configuration;
11+
import org.springframework.http.HttpMethod;
1112
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
1213
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
14+
import org.springframework.security.config.http.SessionCreationPolicy;
1315
import org.springframework.security.web.SecurityFilterChain;
1416

1517
@Configuration
@@ -18,7 +20,26 @@ public class SecurityConfig {
1820

1921
@Bean
2022
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
21-
http.authorizeHttpRequests(authorizeRequests -> authorizeRequests.anyRequest().permitAll())
23+
http.authorizeHttpRequests(
24+
authorizeRequests ->
25+
authorizeRequests
26+
// GitHub webhooks, authenticated by HMAC signature
27+
// in GitHubController
28+
.requestMatchers(HttpMethod.POST, "/", "/webhooks")
29+
.permitAll()
30+
// Single scan API, authenticated by API key
31+
// in GitHubController
32+
.requestMatchers(HttpMethod.POST, "/scan/**")
33+
.permitAll()
34+
.requestMatchers(HttpMethod.GET, "/health")
35+
.permitAll()
36+
.requestMatchers("/error")
37+
.permitAll()
38+
.anyRequest()
39+
.denyAll())
40+
.sessionManagement(
41+
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
42+
// Stateless API without sessions or cookies, so CSRF protection is not applicable
2243
.csrf(csrf -> csrf.disable());
2344
return http.build();
2445
}

‎src/main/java/com/lpvs/controller/GitHubController.java‎

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
import org.springframework.web.util.HtmlUtils;
3434

3535
import java.io.IOException;
36+
import java.nio.charset.StandardCharsets;
37+
import java.security.MessageDigest;
3638
import java.util.Date;
3739
import java.util.Optional;
3840
import javax.crypto.Mac;
@@ -101,6 +103,7 @@ public void initializeGitHubController() {
101103
private LPVSExitHandler exitHandler;
102104

103105
private static final String SIGNATURE = "X-Hub-Signature-256";
106+
private static final String API_KEY_HEADER = "X-LPVS-Api-Key";
104107
private static final String SUCCESS = "Success";
105108
private static final String ERROR = "Error";
106109
private static final String ALGORITHM = "HmacSHA256";
@@ -204,13 +207,34 @@ public ResponseEntity<LPVSResponseWrapper> gitHubWebhooks(
204207
.body(new LPVSResponseWrapper(SUCCESS));
205208
}
206209

210+
/**
211+
* The API key used for authenticating single scan requests (optional).
212+
* If not set, the single scan endpoint is disabled.
213+
*/
214+
@Value("${lpvs.api.key:}")
215+
private String apiKeyProperty;
216+
217+
/**
218+
* Returns the API key from the LPVS_API_KEY environment variable or the application property.
219+
* The environment variable takes precedence over the property.
220+
*
221+
* @return The API key, or an empty string if it is not set.
222+
*/
223+
private String getApiKey() {
224+
return Optional.ofNullable(System.getenv("LPVS_API_KEY"))
225+
.filter(StringUtils::hasText)
226+
.orElse(Optional.ofNullable(apiKeyProperty).orElse(""));
227+
}
228+
207229
/**
208230
* Handles a GitHub single scan request.
209231
*
210232
* This endpoint performs a single scan operation based on the GitHub organization, repository,
211233
* and pull request number provided in the path variables. The method validates
212-
* the input parameters and performs necessary security checks.
234+
* the input parameters and performs necessary security checks. The caller must provide
235+
* the API key configured in LPVS_API_KEY (lpvs.api.key) in the X-LPVS-Api-Key header.
213236
*
237+
* @param apiKey The API key provided by the caller.
214238
* @param gitHubOrg The GitHub organization name. Must not be empty and should be a valid string.
215239
* @param gitHubRepo The GitHub repository name. Must not be empty and should be a valid string.
216240
* @param prNumber The pull request number. Must be a positive integer greater than or equal to 1.
@@ -222,13 +246,25 @@ public ResponseEntity<LPVSResponseWrapper> gitHubWebhooks(
222246
value = "/scan/{gitHubOrg}/{gitHubRepo}/{prNumber}",
223247
method = RequestMethod.POST)
224248
public ResponseEntity<LPVSResponseWrapper> gitHubSingleScan(
249+
@RequestHeader(value = API_KEY_HEADER, required = false) String apiKey,
225250
@PathVariable("gitHubOrg") @NotEmpty @Valid String gitHubOrg,
226251
@PathVariable("gitHubRepo") @NotEmpty @Valid String gitHubRepo,
227252
@PathVariable("prNumber") @Min(1) @Valid Integer prNumber) {
228253
log.debug("New GitHub single scan request received");
229254

230-
if (GITHUB_SECRET.trim().isEmpty()) {
231-
log.error("Received empty GITHUB_SECRET");
255+
String expectedApiKey = getApiKey();
256+
if (!StringUtils.hasText(expectedApiKey)) {
257+
log.error("Single scan API is disabled: LPVS_API_KEY (lpvs.api.key) is not set");
258+
return ResponseEntity.status(HttpStatus.FORBIDDEN)
259+
.headers(LPVSPayloadUtil.generateSecurityHeaders())
260+
.body(new LPVSResponseWrapper(ERROR));
261+
}
262+
// Constant-time comparison to prevent timing attacks
263+
if (apiKey == null
264+
|| !MessageDigest.isEqual(
265+
expectedApiKey.getBytes(StandardCharsets.UTF_8),
266+
apiKey.getBytes(StandardCharsets.UTF_8))) {
267+
log.error("Received empty or incorrect API key");
232268
return ResponseEntity.status(HttpStatus.FORBIDDEN)
233269
.headers(LPVSPayloadUtil.generateSecurityHeaders())
234270
.body(new LPVSResponseWrapper(ERROR));

‎src/main/resources/application.properties‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ github.api.url=https://api.github.com
2929
# Corresponding env. variable LPVS_GITHUB_SECRET
3030
github.secret=LPVS
3131

32+
# API key for the single scan endpoint (X-LPVS-Api-Key header). If empty, the endpoint is disabled.
33+
# Use a strong random value, e.g. `openssl rand -hex 32`.
34+
# Corresponding env. variable LPVS_API_KEY (takes precedence over this property)
35+
lpvs.api.key=
36+
3237
# Used Core Pool Size
3338
lpvs.cores=8
3439
# Number of scan attempts
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Copyright (c) 2026, Samsung Electronics Co., Ltd. All rights reserved.
3+
*
4+
* Use of this source code is governed by a MIT license that can be
5+
* found in the LICENSE file.
6+
*/
7+
package com.lpvs.config;
8+
9+
import com.lpvs.controller.GitHubController;
10+
import com.lpvs.controller.HealthController;
11+
import com.lpvs.repository.LPVSQueueRepository;
12+
import com.lpvs.service.LPVSGitHubConnectionService;
13+
import com.lpvs.service.LPVSGitHubService;
14+
import com.lpvs.service.LPVSQueueService;
15+
import com.lpvs.util.LPVSExitHandler;
16+
import org.apache.commons.codec.binary.Hex;
17+
import org.junit.jupiter.api.Test;
18+
import org.kohsuke.github.GHPullRequest;
19+
import org.kohsuke.github.GHRepository;
20+
import org.kohsuke.github.GitHub;
21+
import org.springframework.beans.factory.annotation.Autowired;
22+
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
23+
import org.springframework.http.MediaType;
24+
import org.springframework.test.context.ContextConfiguration;
25+
import org.springframework.test.context.TestPropertySource;
26+
import org.springframework.test.context.bean.override.mockito.MockitoBean;
27+
import org.springframework.test.web.servlet.MockMvc;
28+
29+
import java.nio.charset.StandardCharsets;
30+
import javax.crypto.Mac;
31+
import javax.crypto.spec.SecretKeySpec;
32+
33+
import static org.mockito.Mockito.mock;
34+
import static org.mockito.Mockito.never;
35+
import static org.mockito.Mockito.verify;
36+
import static org.mockito.Mockito.when;
37+
import static org.mockito.ArgumentMatchers.any;
38+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
39+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
40+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl;
41+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
42+
43+
@WebMvcTest
44+
@ContextConfiguration(
45+
classes = {SecurityConfig.class, GitHubController.class, HealthController.class})
46+
@TestPropertySource(
47+
properties = {"github.secret=" + SecurityConfigAccessTest.SECRET, "lpvs.api.key=test-key"})
48+
class SecurityConfigAccessTest {
49+
50+
static final String SECRET = "test-webhook-secret";
51+
52+
@Autowired private MockMvc mockMvc;
53+
54+
@MockitoBean private LPVSQueueService queueService;
55+
@MockitoBean private LPVSGitHubService gitHubService;
56+
@MockitoBean private LPVSGitHubConnectionService gitHubConnectionService;
57+
@MockitoBean private LPVSQueueRepository queueRepository;
58+
@MockitoBean private LPVSExitHandler exitHandler;
59+
60+
private static String sign(String payload) throws Exception {
61+
Mac mac = Mac.getInstance("HmacSHA256");
62+
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
63+
return "sha256="
64+
+ Hex.encodeHexString(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
65+
}
66+
67+
@Test
68+
void healthIsPublic() throws Exception {
69+
mockMvc.perform(get("/health")).andExpect(status().isOk());
70+
}
71+
72+
@Test
73+
void webhookIsPublic() throws Exception {
74+
String payload = "{\"zen\": \"ping\"}";
75+
mockMvc.perform(
76+
post("/webhooks")
77+
.contentType(MediaType.APPLICATION_JSON)
78+
.header("X-Hub-Signature-256", sign(payload))
79+
.content(payload))
80+
.andExpect(status().isOk());
81+
}
82+
83+
@Test
84+
void rootPostIsForwardedToWebhook() throws Exception {
85+
mockMvc.perform(post("/")).andExpect(forwardedUrl("/webhooks"));
86+
}
87+
88+
@Test
89+
void singleScanWithoutApiKeyIsForbidden() throws Exception {
90+
mockMvc.perform(post("/scan/org/repo/1")).andExpect(status().isForbidden());
91+
verify(gitHubConnectionService, never()).connectToGitHubApi();
92+
}
93+
94+
@Test
95+
void singleScanWithApiKeyIsAllowed() throws Exception {
96+
GitHub gitHub = mock(GitHub.class);
97+
GHRepository repository = mock(GHRepository.class);
98+
when(gitHubConnectionService.connectToGitHubApi()).thenReturn(gitHub);
99+
when(gitHub.getRepository("org/repo")).thenReturn(repository);
100+
when(repository.getPullRequest(1)).thenReturn(mock(GHPullRequest.class));
101+
102+
mockMvc.perform(post("/scan/org/repo/1").header("X-LPVS-Api-Key", "test-key"))
103+
.andExpect(status().isOk());
104+
verify(queueService).addFirst(any());
105+
}
106+
107+
@Test
108+
void unknownEndpointIsDenied() throws Exception {
109+
mockMvc.perform(get("/unknown")).andExpect(status().isForbidden());
110+
mockMvc.perform(post("/unknown")).andExpect(status().isForbidden());
111+
}
112+
113+
@Test
114+
void wrongMethodIsDenied() throws Exception {
115+
mockMvc.perform(post("/health")).andExpect(status().isForbidden());
116+
mockMvc.perform(get("/scan/org/repo/1").header("X-LPVS-Api-Key", "test-key"))
117+
.andExpect(status().isForbidden());
118+
}
119+
}

0 commit comments

Comments
 (0)