Files
43837afca3 fix(security): consolidate & enhance batched security fixes (#1155, #967, #972, #974, #989, #973) (#1185)
* fix(sandbox): avoid shell in FsBridge writes

Replace sh -c with interpolated path by shell-free 'tee -- <path>' argv form,
piping content via stdin. Prevents command injection through filenames
containing shell metacharacters inside the sandbox container.

Co-authored-by: evgyur <evgyur@gmail.com>

* fix(security): fail-closed on pairing DB errors across channels

On IsPaired lookup error, deny instead of granting access. Covers the shared
CheckDMPolicy/CheckGroupPolicy helpers (Slack/Discord/Feishu/WhatsApp/Zalo) and
the four inline Telegram pairing checks.

Co-authored-by: Srini <srinis.k@gmail.com>

* fix(security): harden provider URL validation against SSRF

Enforce scheme check for all provider types; restrict local types (ollama,
claude_cli, acp) to an explicit localhost allowlist instead of skipping checks;
resolve remote hostnames and reject any IP in a private/reserved range via the
shared security.IsBlocked CIDR list (covers loopback, link-local, metadata,
multicast, and unspecified 0.0.0.0/::). Closes the wildcard-DNS bypass and the
local-type escape hatch. Operator opt-in via GOCLAW_ALLOW_PRIVATE_PROVIDER_URLS.

Exports security.IsBlocked as the single source of truth for blocked ranges.

Co-authored-by: Linh Vo Van <linh.vo@e-cq.net>

* feat(pipeline): add fail-closed tool call authorization gate

Gate tool execution against the server-side AllowedTools allowlist built from the
RBAC/tenant-aware filtered tool set. Resolve the tool-call prefix before the
allowlist lookup so prefixed agents are not wrongly blocked, re-check deny on lazy
MCP activation, and expand IsDenied to cover aliased tool names.

Co-authored-by: Huy Doan <tui@pm.me>

* fix(security): expand file-serve deny-list defense-in-depth

Add absolute-path deny prefixes (/home, /Users, /srv, /var/lib, /var/www, /opt)
and an explicit fail-closed log when no file-serving boundary is configured.

Co-authored-by: Linh Vo Van <linh.vo@e-cq.net>

* fix(providers): allow claude cli executable paths

Refs: #1185

---------

Co-authored-by: evgyur <evgyur@gmail.com>
Co-authored-by: Srini <srinis.k@gmail.com>
Co-authored-by: Linh Vo Van <linh.vo@e-cq.net>
Co-authored-by: Huy Doan <tui@pm.me>
2026-06-05 00:48:38 +07:00

215 lines
8.8 KiB
Go

package providers
import (
"context"
"fmt"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
// Vertex AI constants. Kept in the providers package (not store) to avoid an
// import cycle — store is imported by providers, so providers cannot import store.
const (
// VertexDefaultModel is the default Gemini model id (Vertex requires the "google/" prefix).
VertexDefaultModel = "google/gemini-2.0-flash-001"
// VertexDefaultScope is the OAuth2 scope for Vertex AI access.
VertexDefaultScope = "https://www.googleapis.com/auth/cloud-platform"
// ProviderTypeVertex mirrors store.ProviderVertex; duplicated here to keep the
// providers package free of a store import. Kept in sync by convention.
ProviderTypeVertex = "vertex"
)
// VertexDefaultAPIBase builds the Vertex AI OpenAI-compatible endpoint URL
// from a GCP project ID and region. Returns empty when either is missing.
// Matches: https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/endpoints/openapi
func VertexDefaultAPIBase(projectID, region string) string {
if projectID == "" || region == "" {
return ""
}
return "https://" + region + "-aiplatform.googleapis.com/v1/projects/" +
projectID + "/locations/" + region + "/endpoints/openapi"
}
// VertexConfig is the input needed to build a Vertex AI provider instance.
// Credentials precedence: CredentialsJSON > CredentialsFile > ADC (Application Default Credentials).
// When all credential sources are empty, ADC is used — works on GCE/GKE/Cloud Run where
// the metadata server issues tokens automatically, or when GOOGLE_APPLICATION_CREDENTIALS is set.
type VertexConfig struct {
Name string // registry name (e.g. "vertex"); defaults to "vertex"
CredentialsJSON string // inline service account JSON (typically from DB or env)
CredentialsFile string // path to service account JSON file. OPERATOR-ONLY — never expose via admin UI
// or DB settings without path allow-list validation: this path is read directly from disk,
// which would let remote admins exfiltrate arbitrary readable files via crafted settings.
ProjectID string // required — GCP project ID (6-30 chars, lowercase letters/digits/hyphens, must start with a letter)
Region string // required — GCP region (e.g. "us-central1", "asia-southeast1")
DefaultModel string // e.g. "google/gemini-2.0-flash-001"; defaults to VertexDefaultModel
APIBaseOverride string // optional — explicit base URL; defaults to computed from project+region
}
// GCP region format: lowercase, hyphen-separated alphanum segments. e.g. "us-central1", "asia-southeast1", "global".
var vertexRegionRe = regexp.MustCompile(`^[a-z]+(-[a-z0-9]+)*$`)
// GCP project ID format per https://cloud.google.com/resource-manager/docs/creating-managing-projects:
// 6-30 chars, lowercase letters/digits/hyphens, must start with a letter.
var vertexProjectIDRe = regexp.MustCompile(`^[a-z][a-z0-9-]{4,28}[a-z0-9]$`)
// validateVertexProjectID rejects project IDs that don't match GCP's documented shape.
// Defense-in-depth: values come from admin-authenticated input (config, env, or Settings JSONB)
// and are interpolated into the endpoint URL — a malformed value could escape the intended host.
func validateVertexProjectID(id string) error {
if !vertexProjectIDRe.MatchString(id) {
return fmt.Errorf("vertex: invalid project_id %q (expected 6-30 lowercase letters/digits/hyphens starting with a letter)", id)
}
return nil
}
// validateVertexRegion rejects region strings that don't match GCP's documented shape.
func validateVertexRegion(region string) error {
if !vertexRegionRe.MatchString(region) {
return fmt.Errorf("vertex: invalid region %q (expected lowercase hyphen-separated alphanum, e.g. us-central1)", region)
}
return nil
}
// validateVertexAPIBaseOverride sanity-checks an explicit API base URL when provided.
// Belt-and-suspenders defense: `validateProviderURL` in internal/http runs at CRUD time,
// but a DB row inserted via migration or direct SQL can bypass that path.
// We require https + a Google-looking Vertex hostname to prevent data exfiltration
// (messages going to an attacker-controlled server while auth goes to Google).
func validateVertexAPIBaseOverride(base string) error {
u, err := url.Parse(base)
if err != nil {
return fmt.Errorf("vertex: invalid api_base_override %q: %w", base, err)
}
if u.Scheme != "https" {
return fmt.Errorf("vertex: api_base_override must use https scheme, got %q", u.Scheme)
}
host := strings.ToLower(u.Hostname())
if !strings.HasSuffix(host, "aiplatform.googleapis.com") && !strings.HasSuffix(host, ".googleapis.com") {
return fmt.Errorf("vertex: api_base_override host %q is not a googleapis.com endpoint", host)
}
return nil
}
// NewVertexProvider constructs an OpenAIProvider pre-configured for Google Cloud Vertex AI.
// Uses oauth2.Transport for automatic token refresh (1-hour access tokens) — no manual refresh needed.
// The returned provider speaks OpenAI ChatCompletions format against Vertex's OpenAI-compatible endpoint.
func NewVertexProvider(ctx context.Context, cfg VertexConfig) (*OpenAIProvider, error) {
if cfg.ProjectID == "" {
return nil, fmt.Errorf("vertex: project_id is required")
}
if cfg.Region == "" {
return nil, fmt.Errorf("vertex: region is required")
}
if err := validateVertexProjectID(cfg.ProjectID); err != nil {
return nil, err
}
if err := validateVertexRegion(cfg.Region); err != nil {
return nil, err
}
if override := strings.TrimSpace(cfg.APIBaseOverride); override != "" {
if err := validateVertexAPIBaseOverride(override); err != nil {
return nil, err
}
}
tokenSource, err := resolveVertexTokenSource(ctx, cfg)
if err != nil {
return nil, err
}
// ReuseTokenSource caches the current token in-memory until expiry (~1 hour),
// then transparently fetches a fresh one. No extra work for callers.
cached := oauth2.ReuseTokenSource(nil, tokenSource)
// Use NewDefaultTransport as the oauth2 base transport so Vertex requests
// inherit the same per-stage timeouts as other providers. SSRF defense is
// provided by validateVertexAPIBaseOverride, which enforces the googleapis.com
// host constraint at provider-creation time.
client := &http.Client{
Timeout: DefaultHTTPTimeout,
Transport: &oauth2.Transport{
Source: cached,
Base: NewDefaultTransport(),
},
}
apiBase := strings.TrimSpace(cfg.APIBaseOverride)
if apiBase == "" {
apiBase = VertexDefaultAPIBase(cfg.ProjectID, cfg.Region)
}
defaultModel := cfg.DefaultModel
if defaultModel == "" {
defaultModel = VertexDefaultModel
}
name := cfg.Name
if name == "" {
name = "vertex"
}
// apiKey is intentionally empty — oauth2.Transport injects Authorization from the TokenSource.
// WithoutAuthHeader ensures doRequest() doesn't overwrite that with a "Bearer " header.
prov := NewOpenAIProvider(name, "", apiBase, defaultModel).
WithProviderType(ProviderTypeVertex).
WithHTTPClient(client).
WithoutAuthHeader()
return prov, nil
}
// resolveVertexTokenSource returns a GCP TokenSource using the first available credential source:
// inline JSON → file path → Application Default Credentials.
func resolveVertexTokenSource(ctx context.Context, cfg VertexConfig) (oauth2.TokenSource, error) {
scope := VertexDefaultScope
if data := strings.TrimSpace(cfg.CredentialsJSON); data != "" {
creds, err := google.CredentialsFromJSON(ctx, []byte(data), scope)
if err != nil {
return nil, fmt.Errorf("vertex: parse inline credentials: %w", err)
}
return creds.TokenSource, nil
}
if path := strings.TrimSpace(cfg.CredentialsFile); path != "" {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("vertex: read credentials file: %w", err)
}
creds, err := google.CredentialsFromJSON(ctx, data, scope)
if err != nil {
return nil, fmt.Errorf("vertex: parse credentials file %q: %w", path, err)
}
return creds.TokenSource, nil
}
// ADC: GOOGLE_APPLICATION_CREDENTIALS env, ~/.config/gcloud/..., or GCE metadata server.
creds, err := google.FindDefaultCredentials(ctx, scope)
if err != nil {
return nil, fmt.Errorf("vertex: application default credentials not found (set GOOGLE_APPLICATION_CREDENTIALS, provide credentials_file, or run on GCP): %w", err)
}
return creds.TokenSource, nil
}
// vertexInitTimeout caps credential discovery time so ADC on non-GCP machines
// doesn't stall gateway startup waiting for the metadata server.
const vertexInitTimeout = 10 * time.Second
// NewVertexProviderWithTimeout wraps NewVertexProvider with a bounded context.
// Recommended for startup-time registration where slow metadata lookups must not block boot.
func NewVertexProviderWithTimeout(cfg VertexConfig) (*OpenAIProvider, error) {
ctx, cancel := context.WithTimeout(context.Background(), vertexInitTimeout)
defer cancel()
return NewVertexProvider(ctx, cfg)
}