Four findings from the security audit on PR #641: FIX 1 (MEDIUM): import_url scheme validation - Reject non-HTTPS import URLs with 400 before forwarding to CF API. Prevents SSRF via http://, git://, ssh://, file:// etc. FIX 2 (MEDIUM): CF 5xx error leakage - Add cfErrMessage() helper: returns "upstream service error" for CF 5xx responses and non-CF errors, passes through 4xx messages. - Applied at all four CF-error response sites (Create, Get, Fork, Token). FIX 3 (LOW): repo name validation - Add package-level repoNameRE = ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$ - Validate in Create and Fork handlers when caller supplies an explicit name. Auto-generated names ("molecule-ws-<id>") are always safe and skip validation. FIX 4 (LOW): response body size limit in CF client - Wrap resp.Body with io.LimitReader(1 MB) before json.NewDecoder in do(). Prevents memory exhaustion from a runaway/malicious CF response. Tests: 16 new tests covering all four fixes (cfErrMessage 4xx/5xx/non-API, import_url non-HTTPS cases, invalid repo names in Create and Fork). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
283 lines
9.8 KiB
Go
283 lines
9.8 KiB
Go
// Package artifacts provides a minimal Go client for the Cloudflare Artifacts
|
|
// REST API (private beta Apr 2026, public beta May 2026).
|
|
//
|
|
// API reference: https://developers.cloudflare.com/artifacts/api/rest-api/
|
|
// Blog post: https://blog.cloudflare.com/artifacts-git-for-agents-beta/
|
|
//
|
|
// Base URL: https://artifacts.cloudflare.net/v1/api/namespaces/{namespace}
|
|
// Auth: Authorization: Bearer <CLOUDFLARE_API_TOKEN>
|
|
//
|
|
// This client covers the subset of operations needed for the Molecule AI
|
|
// workspace-snapshot demo:
|
|
// - CreateRepo — provision a bare Git repo for a workspace
|
|
// - GetRepo — fetch repo metadata (remote URL, created_at, …)
|
|
// - ForkRepo — create an isolated copy (e.g. workspace branching)
|
|
// - ImportRepo — bootstrap from an external GitHub/GitLab URL
|
|
// - DeleteRepo — clean-up
|
|
// - CreateToken — mint a short-lived Git credential for clone/push
|
|
// - RevokeToken — invalidate an issued token
|
|
package artifacts
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultBaseURL = "https://artifacts.cloudflare.net/v1/api"
|
|
defaultTimeout = 30 * time.Second
|
|
)
|
|
|
|
// Client is a thin HTTP wrapper around the Cloudflare Artifacts REST API.
|
|
// Instantiate with New(); override BaseURL in tests via NewWithBaseURL().
|
|
type Client struct {
|
|
baseURL string // e.g. https://artifacts.cloudflare.net/v1/api/namespaces/my-ns
|
|
apiToken string // Cloudflare API token — never logged
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// New returns a Client scoped to the given namespace.
|
|
// apiToken is a Cloudflare API token with Artifacts write permissions.
|
|
// namespace identifies the CF Artifacts namespace (maps to CLOUDFLARE_ARTIFACTS_NAMESPACE).
|
|
func New(apiToken, namespace string) *Client {
|
|
return NewWithBaseURL(apiToken, namespace, defaultBaseURL)
|
|
}
|
|
|
|
// NewWithBaseURL is the same as New but lets callers override the base URL —
|
|
// primarily used in unit tests to point at an httptest.Server.
|
|
func NewWithBaseURL(apiToken, namespace, baseURL string) *Client {
|
|
ns := url.PathEscape(namespace)
|
|
return &Client{
|
|
baseURL: fmt.Sprintf("%s/namespaces/%s", baseURL, ns),
|
|
apiToken: apiToken,
|
|
httpClient: &http.Client{
|
|
Timeout: defaultTimeout,
|
|
},
|
|
}
|
|
}
|
|
|
|
// ---- Domain types --------------------------------------------------------
|
|
|
|
// Repo represents a single Cloudflare Artifacts repository.
|
|
type Repo struct {
|
|
// Name is the user-supplied identifier within the namespace.
|
|
Name string `json:"name"`
|
|
// ID is the opaque CF-assigned identifier.
|
|
ID string `json:"id,omitempty"`
|
|
// RemoteURL is the authenticated Git remote in the form
|
|
// https://x:<TOKEN>@<hash>.artifacts.cloudflare.net/git/repo-<id>.git
|
|
RemoteURL string `json:"remote_url,omitempty"`
|
|
// ReadOnly marks repos that accept only fetch/clone operations.
|
|
ReadOnly bool `json:"read_only,omitempty"`
|
|
// Description is an optional human-readable label.
|
|
Description string `json:"description,omitempty"`
|
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
|
}
|
|
|
|
// ForkResult is the response body from POST /repos/:name/fork.
|
|
type ForkResult struct {
|
|
Repo Repo `json:"repo"`
|
|
ObjectCount int `json:"object_count,omitempty"`
|
|
}
|
|
|
|
// RepoToken is a short-lived credential for Git operations against a single repo.
|
|
// The plaintext Token value is returned only once — callers must store it.
|
|
type RepoToken struct {
|
|
ID string `json:"id"`
|
|
Token string `json:"token"`
|
|
Scope string `json:"scope"` // "read" | "write"
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
|
|
// ---- Request payloads ----------------------------------------------------
|
|
|
|
// CreateRepoRequest is the body for POST /repos.
|
|
type CreateRepoRequest struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
DefaultBranch string `json:"default_branch,omitempty"`
|
|
ReadOnly bool `json:"read_only,omitempty"`
|
|
}
|
|
|
|
// ForkRepoRequest is the body for POST /repos/:name/fork.
|
|
type ForkRepoRequest struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
ReadOnly bool `json:"read_only,omitempty"`
|
|
DefaultBranchOnly bool `json:"default_branch_only,omitempty"`
|
|
}
|
|
|
|
// ImportRepoRequest is the body for POST /repos/:name/import.
|
|
type ImportRepoRequest struct {
|
|
// URL is the HTTPS URL of the source Git repository.
|
|
URL string `json:"url"`
|
|
Branch string `json:"branch,omitempty"`
|
|
Depth int `json:"depth,omitempty"`
|
|
ReadOnly bool `json:"read_only,omitempty"`
|
|
}
|
|
|
|
// CreateTokenRequest is the body for POST /tokens.
|
|
type CreateTokenRequest struct {
|
|
// Repo is the name of the repository to scope the token to.
|
|
Repo string `json:"repo"`
|
|
Scope string `json:"scope,omitempty"` // "read" | "write"; default "write"
|
|
// TTL is the lifetime in seconds. Default 86400 (24h).
|
|
TTL int `json:"ttl,omitempty"`
|
|
}
|
|
|
|
// ---- API error -----------------------------------------------------------
|
|
|
|
// APIError represents a non-2xx response from the Cloudflare v4 envelope.
|
|
type APIError struct {
|
|
StatusCode int
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
return fmt.Sprintf("cloudflare artifacts: HTTP %d — code %d: %s", e.StatusCode, e.Code, e.Message)
|
|
}
|
|
|
|
// ---- HTTP helpers --------------------------------------------------------
|
|
|
|
// do executes an HTTP request, checks the Cloudflare v4 envelope, and
|
|
// JSON-decodes the "result" field into out (pass nil to discard).
|
|
func (c *Client) do(ctx context.Context, method, path string, body, out interface{}) error {
|
|
var bodyReader io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("artifacts: marshal request: %w", err)
|
|
}
|
|
bodyReader = bytes.NewReader(b)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
|
|
if err != nil {
|
|
return fmt.Errorf("artifacts: build request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.apiToken)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("artifacts: request %s %s: %w", method, path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Decode the Cloudflare v4 envelope. Cap at 1 MiB to prevent a
|
|
// malicious or runaway upstream response from exhausting memory.
|
|
var envelope struct {
|
|
Result json.RawMessage `json:"result"`
|
|
Success bool `json:"success"`
|
|
Errors []struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"errors"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&envelope); err != nil {
|
|
// Non-JSON body (network error page, etc.)
|
|
return &APIError{StatusCode: resp.StatusCode, Message: fmt.Sprintf("non-JSON body (status %d)", resp.StatusCode)}
|
|
}
|
|
|
|
if !envelope.Success || resp.StatusCode >= 300 {
|
|
apiErr := &APIError{StatusCode: resp.StatusCode}
|
|
if len(envelope.Errors) > 0 {
|
|
apiErr.Code = envelope.Errors[0].Code
|
|
apiErr.Message = envelope.Errors[0].Message
|
|
} else {
|
|
apiErr.Message = "unknown error"
|
|
}
|
|
return apiErr
|
|
}
|
|
|
|
if out != nil && len(envelope.Result) > 0 {
|
|
if err := json.Unmarshal(envelope.Result, out); err != nil {
|
|
return fmt.Errorf("artifacts: decode result: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- Repo operations -----------------------------------------------------
|
|
|
|
// CreateRepo provisions a new bare Git repo in the namespace.
|
|
// Corresponds to POST /repos.
|
|
func (c *Client) CreateRepo(ctx context.Context, req CreateRepoRequest) (*Repo, error) {
|
|
var repo Repo
|
|
if err := c.do(ctx, http.MethodPost, "/repos", req, &repo); err != nil {
|
|
return nil, err
|
|
}
|
|
return &repo, nil
|
|
}
|
|
|
|
// GetRepo fetches metadata for an existing repo.
|
|
// Corresponds to GET /repos/:name.
|
|
func (c *Client) GetRepo(ctx context.Context, name string) (*Repo, error) {
|
|
var repo Repo
|
|
path := "/repos/" + url.PathEscape(name)
|
|
if err := c.do(ctx, http.MethodGet, path, nil, &repo); err != nil {
|
|
return nil, err
|
|
}
|
|
return &repo, nil
|
|
}
|
|
|
|
// ForkRepo creates an isolated copy of an existing repo.
|
|
// Corresponds to POST /repos/:name/fork.
|
|
func (c *Client) ForkRepo(ctx context.Context, sourceName string, req ForkRepoRequest) (*ForkResult, error) {
|
|
var result ForkResult
|
|
path := "/repos/" + url.PathEscape(sourceName) + "/fork"
|
|
if err := c.do(ctx, http.MethodPost, path, req, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// ImportRepo bootstraps a repo from an external HTTPS Git URL.
|
|
// Corresponds to POST /repos/:name/import.
|
|
func (c *Client) ImportRepo(ctx context.Context, name string, req ImportRepoRequest) (*Repo, error) {
|
|
var repo Repo
|
|
path := "/repos/" + url.PathEscape(name) + "/import"
|
|
if err := c.do(ctx, http.MethodPost, path, req, &repo); err != nil {
|
|
return nil, err
|
|
}
|
|
return &repo, nil
|
|
}
|
|
|
|
// DeleteRepo deletes a repo (returns 202 Accepted).
|
|
// Corresponds to DELETE /repos/:name.
|
|
func (c *Client) DeleteRepo(ctx context.Context, name string) error {
|
|
path := "/repos/" + url.PathEscape(name)
|
|
return c.do(ctx, http.MethodDelete, path, nil, nil)
|
|
}
|
|
|
|
// ---- Token operations ----------------------------------------------------
|
|
|
|
// CreateToken mints a short-lived Git credential scoped to a single repo.
|
|
// The plaintext token is in the returned RepoToken.Token field — it will not
|
|
// be available again after this call returns.
|
|
// Corresponds to POST /tokens.
|
|
func (c *Client) CreateToken(ctx context.Context, req CreateTokenRequest) (*RepoToken, error) {
|
|
var token RepoToken
|
|
if err := c.do(ctx, http.MethodPost, "/tokens", req, &token); err != nil {
|
|
return nil, err
|
|
}
|
|
return &token, nil
|
|
}
|
|
|
|
// RevokeToken invalidates an issued token by its ID.
|
|
// Corresponds to DELETE /tokens/:id.
|
|
func (c *Client) RevokeToken(ctx context.Context, tokenID string) error {
|
|
path := "/tokens/" + url.PathEscape(tokenID)
|
|
return c.do(ctx, http.MethodDelete, path, nil, nil)
|
|
}
|