Skip to content

Commit b78db62

Browse files
aksOpsPaperclip-Paperclipclaude
committed
fix(security): replace unsafe new Yaml() with SafeConstructor and harden CORS
SnakeYAML's default constructor allows arbitrary Java class instantiation via YAML tags (e.g. !!javax.script.ScriptEngineManager). Replace all instances of new Yaml() used for loading with new Yaml(new SafeConstructor(new LoaderOptions())) in: - ProjectConfigLoader (2 locations) - ConfigScanner (2 locations) - StructuredParser (1 location) - GraphCommand (1 location, output-only but hardened for consistency) CORS: Replace wildcard allowedOrigins("*") with configurable allowedOriginPatterns defaulting to localhost. Configurable via codeiq.cors.allowed-origin-patterns property. Includes 21 new tests: 7 CorsConfig tests + 14 ProjectConfigLoader tests covering override paths, unsafe YAML tags, and parser settings. Co-Authored-By: Paperclip <noreply@paperclip.ing> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent abf7f92 commit b78db62

7 files changed

Lines changed: 366 additions & 8 deletions

File tree

src/main/java/io/github/randomcodespace/iq/analyzer/ConfigScanner.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ private void scanSpringConfig(Path root, InfrastructureRegistry registry) {
9494
private void parseSpringYaml(Path file, InfrastructureRegistry registry) {
9595
try {
9696
String content = Files.readString(file, StandardCharsets.UTF_8);
97-
Yaml yaml = new Yaml();
97+
Yaml yaml = new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor(new org.yaml.snakeyaml.LoaderOptions()));
9898
Object loaded = yaml.load(content);
9999
if (!(loaded instanceof Map<?, ?> raw)) return;
100100

@@ -224,7 +224,7 @@ private void scanDockerCompose(Path root, InfrastructureRegistry registry) {
224224
private void parseDockerCompose(Path file, InfrastructureRegistry registry) {
225225
try {
226226
String content = Files.readString(file, StandardCharsets.UTF_8);
227-
Yaml yaml = new Yaml();
227+
Yaml yaml = new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor(new org.yaml.snakeyaml.LoaderOptions()));
228228
Object loaded = yaml.load(content);
229229
if (!(loaded instanceof Map<?, ?> data)) return;
230230

src/main/java/io/github/randomcodespace/iq/analyzer/StructuredParser.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ public Object parse(String language, String content, String filePath) {
6262

6363
@SuppressWarnings("unchecked")
6464
private Object parseYaml(String content) {
65-
var yaml = new Yaml();
65+
var yaml = new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor(new org.yaml.snakeyaml.LoaderOptions()));
6666
var docs = new java.util.ArrayList<>();
6767
for (Object doc : yaml.loadAll(content)) {
6868
docs.add(doc);

src/main/java/io/github/randomcodespace/iq/cli/GraphCommand.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ private String renderYaml(List<CodeNode> nodes) {
122122
graphData.put("nodes", nodeList);
123123
graphData.put("count", nodes.size());
124124

125-
Yaml yaml = new Yaml();
125+
Yaml yaml = new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor(new org.yaml.snakeyaml.LoaderOptions()));
126126
return yaml.dump(graphData);
127127
}
128128

src/main/java/io/github/randomcodespace/iq/config/CorsConfig.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.github.randomcodespace.iq.config;
22

3+
import org.springframework.beans.factory.annotation.Value;
34
import org.springframework.context.annotation.Bean;
45
import org.springframework.context.annotation.Configuration;
56
import org.springframework.context.annotation.Profile;
@@ -9,17 +10,22 @@
910
@Configuration
1011
@Profile("serving")
1112
public class CorsConfig {
13+
14+
@Value("${codeiq.cors.allowed-origin-patterns:http://localhost:[*],http://127.0.0.1:[*]}")
15+
private String allowedOriginPatterns = "http://localhost:[*],http://127.0.0.1:[*]";
16+
1217
@Bean
1318
public WebMvcConfigurer corsConfigurer() {
19+
String[] patterns = allowedOriginPatterns.split(",");
1420
return new WebMvcConfigurer() {
1521
@Override
1622
public void addCorsMappings(CorsRegistry registry) {
1723
registry.addMapping("/api/**")
18-
.allowedOrigins("*")
24+
.allowedOriginPatterns(patterns)
1925
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
2026
.allowedHeaders("*");
2127
registry.addMapping("/mcp/**")
22-
.allowedOrigins("*")
28+
.allowedOriginPatterns(patterns)
2329
.allowedMethods("GET", "POST", "OPTIONS")
2430
.allowedHeaders("*");
2531
}

src/main/java/io/github/randomcodespace/iq/config/ProjectConfigLoader.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public static boolean loadIfPresent(Path directory, CodeIqConfig config) {
4444
if (Files.isRegularFile(configFile)) {
4545
try {
4646
String content = Files.readString(configFile, StandardCharsets.UTF_8);
47-
Yaml yaml = new Yaml();
47+
Yaml yaml = new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor(new org.yaml.snakeyaml.LoaderOptions()));
4848
Map<String, Object> data = yaml.load(content);
4949
if (data != null) {
5050
applyOverrides(data, config);
@@ -74,7 +74,7 @@ public static ProjectConfig loadProjectConfig(Path directory) {
7474
if (Files.isRegularFile(configFile)) {
7575
try {
7676
String content = Files.readString(configFile, StandardCharsets.UTF_8);
77-
Yaml yaml = new Yaml();
77+
Yaml yaml = new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor(new org.yaml.snakeyaml.LoaderOptions()));
7878
Map<String, Object> data = yaml.load(content);
7979
if (data != null) {
8080
log.info("Loaded project config from {}", configFile);
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package io.github.randomcodespace.iq.config;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.springframework.web.cors.CorsConfiguration;
5+
import org.springframework.web.servlet.config.annotation.CorsRegistry;
6+
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
7+
8+
import java.util.Map;
9+
10+
import static org.junit.jupiter.api.Assertions.*;
11+
12+
class CorsConfigTest {
13+
14+
static class TestableCorsRegistry extends CorsRegistry {
15+
@Override
16+
public Map<String, CorsConfiguration> getCorsConfigurations() {
17+
return super.getCorsConfigurations();
18+
}
19+
}
20+
21+
private CorsConfig createCorsConfig() {
22+
return new CorsConfig();
23+
}
24+
25+
@Test
26+
void corsConfigurerReturnsWebMvcConfigurer() {
27+
WebMvcConfigurer configurer = createCorsConfig().corsConfigurer();
28+
assertNotNull(configurer);
29+
}
30+
31+
@Test
32+
void corsConfigurerDoesNotThrowWhenAddingMappings() {
33+
WebMvcConfigurer configurer = createCorsConfig().corsConfigurer();
34+
TestableCorsRegistry registry = new TestableCorsRegistry();
35+
assertDoesNotThrow(() -> configurer.addCorsMappings(registry));
36+
}
37+
38+
@Test
39+
void corsRegistryContainsApiAndMcpMappings() {
40+
WebMvcConfigurer configurer = createCorsConfig().corsConfigurer();
41+
TestableCorsRegistry registry = new TestableCorsRegistry();
42+
configurer.addCorsMappings(registry);
43+
44+
var configurations = registry.getCorsConfigurations();
45+
assertTrue(configurations.containsKey("/api/**"),
46+
"Should register CORS mapping for /api/**");
47+
assertTrue(configurations.containsKey("/mcp/**"),
48+
"Should register CORS mapping for /mcp/**");
49+
}
50+
51+
@Test
52+
void apiMappingAllowsExpectedMethods() {
53+
WebMvcConfigurer configurer = createCorsConfig().corsConfigurer();
54+
TestableCorsRegistry registry = new TestableCorsRegistry();
55+
configurer.addCorsMappings(registry);
56+
57+
var configurations = registry.getCorsConfigurations();
58+
var apiCors = configurations.get("/api/**");
59+
assertNotNull(apiCors);
60+
var methods = apiCors.getAllowedMethods();
61+
assertNotNull(methods);
62+
assertTrue(methods.contains("GET"));
63+
assertTrue(methods.contains("OPTIONS"));
64+
}
65+
66+
@Test
67+
void mcpMappingAllowsGetPostOptions() {
68+
WebMvcConfigurer configurer = createCorsConfig().corsConfigurer();
69+
TestableCorsRegistry registry = new TestableCorsRegistry();
70+
configurer.addCorsMappings(registry);
71+
72+
var configurations = registry.getCorsConfigurations();
73+
var mcpCors = configurations.get("/mcp/**");
74+
assertNotNull(mcpCors);
75+
var methods = mcpCors.getAllowedMethods();
76+
assertNotNull(methods);
77+
assertTrue(methods.contains("GET"));
78+
assertTrue(methods.contains("POST"));
79+
assertTrue(methods.contains("OPTIONS"));
80+
}
81+
82+
@Test
83+
void apiMappingRestrictsToLocalhostOrigins() {
84+
WebMvcConfigurer configurer = createCorsConfig().corsConfigurer();
85+
TestableCorsRegistry registry = new TestableCorsRegistry();
86+
configurer.addCorsMappings(registry);
87+
88+
var configurations = registry.getCorsConfigurations();
89+
var apiCors = configurations.get("/api/**");
90+
assertNotNull(apiCors);
91+
var patterns = apiCors.getAllowedOriginPatterns();
92+
assertNotNull(patterns);
93+
assertTrue(patterns.stream().anyMatch(p -> p.contains("localhost")),
94+
"CORS should restrict to localhost origins");
95+
}
96+
97+
@Test
98+
void apiMappingAllowsAllHeaders() {
99+
WebMvcConfigurer configurer = createCorsConfig().corsConfigurer();
100+
TestableCorsRegistry registry = new TestableCorsRegistry();
101+
configurer.addCorsMappings(registry);
102+
103+
var configurations = registry.getCorsConfigurations();
104+
var apiCors = configurations.get("/api/**");
105+
assertNotNull(apiCors);
106+
var headers = apiCors.getAllowedHeaders();
107+
assertNotNull(headers);
108+
assertTrue(headers.contains("*"));
109+
}
110+
}

0 commit comments

Comments
 (0)