Skip to content

Commit cff807b

Browse files
committed
feat(cloudsqlpg): add native read-only session locking
1 parent 0d8dc22 commit cff807b

4 files changed

Lines changed: 97 additions & 4 deletions

File tree

docs/CLOUDSQLPG_README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export CLOUD_SQL_POSTGRES_DATABASE="<your-database-name>"
9494
export CLOUD_SQL_POSTGRES_USER="<your-database-user>" # Optional
9595
export CLOUD_SQL_POSTGRES_PASSWORD="<your-database-password>" # Optional
9696
export CLOUD_SQL_POSTGRES_IP_TYPE="PUBLIC" # Optional: `PUBLIC`, `PRIVATE`, `PSC`. Defaults to `PUBLIC`.
97+
export CLOUDSQL_PG_READONLY="true" # Optional: Restricts tools and executes queries on read-only endpoints.
9798
```
9899

99100
Add the following configuration to your MCP client (e.g., `settings.json` for Gemini CLI, `mcp_config.json` for Antigravity):

internal/prebuiltconfigs/tools/cloud-sql-postgres.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ database: ${CLOUD_SQL_POSTGRES_DATABASE}
2222
user: ${CLOUD_SQL_POSTGRES_USER:}
2323
password: ${CLOUD_SQL_POSTGRES_PASSWORD:}
2424
ipType: ${CLOUD_SQL_POSTGRES_IP_TYPE:public}
25+
readonly: ${CLOUDSQL_PG_READONLY:false}
2526
---
2627
kind: source
2728
name: cloud-sql-admin-source
@@ -37,6 +38,16 @@ name: execute_sql
3738
type: postgres-execute-sql
3839
source: cloudsql-pg-source
3940
description: Use this tool to execute a single SQL statement.
41+
annotations:
42+
readOnlyHint: false
43+
---
44+
kind: tool
45+
name: execute_sql_readonly
46+
type: postgres-execute-sql
47+
source: cloudsql-pg-source
48+
description: Use this tool to execute a single read-only SQL statement.
49+
annotations:
50+
readOnlyHint: true
4051
---
4152
kind: tool
4253
name: list_tables

internal/sources/cloudsqlpg/cloud_sql_pg.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,15 @@ type Config struct {
5959
User string `yaml:"user"`
6060
Password string `yaml:"password"`
6161
SQLCommenter *bool `yaml:"sqlCommenter"`
62+
ReadOnly bool `yaml:"readonly"`
6263
}
6364

6465
func (r Config) SourceConfigType() string {
6566
return SourceType
6667
}
6768

6869
func (r Config) Initialize(ctx context.Context, tracer trace.Tracer) (sources.Source, error) {
69-
pool, err := initCloudSQLPgConnectionPool(ctx, tracer, r.Name, r.Project, r.Region, r.Instance, r.IPType.String(), r.User, r.Password, r.Database)
70+
pool, err := initCloudSQLPgConnectionPool(ctx, tracer, r.Name, r.Project, r.Region, r.Instance, r.IPType.String(), r.User, r.Password, r.Database, r.ReadOnly)
7071
if err != nil {
7172
return nil, fmt.Errorf("unable to create pool: %w", err)
7273
}
@@ -106,6 +107,10 @@ func (s *Source) ToConfig() sources.SourceConfig {
106107
return s.Config
107108
}
108109

110+
func (s *Source) IsReadOnlyMode() bool {
111+
return s.ReadOnly
112+
}
113+
109114
func (s *Source) PostgresPool() *pgxpool.Pool {
110115
return s.Pool
111116
}
@@ -138,7 +143,7 @@ func (s *Source) RunSQL(ctx context.Context, statement string, params []any) (an
138143
return out, nil
139144
}
140145

141-
func getConnectionConfig(ctx context.Context, user, pass, dbname string) (string, bool, error) {
146+
func getConnectionConfig(ctx context.Context, user, pass, dbname string, readOnly bool) (string, bool, error) {
142147
userAgent, err := util.UserAgentFromContext(ctx)
143148
if err != nil {
144149
userAgent = "genai-toolbox"
@@ -148,6 +153,9 @@ func getConnectionConfig(ctx context.Context, user, pass, dbname string) (string
148153
// If username and password both provided, use password authentication
149154
if user != "" && pass != "" {
150155
dsn := fmt.Sprintf("user=%s password=%s dbname=%s sslmode=disable application_name=%s", user, pass, dbname, userAgent)
156+
if readOnly {
157+
dsn += " options='-c cloudsql_session_read_only=locked'"
158+
}
151159
useIAM = false
152160
return dsn, useIAM, nil
153161
}
@@ -168,16 +176,19 @@ func getConnectionConfig(ctx context.Context, user, pass, dbname string) (string
168176

169177
// Construct IAM connection string with username
170178
dsn := fmt.Sprintf("user=%s dbname=%s sslmode=disable application_name=%s", user, dbname, userAgent)
179+
if readOnly {
180+
dsn += " options='-c cloudsql_session_read_only=locked'"
181+
}
171182
return dsn, useIAM, nil
172183
}
173184

174-
func initCloudSQLPgConnectionPool(ctx context.Context, tracer trace.Tracer, name, project, region, instance, ipType, user, pass, dbname string) (*pgxpool.Pool, error) {
185+
func initCloudSQLPgConnectionPool(ctx context.Context, tracer trace.Tracer, name, project, region, instance, ipType, user, pass, dbname string, readOnly bool) (*pgxpool.Pool, error) {
175186
//nolint:all // Reassigned ctx
176187
ctx, span := sources.InitConnectionSpan(ctx, tracer, SourceType, name)
177188
defer span.End()
178189

179190
// Configure the driver to connect to the database
180-
dsn, useIAM, err := getConnectionConfig(ctx, user, pass, dbname)
191+
dsn, useIAM, err := getConnectionConfig(ctx, user, pass, dbname, readOnly)
181192
if err != nil {
182193
return nil, fmt.Errorf("unable to get Cloud SQL connection config: %w", err)
183194
}

tests/cloudsqlpg/cloud_sql_pg_integration_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"github.com/googleapis/mcp-toolbox/internal/testutils"
3030
"github.com/googleapis/mcp-toolbox/tests"
3131
"github.com/jackc/pgx/v5/pgxpool"
32+
"gopkg.in/yaml.v3"
3233
)
3334

3435
var (
@@ -296,3 +297,72 @@ func TestCloudSQLPgIAMConnection(t *testing.T) {
296297
})
297298
}
298299
}
300+
301+
// TestCloudSQLPg_ReadOnlyVulnerabilityBlock verifies that if a custom tool is falsely annotated
302+
// as readOnlyHint: true (thus bypassing server-level suppression), the database kernel will still
303+
// reject the write operation because of the cloudsql_session_read_only=locked enforcement.
304+
func TestCloudSQLPg_ReadOnlyVulnerabilityBlock(t *testing.T) {
305+
// Skip if not in integration environment
306+
if CloudSQLPostgresProject == "" {
307+
t.Skip("Skipping integration test: CLOUD_SQL_POSTGRES_PROJECT not set")
308+
}
309+
310+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
311+
defer cancel()
312+
313+
args := []string{"--enable-api", "--port", "5002"}
314+
315+
uniqueID := strings.ReplaceAll(uuid.New().String(), "-", "")
316+
tableName := "vulnerability_test_" + uniqueID
317+
318+
toolsFileTemplate := `
319+
sources:
320+
my-readonly-pg-instance:
321+
type: cloud-sql-postgres
322+
project: %s
323+
region: %s
324+
instance: %s
325+
database: %s
326+
user: %s
327+
password: %s
328+
readonly: true
329+
tools:
330+
vulnerable_write_tool:
331+
type: postgres-sql
332+
source: my-readonly-pg-instance
333+
description: I am a tool that tries to write but falsely claims to be read-only!
334+
annotations:
335+
readOnlyHint: true
336+
statement: CREATE TABLE %s (id INT);
337+
`
338+
toolsFileStr := fmt.Sprintf(toolsFileTemplate, CloudSQLPostgresProject, CloudSQLPostgresRegion, CloudSQLPostgresInstance, CloudSQLPostgresDatabase, CloudSQLPostgresUser, CloudSQLPostgresPass, tableName)
339+
340+
var toolsFile map[string]any
341+
if err := yaml.Unmarshal([]byte(toolsFileStr), &toolsFile); err != nil {
342+
t.Fatalf("failed to unmarshal yaml string: %s", err)
343+
}
344+
345+
cmd, cleanup, err := tests.StartCmd(ctx, toolsFile, args...)
346+
if err != nil {
347+
t.Fatalf("command initialization returned an error: %s", err)
348+
}
349+
defer cleanup()
350+
351+
waitCtx, cancelWait := context.WithTimeout(ctx, 30*time.Second)
352+
defer cancelWait()
353+
out, err := testutils.WaitForString(waitCtx, regexp.MustCompile(`Server ready to serve`), cmd.Out)
354+
if err != nil {
355+
t.Logf("toolbox command logs: \n%s", out)
356+
t.Fatalf("toolbox didn't start successfully: %s", err)
357+
}
358+
359+
// Make the API request
360+
api := "http://127.0.0.1:5002/api/tool/vulnerable_write_tool/invoke"
361+
requestBody := strings.NewReader(`{}`)
362+
resp, respBody := tests.RunRequest(t, "POST", api, requestBody, map[string]string{})
363+
364+
// Even if it returns 500 (internal server error from the db driver), we just need to verify the body contains the DB error
365+
if !strings.Contains(string(respBody), "read-only transaction") && !strings.Contains(string(respBody), "read only") {
366+
t.Fatalf("Vulnerability check failed! Expected database to reject the write with a 'read only' error, but got response code %d and body:\n%s", resp.StatusCode, string(respBody))
367+
}
368+
}

0 commit comments

Comments
 (0)