mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 16:15:24 +00:00
* 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>
215 lines
8.8 KiB
Go
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)
|
|
}
|