forked from molecule-ai/molecule-core
Merge pull request #1885 from Molecule-AI/fix/ki005-security-clean
[P0] fix(security): F1085/KI-005/CWE-78 — clean rebase onto staging
This commit is contained in:
commit
50ae33e8b3
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@ -142,7 +142,8 @@ jobs:
|
||||
|
||||
# Strip the package-import prefix so we can match .coverage-allowlist.txt
|
||||
# entries written as paths relative to workspace-server/.
|
||||
rel=$(echo "$file" | sed 's|^github.com/Molecule-AI/molecule-monorepo/platform/||')
|
||||
# Handle both module paths: platform/workspace-server/... and platform/...
|
||||
rel=$(echo "$file" | sed 's|^github.com/Molecule-AI/molecule-monorepo/platform/workspace-server/||; s|^github.com/Molecule-AI/molecule-monorepo/platform/||')
|
||||
|
||||
if echo "$ALLOWLIST" | grep -qxF "$rel"; then
|
||||
echo "::warning file=workspace-server/$rel::Critical file at ${pct}% coverage (allowlisted, #1823) — fix before expiry."
|
||||
|
||||
@ -8,6 +8,20 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ssrfCheckEnabled controls whether isSafeURL performs real validation.
|
||||
// Tests disable it via setSSRFCheckForTest so that httptest.NewServer
|
||||
// loopback URLs and fake hostnames (*.example) don't trigger SSRF
|
||||
// rejections. Production code never mutates this.
|
||||
var ssrfCheckEnabled = true
|
||||
|
||||
// setSSRFCheckForTest overrides ssrfCheckEnabled for the duration of a test
|
||||
// and returns a restore function. Use with defer in *_test.go only.
|
||||
func setSSRFCheckForTest(enabled bool) func() {
|
||||
prev := ssrfCheckEnabled
|
||||
ssrfCheckEnabled = enabled
|
||||
return func() { ssrfCheckEnabled = prev }
|
||||
}
|
||||
|
||||
// isSafeURL validates that a URL resolves to a publicly-routable address,
|
||||
// preventing A2A requests from being redirected to internal/cloud-metadata
|
||||
// infrastructure (SSRF, CWE-918). Workspace URLs come from DB/Redis caches
|
||||
@ -18,6 +32,9 @@ import (
|
||||
// the same VPC and register by their VPC-private IP. Metadata endpoints,
|
||||
// loopback, link-local, and TEST-NET stay blocked in every mode.
|
||||
func isSafeURL(rawURL string) error {
|
||||
if !ssrfCheckEnabled {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
@ -168,8 +185,20 @@ func mustCIDR(s string) net.IPNet {
|
||||
// the destination via absolute paths or ".." traversal. Used by
|
||||
// copyFilesToContainer and deleteViaEphemeral as a defence-in-depth measure.
|
||||
func validateRelPath(filePath string) error {
|
||||
// Reject empty string and dot-only paths before any processing.
|
||||
if filePath == "" || filePath == "." {
|
||||
return fmt.Errorf("empty or dot-only path not allowed")
|
||||
}
|
||||
clean := filepath.Clean(filePath)
|
||||
if filepath.IsAbs(clean) || strings.Contains(clean, "..") {
|
||||
// Reject absolute paths (Unix / or Windows C:\).
|
||||
if filepath.IsAbs(clean) {
|
||||
return fmt.Errorf("path traversal or absolute path not allowed: %s", filePath)
|
||||
}
|
||||
// Reject any path containing ".." anywhere — check both raw and cleaned
|
||||
// because filepath.Clean resolves ".." upward (e.g. "foo/../bar" → "bar"
|
||||
// and "foo/.." → ".") which would make the check pass if only clean were checked.
|
||||
// We only want explicitly-named files; ".." implies intent to escape.
|
||||
if strings.Contains(filePath, "..") || strings.Contains(clean, "..") {
|
||||
return fmt.Errorf("path traversal or absolute path not allowed: %s", filePath)
|
||||
}
|
||||
return nil
|
||||
|
||||
@ -292,8 +292,7 @@ func (h *TemplatesHandler) ReadFile(c *gin.Context) {
|
||||
|
||||
// Try container first
|
||||
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
|
||||
containerPath := rootPath + "/" + filePath
|
||||
content, err := h.execInContainer(ctx, containerName, []string{"cat", containerPath})
|
||||
content, err := h.execInContainer(ctx, containerName, []string{"cat", rootPath, filePath})
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"path": filePath,
|
||||
|
||||
@ -75,17 +75,25 @@ func (h *TerminalHandler) HandleConnect(c *gin.Context) {
|
||||
// also reach Workspace B's terminal if it knows B's UUID (enumeration
|
||||
// via canvas, logs, or delegation). Shell access is more dangerous than
|
||||
// A2A message-passing, so we apply the same hierarchy check here.
|
||||
// GH#756/#1609 security fix: if the caller claims a specific workspace
|
||||
// identity (X-Workspace-ID header), the bearer token — if present — must
|
||||
// belong to that claimed workspace. ValidateAnyToken accepted ANY valid org
|
||||
// token, allowing Workspace A to forge X-Workspace-ID: B and reach B's
|
||||
// terminal if A held any valid token. ValidateToken binds the token to
|
||||
// the claimed workspace identity.
|
||||
callerID := c.GetHeader("X-Workspace-ID")
|
||||
if callerID != "" {
|
||||
if callerID != "" && callerID != workspaceID {
|
||||
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
|
||||
if tok != "" {
|
||||
if err := wsauth.ValidateAnyToken(ctx, db.DB, tok); err == nil {
|
||||
if !canCommunicateCheck(callerID, workspaceID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not authorized to access this workspace's terminal"})
|
||||
return
|
||||
}
|
||||
if err := wsauth.ValidateToken(ctx, db.DB, callerID, tok); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token for claimed workspace"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if !canCommunicateCheck(callerID, workspaceID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not authorized to access this workspace's terminal"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check for CP-provisioned workspace (instance_id persisted by
|
||||
|
||||
@ -73,16 +73,15 @@ func TestTerminalConnect_KI005_RejectsUnauthorizedCrossWorkspace(t *testing.T) {
|
||||
canCommunicateCheck = func(callerID, targetID string) bool { return false }
|
||||
defer func() { canCommunicateCheck = prev }()
|
||||
|
||||
// Token lookup: ws-caller's token is valid. ValidateAnyToken uses
|
||||
// workspace_auth_tokens + a JOIN on workspaces to filter out removed
|
||||
// rows; an older version of this test expected "workspace_tokens"
|
||||
// (outdated table name) and got 503 Docker-unavailable because the
|
||||
// token validation silently failed before the CanCommunicate check.
|
||||
rows := sqlmock.NewRows([]string{"id"}).AddRow("tok-1")
|
||||
mock.ExpectQuery(`SELECT t\.id\s+FROM workspace_auth_tokens t`).
|
||||
// Token lookup: ws-caller's token is valid. ValidateToken (GH#756) uses
|
||||
// workspace_auth_tokens + a JOIN on workspaces to bind the token to its
|
||||
// owning workspace_id. The mock returns both id and workspace_id matching
|
||||
// the callerID so that ValidateToken confirms the token belongs to ws-caller.
|
||||
rows := sqlmock.NewRows([]string{"id", "workspace_id"}).AddRow("tok-1", "ws-caller")
|
||||
mock.ExpectQuery(`SELECT t\.id, t\.workspace_id\s+FROM workspace_auth_tokens t`).
|
||||
WithArgs(sqlmock.AnyArg()).
|
||||
WillReturnRows(rows)
|
||||
// ValidateAnyToken also fires a best-effort last_used_at UPDATE after
|
||||
// ValidateToken fires a best-effort last_used_at UPDATE after
|
||||
// successful validation. Accept it so ExpectationsWereMet passes.
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens SET last_used_at`).
|
||||
WithArgs(sqlmock.AnyArg()).
|
||||
@ -207,9 +206,11 @@ func TestTerminalConnect_KI005_SkipsCheckWithoutHeader(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestTerminalConnect_KI005_RejectsInvalidToken tests that an invalid bearer
|
||||
// token also results in a non-200 response (falls through to Docker auth).
|
||||
// ValidateAnyToken returns error → CanCommunicate is never called.
|
||||
// token when X-Workspace-ID is set results in 401 Unauthorized.
|
||||
// ValidateToken returns ErrInvalidToken (no matching DB row) → 401, CanCommunicate
|
||||
// is never reached.
|
||||
func TestTerminalConnect_KI005_RejectsInvalidToken(t *testing.T) {
|
||||
setupTestDB(t) // provides a mock DB; no expectations set → ValidateToken query returns error
|
||||
canCommunicateCalled := false
|
||||
prev := canCommunicateCheck
|
||||
canCommunicateCheck = func(callerID, targetID string) bool {
|
||||
@ -231,16 +232,19 @@ func TestTerminalConnect_KI005_RejectsInvalidToken(t *testing.T) {
|
||||
if canCommunicateCalled {
|
||||
t.Error("CanCommunicate should not be called with an invalid token")
|
||||
}
|
||||
// Got 503 (nil docker) instead of 200/403 — ValidateAnyToken rejected the
|
||||
// token and we fell through to Docker auth, which returned 503 (nil docker).
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("invalid token: got %d, want 503 nil-docker (%s)", w.Code, w.Body.String())
|
||||
// ValidateToken returns ErrInvalidToken (token not in DB or bound to wrong workspace).
|
||||
// HandleConnect returns 401 Unauthorized — does NOT fall through to Docker.
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("invalid token: got %d, want 401 Unauthorized (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTerminalConnect_KI005_AllowsSiblingWorkspace tests the sibling path:
|
||||
// two workspaces with the same parent ID should be allowed to communicate.
|
||||
// ValidateToken must succeed (token bound to ws-pm) and CanCommunicate must
|
||||
// return true before we fall through to the Docker path.
|
||||
func TestTerminalConnect_KI005_AllowsSiblingWorkspace(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
prev := canCommunicateCheck
|
||||
canCommunicateCheck = func(callerID, targetID string) bool {
|
||||
// Simulate sibling: same parent
|
||||
@ -248,17 +252,27 @@ func TestTerminalConnect_KI005_AllowsSiblingWorkspace(t *testing.T) {
|
||||
}
|
||||
defer func() { canCommunicateCheck = prev }()
|
||||
|
||||
// ValidateToken: token is bound to ws-pm (the callerID). Returns id + workspace_id.
|
||||
rows := sqlmock.NewRows([]string{"id", "workspace_id"}).AddRow("tok-pm", "ws-pm")
|
||||
mock.ExpectQuery(`SELECT t\.id, t\.workspace_id\s+FROM workspace_auth_tokens t`).
|
||||
WithArgs(sqlmock.AnyArg()).
|
||||
WillReturnRows(rows)
|
||||
// Best-effort last_used_at UPDATE.
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens SET last_used_at`).
|
||||
WithArgs(sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewTerminalHandler(nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-dev"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-dev/terminal", nil)
|
||||
c.Request.Header.Set("X-Workspace-ID", "ws-pm")
|
||||
c.Request.Header.Set("Authorization", "Bearer valid-token")
|
||||
c.Request.Header.Set("Authorization", "Bearer valid-token-for-ws-pm")
|
||||
|
||||
h.HandleConnect(c)
|
||||
|
||||
// CanCommunicate returned true → reached Docker path → 503 nil-docker
|
||||
// ValidateToken passed + CanCommunicate=true → reached Docker path → 503 nil-docker.
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("sibling access: got %d, want 503 nil-docker (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
@ -212,13 +212,11 @@ func TestWorkspaceAuth_OrgToken_DBRowScanError_DoesNotPanic(t *testing.T) {
|
||||
orgToken := "tok_token_ok"
|
||||
tokenHash := sha256.Sum256([]byte(orgToken))
|
||||
|
||||
// Single-round-trip Validate: returns NULL org_id (stands in for the
|
||||
// scan-error case the original test was exercising; the secondary hop
|
||||
// it mimicked no longer exists).
|
||||
// orgtoken.Validate returns 3 columns including org_id (sql.NullString).
|
||||
mock.ExpectQuery(orgTokenValidateQuery).
|
||||
WithArgs(tokenHash[:]).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "org_id"}).
|
||||
AddRow("tok-ok", "tok_tok_", nil))
|
||||
AddRow("tok-ok", "tok_tok_", "00000000-0000-0000-0000-000000000099"))
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/workspaces/:id/secrets", WorkspaceAuth(mockDB), func(c *gin.Context) {
|
||||
|
||||
@ -473,12 +473,15 @@ func TestAdminAuth_InvalidBearer_Returns401(t *testing.T) {
|
||||
// token (org_id="ws-org-1").
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// orgTokenValidateQueryV1 is matched for orgtoken.Validate(). Post
|
||||
// migration-036 the query returns id + prefix + org_id in a single
|
||||
// round-trip (the `::text` cast was dropped once the column landed as
|
||||
// text-comparable).
|
||||
// orgTokenValidateQueryV1 is matched for orgtoken.Validate().
|
||||
// NOTE: must match the actual Validate() query: "SELECT id, prefix, org_id FROM org_api_tokens"
|
||||
// (no ::text cast — sql.NullString handles the NULL scan natively).
|
||||
const orgTokenValidateQueryV1 = "SELECT id, prefix, org_id FROM org_api_tokens"
|
||||
|
||||
// orgTokenOrgIDQuery is deprecated — org_id is now returned by the primary Validate query.
|
||||
// Kept here to avoid breaking other test files that may reference it.
|
||||
const orgTokenOrgIDQuery = "SELECT org_id::text FROM org_api_tokens"
|
||||
|
||||
// orgTokenLastUsedQuery is matched for the best-effort last_used_at UPDATE.
|
||||
const orgTokenLastUsedQuery = "UPDATE org_api_tokens SET last_used_at"
|
||||
|
||||
|
||||
@ -145,7 +145,7 @@ func TestList_NewestFirst(t *testing.T) {
|
||||
|
||||
now := time.Now()
|
||||
earlier := now.Add(-1 * time.Hour)
|
||||
mock.ExpectQuery(`SELECT id, prefix.*FROM org_api_tokens.*ORDER BY created_at DESC( LIMIT $1)?`).
|
||||
mock.ExpectQuery(`SELECT id, prefix.*FROM org_api_tokens.*ORDER BY created_at DESC`).
|
||||
WithArgs(listMax).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "name", "org_id", "created_by", "created_at", "last_used_at"}).
|
||||
AddRow("t2", "abcd1234", "zapier", "org-1", "user_01", now, now).
|
||||
|
||||
Loading…
Reference in New Issue
Block a user