diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f61151b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,29 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + commit-message: + prefix: fix(deps) + + - package-ecosystem: gomod + directory: /cmd/query + schedule: + interval: weekly + commit-message: + prefix: fix(deps) + + - package-ecosystem: npm + directory: /cmd/query/www + schedule: + interval: weekly + commit-message: + prefix: fix(deps) + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + commit-message: + prefix: fix(deps) diff --git a/cmd/query/connections/actions.go b/cmd/query/connections/actions.go index 78a89ac..97e578a 100644 --- a/cmd/query/connections/actions.go +++ b/cmd/query/connections/actions.go @@ -1,10 +1,12 @@ package connections import ( + "context" "encoding/json" "fmt" "net" "net/http" + "net/netip" "net/url" "strings" "time" @@ -53,6 +55,8 @@ type testResult struct { URL string `json:"url,omitempty"` } +const allowPrivateConnectionProbeProperty = "connection.test.allow-private-addresses" + func (h *connectionActionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { rel := strings.Trim(strings.TrimPrefix(strings.TrimSuffix(r.URL.Path, "/"), h.prefix), "/") if r.Method != http.MethodPost || (rel != "connection/resolve" && rel != "connection/test") { @@ -153,7 +157,12 @@ func testConnection(ctx dbcontext.Context, c *models.Connection) testResult { dialCtx, cancel := ctx.WithTimeout(5 * time.Second) defer cancel() - conn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", host) + allowPrivate := ctx.Properties().On(false, allowPrivateConnectionProbeProperty) + dialAddress, err := validatedProbeAddress(dialCtx, host, allowPrivate) + if err != nil { + return testResult{OK: false, Message: fmt.Sprintf("TCP connect to %s rejected: %v", host, err), URL: displayURL} + } + conn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", dialAddress) if err != nil { return testResult{OK: false, Message: fmt.Sprintf("TCP connect to %s failed: %v", host, err), URL: displayURL} } @@ -248,7 +257,7 @@ func httpProbe(ctx dbcontext.Context, c *models.Connection, displayURL string) t return testResult{OK: false, Message: fmt.Sprintf("HTTP authentication setup failed: %v", redactError(err, c.URL, displayURL)), URL: displayURL} } client := &http.Client{ - Transport: httpConnection.Transport(), + Transport: httpConnection.TransportWithBase(newHTTPProbeTransport(ctx.Properties().On(false, allowPrivateConnectionProbeProperty))), Timeout: 8 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse @@ -259,6 +268,10 @@ func httpProbe(ctx dbcontext.Context, c *models.Connection, displayURL string) t return testResult{OK: false, Message: fmt.Sprintf("HTTP request failed: %v", err), URL: displayURL} } req.Host = target.hostHeader + // Every HTTP and OAuth-token dial passes through the address policy above, + // which resolves once, rejects non-public destinations by default, and dials + // the vetted IP directly. Redirects are disabled as a second boundary. + // codeql[go/request-forgery] resp, err := client.Do(req) if err != nil { return testResult{OK: false, Message: fmt.Sprintf("HTTP request failed: %v", redactError(err, c.URL, displayURL)), URL: displayURL} @@ -280,7 +293,6 @@ func httpProbe(ctx dbcontext.Context, c *models.Connection, displayURL string) t type httpProbeTarget struct { url *url.URL - hostPort string hostHeader string } @@ -307,11 +319,67 @@ func validatedHTTPProbeTarget(rawURL string) (*httpProbeTarget, error) { probeURL.User = nil return &httpProbeTarget{ url: &probeURL, - hostPort: net.JoinHostPort(u.Hostname(), port), hostHeader: u.Host, }, nil } +func newHTTPProbeTransport(allowPrivate bool) *http.Transport { + dialer := &net.Dialer{} + return &http.Transport{ + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + dialAddress, err := validatedProbeAddress(ctx, address, allowPrivate) + if err != nil { + return nil, err + } + return dialer.DialContext(ctx, network, dialAddress) + }, + TLSHandshakeTimeout: 5 * time.Second, + } +} + +func validatedProbeAddress(ctx context.Context, address string, allowPrivate bool) (string, error) { + host, port, err := net.SplitHostPort(address) + if err != nil || host == "" || port == "" { + return "", fmt.Errorf("invalid network address") + } + + var addresses []netip.Addr + if ip, parseErr := netip.ParseAddr(host); parseErr == nil { + addresses = []netip.Addr{ip} + } else { + addresses, err = net.DefaultResolver.LookupNetIP(ctx, "ip", host) + if err != nil { + return "", fmt.Errorf("resolve host: %w", err) + } + } + if len(addresses) == 0 { + return "", fmt.Errorf("host has no IP addresses") + } + for _, ip := range addresses { + if err := validateProbeIP(ip, allowPrivate); err != nil { + return "", fmt.Errorf("host resolves to a prohibited address: %w", err) + } + } + return net.JoinHostPort(addresses[0].String(), port), nil +} + +func validateProbeIP(ip netip.Addr, allowPrivate bool) error { + ip = ip.Unmap() + if !ip.IsValid() || ip.IsUnspecified() || ip.IsMulticast() { + return fmt.Errorf("unspecified or multicast IP") + } + if allowPrivate { + return nil + } + if !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return fmt.Errorf("non-public IP") + } + if netip.MustParsePrefix("100.64.0.0/10").Contains(ip) { + return fmt.Errorf("shared address space") + } + return nil +} + func redactConnectionURL(rawURL string) string { if rawURL == "" { return "" diff --git a/cmd/query/connections/actions_test.go b/cmd/query/connections/actions_test.go index bef307f..0365361 100644 --- a/cmd/query/connections/actions_test.go +++ b/cmd/query/connections/actions_test.go @@ -8,12 +8,25 @@ import ( "strings" "testing" + "github.com/flanksource/commons/properties" "github.com/google/uuid" dbcontext "github.com/flanksource/commons-db/context" "github.com/flanksource/commons-db/models" ) +func privateProbeTestContext(t *testing.T) dbcontext.Context { + t.Helper() + properties.Set(allowPrivateConnectionProbeProperty, "true") + ctx := dbcontext.NewContext(context.Background()) + ctx.ClearCache() + t.Cleanup(func() { + properties.Set(allowPrivateConnectionProbeProperty, "") + ctx.ClearCache() + }) + return ctx +} + func TestMaskedConnection(t *testing.T) { got := maskedConnection(&models.Connection{ Type: "postgres", @@ -133,7 +146,7 @@ func TestTestConnectionADOReachable(t *testing.T) { t.Fatalf("split host port: %v", err) } - ctx := dbcontext.NewContext(context.Background()) + ctx := privateProbeTestContext(t) dsn := "server=" + host + ";port=" + port + ";database=x;trustServerCertificate=true" res := testConnection(ctx, &models.Connection{Type: "sql_server", URL: dsn}) if !res.OK { @@ -147,7 +160,7 @@ func TestTestConnectionHTTPReachable(t *testing.T) { })) defer ts.Close() - ctx := dbcontext.NewContext(context.Background()) + ctx := privateProbeTestContext(t) res := testConnection(ctx, &models.Connection{Type: "http", URL: ts.URL}) if !res.OK { t.Fatalf("expected reachable, got %+v", res) @@ -164,7 +177,7 @@ func TestTestConnectionHTTPRedactsURL(t *testing.T) { defer ts.Close() u := strings.Replace(ts.URL, "http://", "http://user:pass@", 1) + "?token=secret-token" - ctx := dbcontext.NewContext(context.Background()) + ctx := privateProbeTestContext(t) res := testConnection(ctx, &models.Connection{Type: "http", URL: u}) if !res.OK { t.Fatalf("expected reachable, got %+v", res) @@ -180,7 +193,7 @@ func TestTestConnectionHTTPSReachableWithInsecureTLS(t *testing.T) { })) defer ts.Close() - ctx := dbcontext.NewContext(context.Background()) + ctx := privateProbeTestContext(t) res := testConnection(ctx, &models.Connection{Type: "https", URL: ts.URL, InsecureTLS: true}) if !res.OK { t.Fatalf("expected reachable, got %+v", res) @@ -201,7 +214,7 @@ func TestTestConnectionHTTPBasicAuth(t *testing.T) { })) defer ts.Close() - ctx := dbcontext.NewContext(context.Background()) + ctx := privateProbeTestContext(t) res := testConnection(ctx, &models.Connection{ Type: models.ConnectionTypeOpenSearch, URL: ts.URL, @@ -225,7 +238,7 @@ func TestTestConnectionHTTPAuthenticationFailure(t *testing.T) { })) defer ts.Close() - ctx := dbcontext.NewContext(context.Background()) + ctx := privateProbeTestContext(t) res := testConnection(ctx, &models.Connection{ Type: models.ConnectionTypeOpenSearch, URL: ts.URL, @@ -271,6 +284,15 @@ func TestMergeStoredDraftSecrets(t *testing.T) { } } +func TestTestConnectionRejectsPrivateAddressByDefault(t *testing.T) { + ctx := dbcontext.NewContext(context.Background()) + ctx.ClearCache() + res := testConnection(ctx, &models.Connection{Type: "http", URL: "http://127.0.0.1:8080"}) + if res.OK || !strings.Contains(res.Message, "prohibited address") { + t.Fatalf("expected private address rejection, got %+v", res) + } +} + func TestTestConnectionUnreachable(t *testing.T) { ctx := dbcontext.NewContext(context.Background()) // Port 1 is reserved and not listening — the TCP connect must fail. diff --git a/cmd/query/connections/browser.go b/cmd/query/connections/browser.go index ad9e706..660cb32 100644 --- a/cmd/query/connections/browser.go +++ b/cmd/query/connections/browser.go @@ -235,24 +235,18 @@ func (h *connectionBrowserHandler) serveQuery(w http.ResponseWriter, r *http.Req } func (h *connectionBrowserHandler) executeSQL(r *http.Request, conn *models.Connection, statement, database string) (browserQueryResult, error) { + if err := query.ValidateReadOnlySQL(statement); err != nil { + return browserQueryResult{}, err + } client, err := h.sqlClient(r.Context(), conn, database) if err != nil { return browserQueryResult{}, err } defer client.Close() - if !sqlReturnsRows(statement) { - res, err := client.ExecContext(r.Context(), statement) - if err != nil { - return browserQueryResult{}, err - } - affected, err := res.RowsAffected() - if err != nil { - return browserQueryResult{Message: "Statement executed successfully"}, nil - } - return browserQueryResult{AffectedRows: &affected, Message: "Statement executed successfully"}, nil - } - + // The browser accepts a complete query and enforces the shared single-statement + // read-only policy before opening the database connection. + // codeql[go/sql-injection] rows, err := client.QueryContext(r.Context(), statement) if err != nil { return browserQueryResult{}, err @@ -270,16 +264,6 @@ func (h *connectionBrowserHandler) executeSQL(r *http.Request, conn *models.Conn return browserQueryResult{Rows: values, Columns: columns}, err } -func sqlReturnsRows(statement string) bool { - statement = strings.ToLower(strings.TrimSpace(statement)) - for _, prefix := range []string{"select", "with", "show", "describe", "desc", "explain", "pragma", "values"} { - if strings.HasPrefix(statement, prefix) { - return true - } - } - return false -} - func (h *connectionBrowserHandler) executeOpenSearch(r *http.Request, conn *models.Connection, request browserQueryRequest) (browserQueryResult, error) { index, _ := request.Options["index"].(string) limit := "" diff --git a/cmd/query/connections/browser_test.go b/cmd/query/connections/browser_test.go index 258ee2b..ae0ef5e 100644 --- a/cmd/query/connections/browser_test.go +++ b/cmd/query/connections/browser_test.go @@ -175,19 +175,6 @@ func TestConnectionBrowserOpenSearchInspection(t *testing.T) { } } -func TestSQLReturnsRows(t *testing.T) { - for _, statement := range []string{"SELECT 1", " with x as (select 1) select * from x", "SHOW TABLES", "EXPLAIN SELECT 1"} { - if !sqlReturnsRows(statement) { - t.Errorf("expected row-producing statement: %q", statement) - } - } - for _, statement := range []string{"INSERT INTO t VALUES (1)", "UPDATE t SET a=1", "DELETE FROM t", "CREATE TABLE t(a int)", "EXEC p"} { - if sqlReturnsRows(statement) { - t.Errorf("expected non-row statement: %q", statement) - } - } -} - func TestSQLIdentifier(t *testing.T) { if got := sqlIdentifier(models.ConnectionTypePostgres, "public", "events"); got != `"public"."events"` { t.Fatalf("postgres identifier = %s", got) diff --git a/cmd/query/osv-scanner.toml b/cmd/query/osv-scanner.toml new file mode 100644 index 0000000..a78d276 --- /dev/null +++ b/cmd/query/osv-scanner.toml @@ -0,0 +1,3 @@ +[[IgnoredVulns]] +id = "GO-2026-5932" +reason = "The affected golang.org/x/crypto/openpgp packages are not imported; x/crypto remains required for maintained cryptographic and SSH packages, and the advisory has no fixed release." diff --git a/connection/http.go b/connection/http.go index 7127ccf..6de28f9 100644 --- a/connection/http.go +++ b/connection/http.go @@ -200,11 +200,19 @@ func (h *HTTPConnection) Hydrate(ctx ConnectionContext, namespace string) (*HTTP } func (h HTTPConnection) Transport() netHTTP.RoundTripper { - rt := &httpConnectionRoundTripper{ + return h.TransportWithBase(&netHTTP.Transport{}) +} + +// TransportWithBase applies the connection's authentication and TLS settings +// while preserving caller-owned network policy on base. +func (h HTTPConnection) TransportWithBase(base netHTTP.RoundTripper) netHTTP.RoundTripper { + if base == nil { + base = &netHTTP.Transport{} + } + return &httpConnectionRoundTripper{ HTTPConnection: h, - Base: &netHTTP.Transport{}, + Base: base, } - return rt } type httpConnectionRoundTripper struct { @@ -240,6 +248,9 @@ func (rt *httpConnectionRoundTripper) RoundTrip(req *netHTTP.Request) (*netHTTP. TokenURL: conn.OAuth.TokenURL, Params: conn.OAuth.Params, Scopes: conn.OAuth.Scopes, + TokenTransport: func(netHTTP.RoundTripper) netHTTP.RoundTripper { + return base + }, }) base = oauthTransport.RoundTripper(base) } diff --git a/context/onepassword.go b/context/onepassword.go index aaa15ff..0981c8c 100644 --- a/context/onepassword.go +++ b/context/onepassword.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "net/url" "os" "os/exec" "strings" @@ -76,10 +77,16 @@ func tokenFingerprint(token string) string { // opRead invokes `op read` for a single reference. When a token is supplied it // is injected via OP_SERVICE_ACCOUNT_TOKEN for non-interactive resolution. func opRead(ctx Context, ref, token string) (string, error) { + if err := validateOnePasswordReference(ref); err != nil { + return "", err + } if _, err := exec.LookPath("op"); err != nil { return "", fmt.Errorf("1password CLI (op) not found in PATH: %w", err) } - cmd := exec.CommandContext(ctx, "op", "read", "--no-newline", ref) + // The reference is a validated op:// URI and follows `--`, so the CLI cannot + // reinterpret any part of it as an option. + // codeql[go/command-injection] + cmd := exec.CommandContext(ctx, "op", "read", "--no-newline", "--", ref) cmd.Env = os.Environ() if token != "" { cmd.Env = append(cmd.Env, "OP_SERVICE_ACCOUNT_TOKEN="+token) @@ -92,3 +99,18 @@ func opRead(ctx Context, ref, token string) (string, error) { } return stdout.String(), nil } + +func validateOnePasswordReference(ref string) error { + if strings.ContainsAny(ref, "\x00\r\n") { + return fmt.Errorf("invalid 1password reference: control characters are not allowed") + } + u, err := url.Parse(ref) + if err != nil || u.Scheme != "op" || u.Host == "" || u.Fragment != "" { + return fmt.Errorf("invalid 1password reference: expected op:////") + } + parts := strings.Split(strings.Trim(u.EscapedPath(), "/"), "/") + if len(parts) < 2 || parts[0] == "" || parts[len(parts)-1] == "" { + return fmt.Errorf("invalid 1password reference: expected op:////") + } + return nil +} diff --git a/context/onepassword_test.go b/context/onepassword_test.go index 69739b7..9c13ce1 100644 --- a/context/onepassword_test.go +++ b/context/onepassword_test.go @@ -44,6 +44,27 @@ func TestGetOnePasswordValueFromCache(t *testing.T) { } } +func TestValidateOnePasswordReference(t *testing.T) { + for _, ref := range []string{ + "op://prod/postgres/password", + "op://Private/API%20Key/credential?attribute=otp", + } { + if err := validateOnePasswordReference(ref); err != nil { + t.Errorf("valid reference %q rejected: %v", ref, err) + } + } + for _, ref := range []string{ + "--help", + "https://example.com/secret", + "op://vault/item", + "op://vault/item/field\n--format=json", + } { + if err := validateOnePasswordReference(ref); err == nil { + t.Errorf("invalid reference %q accepted", ref) + } + } +} + func TestOnePasswordTokenPrefersProperty(t *testing.T) { t.Setenv("OP_SERVICE_ACCOUNT_TOKEN", "env-token") @@ -65,8 +86,8 @@ func TestOnePasswordTokenPrefersProperty(t *testing.T) { func TestTokenFingerprintIsolatesCache(t *testing.T) { const ( - tokenA = "a" - tokenB = "b" + tokenA = "a" + tokenB = "b" unkeyedFingerprintForTokenA = "ca978112ca1bbdca" ) diff --git a/osv-scanner.toml b/osv-scanner.toml new file mode 100644 index 0000000..a78d276 --- /dev/null +++ b/osv-scanner.toml @@ -0,0 +1,3 @@ +[[IgnoredVulns]] +id = "GO-2026-5932" +reason = "The affected golang.org/x/crypto/openpgp packages are not imported; x/crypto remains required for maintained cryptographic and SSH packages, and the advisory has no fixed release." diff --git a/query/providers/sql.go b/query/providers/sql.go index 116d421..d5aa1a2 100644 --- a/query/providers/sql.go +++ b/query/providers/sql.go @@ -69,6 +69,9 @@ func (p sqlProvider) OpenRows(ctx context.Context, req query.ProviderRequest) (q if req.Query == "" { return nil, fmt.Errorf("sql query is required") } + if err := query.ValidateReadOnlySQL(req.Query); err != nil { + return nil, err + } opts, err := query.DecodeOptions[sqlOptions](req.Options) if err != nil { @@ -111,6 +114,9 @@ func (p sqlProvider) OpenRows(ctx context.Context, req query.ProviderRequest) (q return nil, fmt.Errorf("failed to create sql client: %w", err) } + // Query is the complete provider statement and has passed the single-statement + // read-only policy above; no request value is interpolated into another query. + // codeql[go/sql-injection] rows, err := client.QueryContext(ctx, req.Query) if err != nil { client.Close() diff --git a/query/sample.go b/query/sample.go index 8057b50..420aeec 100644 --- a/query/sample.go +++ b/query/sample.go @@ -84,7 +84,7 @@ func Sample(ctx context.Context, p Profile, params map[string]any, limit int) (* func validateSampleReadOnly(providerType, query string, options map[string]any) error { switch providerType { case "sql", "postgres", "mysql", "sqlserver", "clickhouse": - return validateReadOnlySQL(query) + return ValidateReadOnlySQL(query) case "http": method := "GET" if raw, ok := options["method"]; ok && strings.TrimSpace(fmt.Sprint(raw)) != "" { @@ -110,14 +110,19 @@ var forbiddenSQLTokens = map[string]struct{}{ "set": {}, "use": {}, "begin": {}, "commit": {}, "rollback": {}, } -func validateReadOnlySQL(sql string) error { - tokens, statements, pragmaAssignment := scanSQL(sql) +// ValidateReadOnlySQL accepts one row-producing statement and rejects SQL +// tokens that can mutate data, schema, permissions, sessions, or transactions. +func ValidateReadOnlySQL(sql string) error { + tokens, statements, pragmaAssignment, executableComment := scanSQL(sql) + if executableComment { + return fmt.Errorf("SQL execution rejects executable comments because only read-only statements are allowed") + } if statements != 1 || len(tokens) == 0 { - return fmt.Errorf("sampling requires exactly one read-only SQL statement") + return fmt.Errorf("SQL execution requires exactly one read-only statement") } for _, token := range tokens { if _, forbidden := forbiddenSQLTokens[token]; forbidden { - return fmt.Errorf("sampling rejected SQL keyword %q because only read-only statements are allowed", strings.ToUpper(token)) + return fmt.Errorf("SQL execution rejected keyword %q because only read-only statements are allowed", strings.ToUpper(token)) } } allowed := map[string]bool{ @@ -125,7 +130,7 @@ func validateReadOnlySQL(sql string) error { "explain": true, "pragma": true, "values": true, "with": true, } if !allowed[tokens[0]] { - return fmt.Errorf("sampling only allows SELECT, WITH, VALUES, SHOW, DESCRIBE, EXPLAIN, or read-only PRAGMA statements") + return fmt.Errorf("SQL execution only allows SELECT, WITH, VALUES, SHOW, DESCRIBE, EXPLAIN, or read-only PRAGMA statements") } if tokens[0] == "with" { hasResult := false @@ -136,24 +141,26 @@ func validateReadOnlySQL(sql string) error { } } if !hasResult { - return fmt.Errorf("sampling requires a read-only WITH statement that returns rows") + return fmt.Errorf("SQL execution requires a read-only WITH statement that returns rows") } } if tokens[0] == "pragma" && pragmaAssignment { - return fmt.Errorf("sampling rejects PRAGMA assignments because only read-only statements are allowed") + return fmt.Errorf("SQL execution rejects PRAGMA assignments because only read-only statements are allowed") } return nil } // scanSQL returns unquoted identifier tokens, the count of non-empty -// semicolon-delimited statements, and whether a PRAGMA-like assignment appears. -// Comments and quoted strings/identifiers are ignored so embedded keywords and -// semicolons do not affect the safety decision. -func scanSQL(input string) ([]string, int, bool) { +// semicolon-delimited statements, whether a PRAGMA-like assignment appears, and +// whether a MySQL executable comment appears. Ordinary comments and quoted +// strings/identifiers are ignored so embedded keywords and semicolons do not +// affect the safety decision. +func scanSQL(input string) ([]string, int, bool, bool) { var tokens []string statements := 0 hasToken := false hasAssignment := false + hasExecutableComment := false for i := 0; i < len(input); { c := input[i] if unicode.IsSpace(rune(c)) { @@ -168,6 +175,9 @@ func scanSQL(input string) ([]string, int, bool) { continue } if c == '/' && i+1 < len(input) && input[i+1] == '*' { + if i+2 < len(input) && input[i+2] == '!' { + hasExecutableComment = true + } i += 2 for i+1 < len(input) && !(input[i] == '*' && input[i+1] == '/') { i++ @@ -256,7 +266,7 @@ func scanSQL(input string) ([]string, int, bool) { if hasToken { statements++ } - return tokens, statements, hasAssignment + return tokens, statements, hasAssignment, hasExecutableComment } // InferSampleColumns infers stable, compact ColumnDefs from top-level row keys. diff --git a/query/sample_test.go b/query/sample_test.go index cd0dcc4..0d56ccb 100644 --- a/query/sample_test.go +++ b/query/sample_test.go @@ -86,6 +86,7 @@ func TestSampleRejectsUnsafeRequests(t *testing.T) { {"postgres", "SELECT 1; SELECT 2", nil}, {"postgres", "WITH removed AS (DELETE FROM jobs RETURNING *) SELECT * FROM removed", nil}, {"postgres", "PRAGMA journal_mode = WAL", nil}, + {"mysql", "SELECT 1 /*!; DELETE FROM jobs */", nil}, {"http", "/jobs", map[string]any{"method": "POST"}}, {"custom", "anything", nil}, } @@ -105,8 +106,40 @@ func TestReadOnlySQLIgnoresQuotedKeywordsAndTrailingSemicolon(t *testing.T) { "/* UPDATE jobs */ WITH rows AS (SELECT 1) SELECT * FROM rows", "EXPLAIN SELECT * FROM [delete]", } { - if err := validateReadOnlySQL(statement); err != nil { + if err := ValidateReadOnlySQL(statement); err != nil { t.Errorf("%q: %v", statement, err) } } } + +func FuzzValidateReadOnlySQL(f *testing.F) { + for _, seed := range []string{ + "SELECT 1", + "WITH rows AS (SELECT 1) SELECT * FROM rows", + "SELECT 'DELETE; DROP' AS message;", + "DELETE FROM jobs", + "SELECT 1; DROP TABLE jobs", + } { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, statement string) { + if err := ValidateReadOnlySQL(statement); err != nil { + return + } + tokens, statements, pragmaAssignment, executableComment := scanSQL(statement) + if statements != 1 || len(tokens) == 0 { + t.Fatalf("accepted invalid statement framing: %q", statement) + } + for _, token := range tokens { + if _, forbidden := forbiddenSQLTokens[token]; forbidden { + t.Fatalf("accepted forbidden token %q in %q", token, statement) + } + } + if tokens[0] == "pragma" && pragmaAssignment { + t.Fatalf("accepted mutating PRAGMA: %q", statement) + } + if executableComment { + t.Fatalf("accepted executable comment: %q", statement) + } + }) +} diff --git a/query/schema/connection.go b/query/schema/connection.go index 65a6234..2654f8c 100644 --- a/query/schema/connection.go +++ b/query/schema/connection.go @@ -109,7 +109,7 @@ func SchemaRefs(dir string, components map[string]Schema) map[string]Schema { } func appendUnique(base []string, values ...string) []string { - seen := make(map[string]bool, len(base)+len(values)) + seen := make(map[string]bool) for _, value := range base { seen[value] = true }