fix(security): close IDOR gaps on /admin/test-token and /orgs/:id/allowlist

Fixes audit #125 findings for CWE-639:

1. admin_test_token.go — CRITICAL IDOR (finding #112)
   When ADMIN_TOKEN is set in production, require it explicitly on
   GET /admin/workspaces/:id/test-token. The original gap: AdminAuth
   accepted any valid org-scoped token, letting an Org A token holder
   mint workspace bearer tokens for ANY workspace UUID they could enumerate.
   Now requires ADMIN_TOKEN when it's configured; MOLECULE_ENV!=production
   path still requires a valid bearer (any org token works for local dev).

2. org_plugin_allowlist.go — HIGH IDOR (finding #112)
   GET and PUT /orgs/:id/plugins/allowlist: add requireOrgOwnership()
   check after org existence verification. Org-token holders can only
   read/write their own org's allowlist. Session and ADMIN_TOKEN callers
   bypass the check (they have platform-wide access via the session
   cookie path, not org tokens).

Closes: #112 (CWE-639 IDOR — tenant config access)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Molecule AI · cp-be 2026-04-20 23:28:43 +00:00
parent acf03cd057
commit 84ff572588
2 changed files with 83 additions and 0 deletions

View File

@ -18,10 +18,12 @@
package handlers
import (
"crypto/subtle"
"database/sql"
"log"
"net/http"
"os"
"strings"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
@ -55,6 +57,20 @@ func (h *AdminTestTokenHandler) GetTestToken(c *gin.Context) {
return
}
// IDOR fix (#112, CRITICAL): when ADMIN_TOKEN is set, require it
// explicitly. Org-scoped tokens and session cookies must not grant
// access — the original gap was that AdminAuth accepted any bearer
// that matched a live org token, allowing cross-org token minting.
adminSecret := os.Getenv("ADMIN_TOKEN")
if adminSecret != "" {
tok := c.GetHeader("Authorization")
tok = strings.TrimPrefix(tok, "Bearer ")
if tok == "" || subtle.ConstantTimeCompare([]byte(tok), []byte(adminSecret)) != 1 {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "admin auth required"})
return
}
}
workspaceID := c.Param("id")
if workspaceID == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})

View File

@ -105,6 +105,61 @@ type putAllowlistRequest struct {
EnabledBy string `json:"enabled_by"` // workspace ID of the admin performing the change
}
// requireCallerOwnsOrg returns the caller's org workspace ID from the
// request context, or "" if the caller is not an org-token holder.
// Used to enforce org isolation on org-scoped routes — the org_token_id
// is the org-scoped token's ID (not the org workspace ID), so we look
// it up via the created_by workspace or a direct relationship.
//
// Returns ("", nil) when the caller is a session/ADMIN_TOKEN user (they
// bypass via the session cookie path or ADMIN_TOKEN, not org tokens).
func requireCallerOwnsOrg(c *gin.Context) (string, error) {
tokenID, ok := c.Get("org_token_id")
if !ok {
return "", nil // not an org-token caller — caller is session/admin
}
tokID, ok := tokenID.(string)
if !ok || tokID == "" {
return "", nil
}
// Look up the org workspace that owns this token.
// org_api_tokens has no org_id column, but we can look up the token's
// created_by workspace and treat that as the caller's org anchor.
var createdBy string
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT created_by FROM org_api_tokens WHERE id = $1`, tokID,
).Scan(&createdBy)
if err != nil || createdBy == "" {
// Token has no created_by (CLI bootstrap path) — treat as unowned;
// deny by default to prevent cross-org access.
return "", fmt.Errorf("token has no org anchor")
}
return createdBy, nil
}
// requireOrgOwnership verifies the caller has authority over the target org.
// Returns 403 and abandons the request if the caller is an org-token holder
// whose org does not match targetOrgID.
func requireOrgOwnership(c *gin.Context, targetOrgID string) bool {
callerOrg, err := requireCallerOwnsOrg(c)
if err != nil {
log.Printf("allowlist: requireOrgOwnership: %v", err)
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "org access denied"})
return false
}
// callerOrg "" means session/admin user — they have full access (no
// org token → full platform admin via session/ADMIN_TOKEN path).
if callerOrg == "" {
return true
}
if callerOrg != targetOrgID {
log.Printf("allowlist: org-token org %s tried to access org %s (denied)", callerOrg, targetOrgID)
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "org access denied"})
return false
}
return true
}
// GetAllowlist handles GET /orgs/:id/plugins/allowlist.
//
// Returns the current allowlist for the org workspace identified by :id.
@ -128,6 +183,13 @@ func (h *OrgPluginAllowlistHandler) GetAllowlist(c *gin.Context) {
return
}
// IDOR fix (#112, HIGH): org-token holders must only access their own org.
// requireOrgOwnership denies cross-org access (403) while letting session
// and ADMIN_TOKEN callers through.
if !requireOrgOwnership(c, orgID) {
return
}
rows, err := db.DB.QueryContext(ctx, `
SELECT plugin_name, enabled_by, enabled_at
FROM org_plugin_allowlist
@ -210,6 +272,11 @@ func (h *OrgPluginAllowlistHandler) PutAllowlist(c *gin.Context) {
return
}
// IDOR fix (#112, HIGH): same as GetAllowlist — require org ownership.
if !requireOrgOwnership(c, orgID) {
return
}
// Replace atomically: delete all current entries, then insert the new set.
tx, err := db.DB.BeginTx(ctx, nil)
if err != nil {