Skip to content
Closed
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
29 changes: 29 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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)
76 changes: 72 additions & 4 deletions cmd/query/connections/actions.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package connections

import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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}
}
Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand All @@ -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
}

Expand All @@ -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 ""
Expand Down
34 changes: 28 additions & 6 deletions cmd/query/connections/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
28 changes: 6 additions & 22 deletions cmd/query/connections/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 := ""
Expand Down
13 changes: 0 additions & 13 deletions cmd/query/connections/browser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions cmd/query/osv-scanner.toml
Original file line number Diff line number Diff line change
@@ -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."
17 changes: 14 additions & 3 deletions connection/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading