Files
Duy /zuey/andGitHub 532ff91d8e fix(security): harden upstream critical surfaces (#32)
* fix(security): harden upstream critical surfaces

Refs #30

* fix(security): close pre-landing review gaps

Refs #30

* fix(security): close official release blockers
2026-05-20 16:33:49 +07:00

249 lines
8.1 KiB
Go

package security
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"log/slog"
"path/filepath"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// blockedEnvKeys is the set of environment variable names that are always rejected.
// These can be used for privilege escalation, path hijacking, or leaking GoClaw internals.
// Keys are checked after NFKC normalization to prevent Unicode bypass.
var blockedEnvKeys = map[string]bool{
"LD_PRELOAD": true,
"LD_LIBRARY_PATH": true,
"PATH": true,
"DYLD_INSERT_LIBRARIES": true,
}
// allowlistEntry is a cached allowlist for one workstation.
type allowlistEntry struct {
patterns []string // enabled binary name patterns
fetchedAt time.Time
}
// AllowlistChecker validates exec requests against a per-workstation binary allowlist.
// Architecture:
// - C1 fix: argv-exec model — cmd is the binary name (argv[0]), not a shell command string.
// Shell injection is impossible because the SSH backend never invokes sh -c.
// - C2 fix: NFKC normalization applied to cmd and each arg before any check.
// - Default-deny: if no enabled pattern matches cmd's binary name → deny.
// - Cache: allowlist loaded from DB with configurable TTL (default 30s).
// Event-driven invalidation via Invalidate() called on permission changes.
type AllowlistChecker struct {
permStore store.WorkstationPermissionStore
cacheTTL time.Duration
mu sync.Mutex
cache map[uuid.UUID]*allowlistEntry // keyed by workstation ID
}
// NewAllowlistChecker creates an AllowlistChecker with the given store and cache TTL.
// Typical TTL: 30s (balances freshness vs. DB load).
func NewAllowlistChecker(permStore store.WorkstationPermissionStore, cacheTTL time.Duration) *AllowlistChecker {
return &AllowlistChecker{
permStore: permStore,
cacheTTL: cacheTTL,
cache: make(map[uuid.UUID]*allowlistEntry),
}
}
// Invalidate evicts the cached allowlist for workstationID.
// Call this when permissions are added, removed, or toggled for that workstation.
func (c *AllowlistChecker) Invalidate(workstationID uuid.UUID) {
c.mu.Lock()
delete(c.cache, workstationID)
c.mu.Unlock()
}
// Check validates cmd (argv[0]) + args against workstation policy.
//
// Pipeline:
// 1. NFKC normalize cmd and each arg (collapses Unicode lookalikes)
// 2. Reject NUL bytes and CRLF in cmd or any arg (unsafe in all contexts)
// 3. Allowlist match on binary name (default-deny)
//
// Env-key validation (LD_PRELOAD, PATH, GOCLAW_*, etc.) is handled
// separately by CheckEnv, called in the tool wiring layer.
func (c *AllowlistChecker) Check(
ctx context.Context,
ws *store.Workstation,
cmd string,
args []string,
) error {
locale := store.LocaleFromContext(ctx)
// ── Step 1: NFKC normalize ───────────────────────────────────────────────
// C2 fix: must happen before ANY matching or byte-level validation.
cmd = NormalizeCmd(cmd)
for i, a := range args {
args[i] = NormalizeCmd(a)
}
// ── Step 2: byte-level safety (NUL / CRLF) ──────────────────────────────
if containsDangerousBytes(cmd) {
c.auditDeny(ws, cmd, "dangerous_bytes_in_cmd")
return fmt.Errorf("%s", i18n.T(locale, i18n.MsgWorkstationInputInvalid, "NUL or CRLF in command"))
}
for i, a := range args {
if containsDangerousBytes(a) {
c.auditDeny(ws, cmd, "dangerous_bytes_in_arg")
return fmt.Errorf("%s", i18n.T(locale, i18n.MsgWorkstationInputInvalid,
fmt.Sprintf("NUL or CRLF in arg[%d]", i)))
}
}
// ── Step 3: binary allowlist (default-deny) ──────────────────────────────
// Extract the binary name (basename of cmd, strip path).
// e.g. "/usr/bin/git" → "git", "python3" → "python3"
binaryName := filepath.Base(cmd)
if binaryName == "" || binaryName == "." {
c.auditDeny(ws, cmd, "empty_binary_name")
return errors.New(i18n.T(locale, i18n.MsgWorkstationCmdDenied, "empty binary name"))
}
if reason := validateLauncherArgs(binaryName, args); reason != "" {
c.auditDeny(ws, cmd, reason)
return errors.New(i18n.T(locale, i18n.MsgWorkstationCmdDenied, reason))
}
patterns, err := c.loadAllowlist(ctx, ws.ID)
if err != nil {
return fmt.Errorf("load allowlist: %w", err)
}
matched := false
for _, pat := range patterns {
if MatchAllowedBinary(pat, binaryName) {
matched = true
break
}
}
if !matched {
c.auditDeny(ws, cmd, "no_allowlist_match")
return errors.New(i18n.T(locale, i18n.MsgWorkstationCmdDenied,
"no allowlist match for: "+binaryName))
}
return nil
}
// CheckEnv validates environment variable keys against the blocklist.
// Called separately so the tool layer can report specific key names.
// Keys are NFKC-normalized before comparison.
func (c *AllowlistChecker) CheckEnv(ctx context.Context, ws *store.Workstation, env map[string]string) error {
locale := store.LocaleFromContext(ctx)
for k := range env {
normalized := NormalizeCmd(k)
if isBlockedEnvKey(normalized) {
c.auditDeny(ws, normalized, "blocked_env_key")
return errors.New(i18n.T(locale, i18n.MsgWorkstationEnvDenied, k))
}
}
return nil
}
// MatchAllowedBinary returns true if pattern matches the binary name.
//
// Matching rules (argv[0] binary name, NOT full command string):
// - Exact match: "git" matches "git"
// - Prefix glob: "python*" matches "python3", "python3.11", "python"
// - No catch-all: "*" alone is rejected as too permissive — returns false
//
// This is intentionally simple. Matching only the binary name is safe because:
// - Shell injection requires a shell; the SSH backend uses argv exec (no sh -c).
// - Argument validation is the remote shell's / OS's responsibility once the
// binary is allowed.
func MatchAllowedBinary(pattern, binaryName string) bool {
// Reject the lone wildcard — it would allow everything including shells.
if pattern == "*" {
return false
}
// Exact match (most common case).
if pattern == binaryName {
return true
}
// Prefix glob: "python*" matches "python3", "python3.11".
if before, ok := strings.CutSuffix(pattern, "*"); ok {
prefix := before
return prefix != "" && strings.HasPrefix(binaryName, prefix)
}
return false
}
// isBlockedEnvKey returns true if the (NFKC-normalized) key should be rejected.
func isBlockedEnvKey(k string) bool {
if blockedEnvKeys[k] {
return true
}
// Block all GOCLAW_* keys to prevent leaking gateway internals.
return strings.HasPrefix(k, "GOCLAW_")
}
func validateLauncherArgs(binaryName string, args []string) string {
switch binaryName {
case "env", "nohup", "setsid", "timeout", "nice", "stdbuf", "xargs":
if len(args) > 0 {
return "launcher command with arguments denied: " + binaryName
}
}
return ""
}
// loadAllowlist returns the enabled binary name patterns for workstationID.
// Results are cached for cacheTTL; evicted by Invalidate().
func (c *AllowlistChecker) loadAllowlist(ctx context.Context, workstationID uuid.UUID) ([]string, error) {
c.mu.Lock()
entry, ok := c.cache[workstationID]
if ok && time.Since(entry.fetchedAt) < c.cacheTTL {
patterns := entry.patterns
c.mu.Unlock()
return patterns, nil
}
c.mu.Unlock()
// Fetch from DB (outside lock to avoid holding lock during I/O).
perms, err := c.permStore.ListForWorkstation(ctx, workstationID)
if err != nil {
return nil, err
}
var patterns []string
for _, p := range perms {
if p.Enabled {
patterns = append(patterns, p.Pattern)
}
}
c.mu.Lock()
c.cache[workstationID] = &allowlistEntry{
patterns: patterns,
fetchedAt: time.Now(),
}
c.mu.Unlock()
return patterns, nil
}
// auditDeny emits a structured security log entry on every deny.
// cmd_hash (not plaintext) is logged for PII/secret hygiene.
func (c *AllowlistChecker) auditDeny(ws *store.Workstation, cmd, reason string) {
hash := sha256.Sum256([]byte(cmd))
slog.Warn("security.workstation_cmd_denied",
"workstation_id", ws.ID,
"tenant_id", ws.TenantID,
"cmd_hash", fmt.Sprintf("%x", hash[:6]),
"reason", reason,
)
}