Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/codeiq.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ serving:
port: 8080 # HTTP port for REST + MCP + UI
bind_address: 0.0.0.0 # interface to bind; 127.0.0.1 for localhost-only
read_only: false # must be false in non-prod; CI gate enforces this
max_file_bytes: 5242880 # cap on /api/file + MCP read_file (bytes); 5 MiB default — rejects with HTTP 413
neo4j:
dir: .codeiq/graph/graph.db # embedded Neo4j data directory
page_cache_mb: 256 # Neo4j page cache (MB)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
import io.github.randomcodespace.iq.model.NodeKind;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
Expand Down Expand Up @@ -291,23 +290,14 @@ public ResponseEntity<String> readFile(
return ResponseEntity.notFound().build();
}
try {
String content = Files.readString(resolvedReal, StandardCharsets.UTF_8);
if (startLine != null || endLine != null) {
String[] lines = content.split("\n", -1);
int start = (startLine != null ? startLine : 1);
int end = (endLine != null ? endLine : lines.length);
start = Math.max(1, Math.min(start, lines.length));
end = Math.max(start, Math.min(end, lines.length));
StringBuilder sb = new StringBuilder();
for (int i = start - 1; i < end; i++) {
if (i > start - 1) sb.append('\n');
sb.append(lines[i]);
}
content = sb.toString();
}
String content = SafeFileReader.read(resolvedReal, startLine, endLine, config.getMaxFileBytes());
return ResponseEntity.ok()
.contentType(MediaType.TEXT_PLAIN)
.body(content);
} catch (SafeFileReader.FileTooLargeException tooLarge) {
return ResponseEntity.status(HttpStatus.CONTENT_TOO_LARGE)
.contentType(MediaType.TEXT_PLAIN)
.body(tooLarge.getMessage());
Comment thread
aksOps marked this conversation as resolved.
Dismissed
} catch (IOException e) {
return ResponseEntity.status(500)
.contentType(MediaType.TEXT_PLAIN)
Expand Down
77 changes: 77 additions & 0 deletions src/main/java/io/github/randomcodespace/iq/api/SafeFileReader.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package io.github.randomcodespace.iq.api;

import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

/**
* Reads files for the read-only serving layer with a hard byte cap.
*
* <p>The two entry points that surface repo content over HTTP — {@code GET /api/file}
* and the {@code read_file} MCP tool — must never load unbounded content into the JVM
* heap; a multi-GB file would OOM the serving process and become a trivial DoS vector.
*
* <p>Behaviour:
* <ul>
* <li>Without a line range, the file's on-disk size is checked first and the read
* is rejected if it exceeds the cap.</li>
* <li>With a {@code startLine}/{@code endLine} range, the file is read line-by-line
* via a {@link BufferedReader}; only lines in range are retained and the
* accumulated UTF-8 byte count is capped the same way.</li>
* </ul>
*/
public final class SafeFileReader {

public static final class FileTooLargeException extends RuntimeException {
private final long size;
private final long max;

public FileTooLargeException(long size, long max) {
super("File exceeds max size: " + size + " bytes (max " + max + " bytes)");
this.size = size;
this.max = max;
}

public long size() { return size; }
public long max() { return max; }
}

private SafeFileReader() {}

public static String read(Path path, Integer startLine, Integer endLine, long maxBytes)

Check failure on line 43 in src/main/java/io/github/randomcodespace/iq/api/SafeFileReader.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_codeiq&issues=AZ3AvnuOGlqvaGIkN7ic&open=AZ3AvnuOGlqvaGIkN7ic&pullRequest=67
throws IOException {
if (startLine == null && endLine == null) {
long size = Files.size(path);
if (size > maxBytes) {
throw new FileTooLargeException(size, maxBytes);
}
return Files.readString(path, StandardCharsets.UTF_8);
}
int start = Math.max(1, startLine != null ? startLine : 1);
int end = endLine != null ? Math.max(start, endLine) : Integer.MAX_VALUE;
StringBuilder sb = new StringBuilder();
long accumulated = 0;
boolean first = true;
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
String line;
int idx = 0;
while ((line = reader.readLine()) != null) {

Check warning on line 60 in src/main/java/io/github/randomcodespace/iq/api/SafeFileReader.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Reduce the total number of break and continue statements in this loop to use at most one.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_codeiq&issues=AZ3AvnuOGlqvaGIkN7ib&open=AZ3AvnuOGlqvaGIkN7ib&pullRequest=67
idx++;
if (idx < start) continue;
Comment thread
aksOps marked this conversation as resolved.
if (idx > end) break;
long lineBytes = line.getBytes(StandardCharsets.UTF_8).length;
long add = lineBytes + (first ? 0L : 1L);
if (accumulated + add > maxBytes) {
throw new FileTooLargeException(accumulated + add, maxBytes);
}
if (!first) sb.append('\n');
sb.append(line);
Comment thread
aksOps marked this conversation as resolved.
accumulated += add;
first = false;
}
}
return sb.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ public class CodeIqConfig {
/** Maximum lines per snippet returned in evidence packs (default 50). */
private int maxSnippetLines = 50;

/** Maximum bytes read by the serving layer's /api/file and MCP read_file (default 5 MiB). */
private long maxFileBytes = 5L * 1024L * 1024L;

public static class Graph {
private String path = ".codeiq/graph/graph.db";

Expand Down Expand Up @@ -152,4 +155,12 @@ public int getMaxSnippetLines() {
void setMaxSnippetLines(int maxSnippetLines) {
this.maxSnippetLines = Math.max(1, maxSnippetLines);
}

public long getMaxFileBytes() {
return maxFileBytes;
}

void setMaxFileBytes(long maxFileBytes) {
this.maxFileBytes = Math.max(1L, maxFileBytes);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

private UnifiedConfigAdapter() {}

public static CodeIqConfig toCodeIqConfig(CodeIqUnifiedConfig u) {

Check failure on line 21 in src/main/java/io/github/randomcodespace/iq/config/UnifiedConfigAdapter.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 27 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_codeiq&issues=AZ3Avnw7GlqvaGIkN7ih&open=AZ3Avnw7GlqvaGIkN7ih&pullRequest=67
CodeIqConfig c = new CodeIqConfig();
if (u == null) {
return c;
Expand Down Expand Up @@ -58,6 +58,9 @@
if (u.serving().readOnly() != null) {
c.setReadOnly(u.serving().readOnly());
}
if (u.serving().maxFileBytes() != null) {
c.setMaxFileBytes(u.serving().maxFileBytes());
}
if (u.serving().neo4j() != null && u.serving().neo4j().dir() != null) {
CodeIqConfig.Graph graph = new CodeIqConfig.Graph();
graph.setPath(u.serving().neo4j().dir());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public static CodeIqUnifiedConfig builtIn() {
8080,
"0.0.0.0",
false,
5L * 1024L * 1024L, // maxFileBytes — 5 MiB cap on /api/file + read_file
new Neo4jConfig(
".codeiq/graph/graph.db",
256, 256, 1024
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,10 @@ private IndexingConfig mergeIndexing(IndexingConfig lo, IndexingConfig hi, Input

private ServingConfig mergeServing(ServingConfig lo, ServingConfig hi, Input l, Map<String,ConfigProvenance> p) {
return new ServingConfig(
take("serving.port", lo.port(), hi.port(), l, p),
take("serving.bind_address", lo.bindAddress(), hi.bindAddress(), l, p),
take("serving.read_only", lo.readOnly(), hi.readOnly(), l, p),
take("serving.port", lo.port(), hi.port(), l, p),
take("serving.bind_address", lo.bindAddress(), hi.bindAddress(), l, p),
take("serving.read_only", lo.readOnly(), hi.readOnly(), l, p),
take("serving.max_file_bytes", lo.maxFileBytes(), hi.maxFileBytes(), l, p),
new Neo4jConfig(
take("serving.neo4j.dir", lo.neo4j().dir(), hi.neo4j().dir(), l, p),
take("serving.neo4j.page_cache_mb", lo.neo4j().pageCacheMb(), hi.neo4j().pageCacheMb(), l, p),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
pageMb = null, heapInit = null, heapMax = null,
maxDepth = null, maxRadius = null, maxFiles = null, maxSnippetLines = null,
parallelism = null;
Long maxPayload = null;
Long maxPayload = null, servingMaxFileBytes = null;

Check warning on line 24 in src/main/java/io/github/randomcodespace/iq/config/unified/EnvVarOverlay.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Declare "servingMaxFileBytes" on a separate line.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_codeiq&issues=AZ3AvnwiGlqvaGIkN7ie&open=AZ3AvnwiGlqvaGIkN7ie&pullRequest=67
Boolean readOnly = null, incremental = null, metrics = null, tracing = null, mcpEnabled = null;
String cacheDir = null, bindAddr = null, projectName = null, projectRoot = null,
projectServiceName = null,
Expand All @@ -37,7 +37,7 @@
if (!k.startsWith("CODEIQ_")) continue;
String key = k.substring("CODEIQ_".length());
try {
switch (key) {

Check warning on line 40 in src/main/java/io/github/randomcodespace/iq/config/unified/EnvVarOverlay.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Reduce the number of non-empty switch cases from 42 to at most 30.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_codeiq&issues=AZ3AvnwiGlqvaGIkN7if&open=AZ3AvnwiGlqvaGIkN7if&pullRequest=67
case "PROJECT_NAME" -> projectName = v;
case "PROJECT_ROOT" -> projectRoot = v;
case "PROJECT_SERVICE_NAME" -> projectServiceName = v;
Expand All @@ -56,6 +56,7 @@
case "SERVING_PORT" -> port = Integer.parseInt(v);
case "SERVING_BINDADDRESS" -> bindAddr = v;
case "SERVING_READONLY" -> readOnly = Boolean.parseBoolean(v);
case "SERVING_MAXFILEBYTES" -> servingMaxFileBytes = Long.parseLong(v);
case "SERVING_NEO4J_DIR" -> neo4jDir = v;
case "SERVING_NEO4J_PAGECACHEMB" -> pageMb = Integer.parseInt(v);
case "SERVING_NEO4J_HEAPINITIALMB" -> heapInit = Integer.parseInt(v);
Expand Down Expand Up @@ -90,7 +91,7 @@
new ProjectConfig(projectName, projectRoot, projectServiceName, List.of()),
new IndexingConfig(languages, include, exclude, incremental, cacheDir, parallelism, batch,
maxDepth, maxRadius, maxFiles, maxSnippetLines, parsers),
new ServingConfig(port, bindAddr, readOnly,
new ServingConfig(port, bindAddr, readOnly, servingMaxFileBytes,
new Neo4jConfig(neo4jDir, pageMb, heapInit, heapMax)),
new McpConfig(mcpEnabled, mcpTransport, mcpBasePath,
new McpAuthConfig(mcpMode, mcpTokenEnv),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package io.github.randomcodespace.iq.config.unified;
public record ServingConfig(Integer port, String bindAddress, Boolean readOnly, Neo4jConfig neo4j) {
public static ServingConfig empty() { return new ServingConfig(null, null, null, Neo4jConfig.empty()); }
public record ServingConfig(Integer port, String bindAddress, Boolean readOnly, Long maxFileBytes, Neo4jConfig neo4j) {
public static ServingConfig empty() { return new ServingConfig(null, null, null, null, Neo4jConfig.empty()); }
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
return new CodeIqUnifiedConfig(
projectFrom((Map<String, Object>) m.get("project"), path, warnedAliases),
indexingFrom((Map<String, Object>) m.get("indexing"), path, warnedAliases),
servingFrom((Map<String, Object>) m.get("serving"), path, warnedAliases),

Check failure on line 77 in src/main/java/io/github/randomcodespace/iq/config/unified/UnifiedConfigLoader.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "serving" 4 times.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_codeiq&issues=AZ3AvnwvGlqvaGIkN7ig&open=AZ3AvnwvGlqvaGIkN7ig&pullRequest=67
mcpFrom((Map<String, Object>) m.get("mcp"), path, warnedAliases),
observabilityFrom((Map<String, Object>) m.get("observability"), path, warnedAliases),
detectorsFrom((Map<String, Object>) m.get("detectors"), path, warnedAliases)
Expand Down Expand Up @@ -126,6 +126,8 @@
requireIntOrNull(m.get("port"), path, "serving.port"),
(String) pick(m, "serving", "bind_address", "bindAddress", path, warned),
(Boolean) pick(m, "serving", "read_only", "readOnly", path, warned),
requireLongOrNull(pick(m, "serving", "max_file_bytes", "maxFileBytes", path, warned),
path, "serving.max_file_bytes"),
n4j);
}

Expand Down
20 changes: 4 additions & 16 deletions src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.randomcodespace.iq.api.SafeFileReader;
import io.github.randomcodespace.iq.config.CodeIqConfig;
import io.github.randomcodespace.iq.intelligence.evidence.EvidencePackAssembler;
import io.github.randomcodespace.iq.intelligence.evidence.EvidencePackRequest;
Expand Down Expand Up @@ -397,22 +398,9 @@ public String readFile(
if (!resolved.startsWith(root)) {
return toJson(Map.of(PROP_ERROR, "Path traversal detected"));
}
String content = java.nio.file.Files.readString(resolved, java.nio.charset.StandardCharsets.UTF_8);
if (startLine != null || endLine != null) {
String[] lines = content.split("\n", -1);
int start = (startLine != null ? startLine : 1);
int end = (endLine != null ? endLine : lines.length);
// Clamp bounds
start = Math.max(1, Math.min(start, lines.length));
end = Math.max(start, Math.min(end, lines.length));
StringBuilder sb = new StringBuilder();
for (int i = start - 1; i < end; i++) {
if (i > start - 1) sb.append('\n');
sb.append(lines[i]);
}
return sb.toString();
}
return content;
return SafeFileReader.read(resolved, startLine, endLine, config.getMaxFileBytes());
} catch (SafeFileReader.FileTooLargeException tooLarge) {
return toJson(Map.of(PROP_ERROR, tooLarge.getMessage()));
} catch (Exception e) {
return toJson(Map.of(PROP_ERROR, "Failed to read file: " + e.getMessage()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,55 @@ void readFileShouldAllowInRepoSymlink(@TempDir Path tempDir) throws Exception {
.andExpect(content().string("in-repo"));
}

@Test
void readFileShouldRejectWhenFileExceedsMaxBytes(@TempDir Path tempDir) throws Exception {
// 2 KiB file, cap at 1 KiB — expect 413 without line range.
byte[] payload = new byte[2048];
java.util.Arrays.fill(payload, (byte) 'a');
Files.write(tempDir.resolve("big.txt"), payload);
CodeIqConfigTestSupport.override(config)
.rootPath(tempDir.toAbsolutePath().toString())
.maxFileBytes(1024L)
.done();
var controller = new GraphController(queryService, config);
var fileMvc = MockMvcBuilders.standaloneSetup(controller).build();

fileMvc.perform(get("/api/file").param("path", "big.txt"))
.andExpect(status().is(413))
.andExpect(content().string(containsString("exceeds max size")));
}

@Test
void readFileShouldServeLineRangeUnderCapEvenWhenWholeFileExceedsCap(@TempDir Path tempDir) throws Exception {
// Build a file where the whole is larger than the cap but a small line-range fits.
StringBuilder big = new StringBuilder();
for (int i = 1; i <= 200; i++) big.append("line").append(i).append('\n');
Files.writeString(tempDir.resolve("ranged.txt"), big.toString(), StandardCharsets.UTF_8);
long cap = 64L; // bytes — whole file is much bigger
CodeIqConfigTestSupport.override(config)
.rootPath(tempDir.toAbsolutePath().toString())
.maxFileBytes(cap)
.done();
var controller = new GraphController(queryService, config);
var fileMvc = MockMvcBuilders.standaloneSetup(controller).build();

// Small range fits under cap — streamed read should succeed.
fileMvc.perform(get("/api/file")
.param("path", "ranged.txt")
.param("startLine", "2")
.param("endLine", "4"))
.andExpect(status().isOk())
.andExpect(content().string("line2\nline3\nline4"));

// Large range would exceed cap — streamed read should 413 before buffering further.
fileMvc.perform(get("/api/file")
.param("path", "ranged.txt")
.param("startLine", "1")
.param("endLine", "200"))
.andExpect(status().is(413))
.andExpect(content().string(containsString("exceeds max size")));
}

// POST /api/analyze removed — API is read-only

// --- /api/file-tree ---
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package io.github.randomcodespace.iq.api;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

class SafeFileReaderTest {

@Test
void readsWholeFileUnderCap(@TempDir Path tempDir) throws IOException {
Path f = tempDir.resolve("small.txt");
Files.writeString(f, "hello", StandardCharsets.UTF_8);
assertEquals("hello", SafeFileReader.read(f, null, null, 1024L));
}

@Test
void rejectsWholeFileExceedingCap(@TempDir Path tempDir) throws IOException {
Path f = tempDir.resolve("big.txt");
byte[] payload = new byte[2048];
java.util.Arrays.fill(payload, (byte) 'x');
Files.write(f, payload);
SafeFileReader.FileTooLargeException e = assertThrows(
SafeFileReader.FileTooLargeException.class,
() -> SafeFileReader.read(f, null, null, 1024L));
assertEquals(2048L, e.size());
assertEquals(1024L, e.max());
}

@Test
void streamsLineRangeWithoutLoadingWholeFile(@TempDir Path tempDir) throws IOException {
Path f = tempDir.resolve("ranged.txt");
StringBuilder sb = new StringBuilder();
for (int i = 1; i <= 100; i++) sb.append("line").append(i).append('\n');
Files.writeString(f, sb.toString(), StandardCharsets.UTF_8);

// Whole-file readString with cap=64 would fail, but the 3-line range fits.
assertEquals("line2\nline3\nline4",
SafeFileReader.read(f, 2, 4, 64L));
}

@Test
void rejectsLineRangeExceedingCap(@TempDir Path tempDir) throws IOException {
Path f = tempDir.resolve("ranged.txt");
StringBuilder sb = new StringBuilder();
for (int i = 1; i <= 100; i++) sb.append("line").append(i).append('\n');
Files.writeString(f, sb.toString(), StandardCharsets.UTF_8);

SafeFileReader.FileTooLargeException e = assertThrows(
SafeFileReader.FileTooLargeException.class,
() -> SafeFileReader.read(f, 1, 100, 32L));
assertTrue(e.max() == 32L);

Check warning on line 59 in src/test/java/io/github/randomcodespace/iq/api/SafeFileReaderTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use assertEquals instead.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_codeiq&issues=AZ3Avnx7GlqvaGIkN7ik&open=AZ3Avnx7GlqvaGIkN7ik&pullRequest=67
}

@Test
void clampsStartLineToOneWhenNegative(@TempDir Path tempDir) throws IOException {
Path f = tempDir.resolve("clamp.txt");
Files.writeString(f, "a\nb\nc\n", StandardCharsets.UTF_8);
assertEquals("a\nb", SafeFileReader.read(f, -3, 2, 1024L));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ void cliOverlayWinsOverEnv(@TempDir Path tmp) throws Exception {
// Build a CLI overlay that sets serving.port=7777 and leaves every other field null so
// only the serving.port leaf is attributed to the CLI layer.
CodeIqUnifiedConfig base = CodeIqUnifiedConfig.empty();
ServingConfig cliServing = new ServingConfig(7777, null, null, base.serving().neo4j());
ServingConfig cliServing = new ServingConfig(7777, null, null, null, base.serving().neo4j());
CodeIqUnifiedConfig overlay =
new CodeIqUnifiedConfig(
base.project(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public static CodeIqConfigTestSupport override(CodeIqConfig config) {
public CodeIqConfigTestSupport serviceName(String v) { config.setServiceName(v); return this; }
public CodeIqConfigTestSupport uiEnabled(boolean v){ config.setUiEnabled(v); return this; }
public CodeIqConfigTestSupport maxSnippetLines(int v) { config.setMaxSnippetLines(v); return this; }
public CodeIqConfigTestSupport maxFileBytes(long v) { config.setMaxFileBytes(v); return this; }

public CodeIqConfigTestSupport graph(CodeIqConfig.Graph g) { config.setGraph(g); return this; }
public CodeIqConfigTestSupport graphPath(String v) {
Expand Down
Loading
Loading