Skip to content

Commit 72775c3

Browse files
cpcloudcursoragent
andcommitted
fix(data): validate database path and drop redundant test infra
Add ValidateDBPath to reject URI-like paths (scheme://, file:, query params) before they reach the SQLite driver's url.ParseQuery, closing the CVE GO-2026-4341 attack surface. Use unicode.IsLetter for broader scheme detection. Remove network isolation files (testmain, nonet_*) — full dep audit confirmed zero network calls in the entire dependency graph. Drop redundant TestPrintPath_* integration tests that duplicated the direct resolveDBPath unit tests via unnecessary go-build subprocesses. Closes #49 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent eb09267 commit 72775c3

File tree

3 files changed

+175
-80
lines changed

3 files changed

+175
-80
lines changed

cmd/micasa/main_test.go

Lines changed: 3 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,9 @@ func TestResolveDBPath_ExplicitPathBeatsEnv(t *testing.T) {
8989
}
9090
}
9191

92-
// Integration tests that invoke the built binary with --print-path.
93-
// These exercise the full CLI parsing + resolveDBPath + print-and-exit path.
92+
// Version tests use exec.Command("go", "build") because debug.ReadBuildInfo()
93+
// only embeds VCS revision info in binaries built with go build, not go test,
94+
// and -ldflags -X injection likewise requires a real build step.
9495

9596
func buildTestBinary(t *testing.T) string {
9697
t.Helper()
@@ -107,84 +108,6 @@ func buildTestBinary(t *testing.T) string {
107108
return bin
108109
}
109110

110-
func TestPrintPath_Default(t *testing.T) {
111-
bin := buildTestBinary(t)
112-
cmd := exec.Command(bin, "--print-path")
113-
cmd.Env = append(os.Environ(), "MICASA_DB_PATH=")
114-
out, err := cmd.Output()
115-
if err != nil {
116-
t.Fatalf("--print-path failed: %v", err)
117-
}
118-
got := strings.TrimSpace(string(out))
119-
if got == "" {
120-
t.Fatal("expected non-empty output")
121-
}
122-
if !strings.HasSuffix(got, "micasa.db") {
123-
t.Errorf("expected path ending in micasa.db, got %q", got)
124-
}
125-
}
126-
127-
func TestPrintPath_ExplicitPath(t *testing.T) {
128-
bin := buildTestBinary(t)
129-
cmd := exec.Command(bin, "--print-path", "/custom/path.db")
130-
out, err := cmd.Output()
131-
if err != nil {
132-
t.Fatalf("--print-path failed: %v", err)
133-
}
134-
got := strings.TrimSpace(string(out))
135-
if got != "/custom/path.db" {
136-
t.Errorf("got %q, want /custom/path.db", got)
137-
}
138-
}
139-
140-
func TestPrintPath_EnvOverride(t *testing.T) {
141-
bin := buildTestBinary(t)
142-
cmd := exec.Command(bin, "--print-path")
143-
cmd.Env = append(os.Environ(), "MICASA_DB_PATH=/env/test.db")
144-
out, err := cmd.Output()
145-
if err != nil {
146-
t.Fatalf("--print-path failed: %v", err)
147-
}
148-
got := strings.TrimSpace(string(out))
149-
if got != "/env/test.db" {
150-
t.Errorf("got %q, want /env/test.db", got)
151-
}
152-
}
153-
154-
func TestPrintPath_DemoNoPath(t *testing.T) {
155-
bin := buildTestBinary(t)
156-
cmd := exec.Command(bin, "--print-path", "--demo")
157-
out, err := cmd.Output()
158-
if err != nil {
159-
t.Fatalf("--print-path --demo failed: %v", err)
160-
}
161-
got := strings.TrimSpace(string(out))
162-
if got != ":memory:" {
163-
t.Errorf("got %q, want :memory:", got)
164-
}
165-
}
166-
167-
func TestPrintPath_DemoWithPath(t *testing.T) {
168-
bin := buildTestBinary(t)
169-
cmd := exec.Command(bin, "--print-path", "--demo", "/tmp/demo.db")
170-
out, err := cmd.Output()
171-
if err != nil {
172-
t.Fatalf("--print-path --demo /path failed: %v", err)
173-
}
174-
got := strings.TrimSpace(string(out))
175-
if got != "/tmp/demo.db" {
176-
t.Errorf("got %q, want /tmp/demo.db", got)
177-
}
178-
}
179-
180-
func TestPrintPath_ExitCodeZero(t *testing.T) {
181-
bin := buildTestBinary(t)
182-
cmd := exec.Command(bin, "--print-path")
183-
if err := cmd.Run(); err != nil {
184-
t.Errorf("expected exit 0, got %v", err)
185-
}
186-
}
187-
188111
func TestVersion_DevShowsCommitHash(t *testing.T) {
189112
// Skip when there is no .git directory (e.g. Nix sandbox builds from a
190113
// source tarball), since Go won't embed VCS info without one.

internal/data/store.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"strings"
1010
"time"
11+
"unicode"
1112

1213
"github.com/cpcloud/micasa/internal/fake"
1314
"github.com/glebarez/sqlite"
@@ -20,6 +21,9 @@ type Store struct {
2021
}
2122

2223
func Open(path string) (*Store, error) {
24+
if err := ValidateDBPath(path); err != nil {
25+
return nil, err
26+
}
2327
db, err := gorm.Open(
2428
sqlite.Open(path),
2529
&gorm.Config{
@@ -35,6 +39,60 @@ func Open(path string) (*Store, error) {
3539
return &Store{db: db}, nil
3640
}
3741

42+
// ValidateDBPath rejects paths that could be interpreted as URIs by the
43+
// SQLite driver. The underlying go-sqlite driver passes query strings
44+
// through net/url.ParseQuery (subject to CVE GO-2026-4341) and enables
45+
// SQLITE_OPEN_URI, so both file:// URIs and scheme://... URLs must be
46+
// blocked. Only plain filesystem paths and the special ":memory:" value
47+
// are accepted.
48+
func ValidateDBPath(path string) error {
49+
if path == "" {
50+
return fmt.Errorf("database path must not be empty")
51+
}
52+
if path == ":memory:" {
53+
return nil
54+
}
55+
// Reject anything with a URI scheme (letters followed by "://").
56+
if i := strings.Index(path, "://"); i > 0 {
57+
scheme := path[:i]
58+
if isLetterOnly(scheme) {
59+
return fmt.Errorf(
60+
"database path %q looks like a URI (%s://); only filesystem paths are accepted",
61+
path, scheme,
62+
)
63+
}
64+
}
65+
// Reject "file:" prefix -- even without "//", SQLite interprets
66+
// "file:path?query" as a URI when SQLITE_OPEN_URI is set.
67+
if strings.HasPrefix(path, "file:") {
68+
return fmt.Errorf(
69+
"database path %q uses the file: scheme; pass a plain filesystem path instead",
70+
path,
71+
)
72+
}
73+
// Reject paths containing '?' -- the go-sqlite driver splits on '?'
74+
// and feeds the remainder to url.ParseQuery.
75+
if strings.ContainsRune(path, '?') {
76+
return fmt.Errorf(
77+
"database path %q contains '?' which would be interpreted as query parameters",
78+
path,
79+
)
80+
}
81+
return nil
82+
}
83+
84+
func isLetterOnly(s string) bool {
85+
if len(s) == 0 {
86+
return false
87+
}
88+
for _, r := range s {
89+
if !unicode.IsLetter(r) {
90+
return false
91+
}
92+
}
93+
return true
94+
}
95+
3896
// Close closes the underlying database connection.
3997
func (s *Store) Close() error {
4098
sqlDB, err := s.db.DB()
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright 2026 Phillip Cloud
2+
// Licensed under the Apache License, Version 2.0
3+
4+
package data
5+
6+
import (
7+
"fmt"
8+
"strings"
9+
"testing"
10+
11+
"github.com/brianvoe/gofakeit/v7"
12+
)
13+
14+
func TestValidateDBPath(t *testing.T) {
15+
tests := []struct {
16+
path string
17+
wantErr string // substring of error, "" means no error
18+
}{
19+
// Valid paths.
20+
{path: ":memory:"},
21+
{path: "/home/user/micasa.db"},
22+
{path: "relative/path.db"},
23+
{path: "./local.db"},
24+
{path: "../parent/db.sqlite"},
25+
{path: "/tmp/micasa test.db"},
26+
{path: "C:\\Users\\me\\micasa.db"},
27+
28+
// URI schemes -- must be rejected.
29+
{path: "https://evil.com/db", wantErr: "looks like a URI"},
30+
{path: "http://localhost/db", wantErr: "looks like a URI"},
31+
{path: "ftp://files.example.com/data.db", wantErr: "looks like a URI"},
32+
{path: "file://localhost/tmp/test.db", wantErr: "looks like a URI"},
33+
34+
// file: without // -- SQLite still interprets this as URI.
35+
{path: "file:/tmp/test.db", wantErr: "file: scheme"},
36+
{path: "file:test.db", wantErr: "file: scheme"},
37+
{path: "file:test.db?mode=ro", wantErr: "file: scheme"},
38+
39+
// Query parameters -- trigger url.ParseQuery in driver.
40+
{path: "/tmp/test.db?_pragma=journal_mode(wal)", wantErr: "contains '?'"},
41+
{path: "test.db?cache=shared", wantErr: "contains '?'"},
42+
43+
// Empty path.
44+
{path: "", wantErr: "must not be empty"},
45+
46+
// Not a scheme: no letters before "://".
47+
{path: "/path/with://in/middle"},
48+
49+
// Numeric prefix before :// is not a scheme.
50+
{path: "123://not-a-scheme"},
51+
}
52+
53+
for _, tt := range tests {
54+
t.Run(tt.path, func(t *testing.T) {
55+
err := ValidateDBPath(tt.path)
56+
if tt.wantErr == "" {
57+
if err != nil {
58+
t.Errorf("ValidateDBPath(%q) = %v, want nil", tt.path, err)
59+
}
60+
return
61+
}
62+
if err == nil {
63+
t.Errorf("ValidateDBPath(%q) = nil, want error containing %q", tt.path, tt.wantErr)
64+
return
65+
}
66+
if !strings.Contains(err.Error(), tt.wantErr) {
67+
t.Errorf(
68+
"ValidateDBPath(%q) = %v, want error containing %q",
69+
tt.path, err, tt.wantErr,
70+
)
71+
}
72+
})
73+
}
74+
}
75+
76+
func TestValidateDBPathRejectsRandomURLs(t *testing.T) {
77+
f := gofakeit.New(42)
78+
for i := 0; i < 100; i++ {
79+
u := f.URL()
80+
t.Run(fmt.Sprintf("seed42_%d", i), func(t *testing.T) {
81+
err := ValidateDBPath(u)
82+
if err == nil {
83+
t.Errorf("ValidateDBPath(%q) = nil, want rejection", u)
84+
}
85+
})
86+
}
87+
}
88+
89+
func TestValidateDBPathRejectsRandomURLsWithQueryParams(t *testing.T) {
90+
f := gofakeit.New(99)
91+
for i := 0; i < 50; i++ {
92+
// Construct URLs with query params that would hit url.ParseQuery.
93+
u := fmt.Sprintf("%s?%s=%s", f.URL(), f.Word(), f.Word())
94+
t.Run(fmt.Sprintf("seed99_%d", i), func(t *testing.T) {
95+
err := ValidateDBPath(u)
96+
if err == nil {
97+
t.Errorf("ValidateDBPath(%q) = nil, want rejection", u)
98+
}
99+
})
100+
}
101+
}
102+
103+
func TestOpenRejectsURIs(t *testing.T) {
104+
f := gofakeit.New(7)
105+
for i := 0; i < 10; i++ {
106+
u := f.URL()
107+
t.Run(fmt.Sprintf("seed7_%d", i), func(t *testing.T) {
108+
_, err := Open(u)
109+
if err == nil {
110+
t.Fatalf("Open(%q) should reject URI paths", u)
111+
}
112+
})
113+
}
114+
}

0 commit comments

Comments
 (0)