mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
4f0984bb15
* feat(artifact): server export + import endpoints for playbooks & conventions
Phase 2 of PLAN-1867. Adds:
- GET /workspaces/{ws}/items/{ref}/export — item-visibility-gated; encodes a
playbook/convention item to a Markdown+frontmatter artifact.
- POST /workspaces/{ws}/import-artifact — editor-gated; byte-capped +
YAML-bomb-guarded parse, forgiving preprocess (foreign selects blanked,
invocation_slug de-collided, status forced draft), creates via the shared
create path.
- Extracts createItemChecked from handleCreateItem so import inherits
validation / uniqueness / edit-perm / side-effects (no direct store.CreateItem).
- PAD_IMPORT_ARTIFACT_MAX_BYTES env override.
Server validation, coercion, and YAML input limits land at the HTTP boundary
per DR-4/DR-7/DR-8 and the Codex P2 notes (collSlug via shared helper,
item-visibility export auth, byte-cap→node-walk→decode ordering).
Implements TASK-1871, TASK-1872, TASK-1873, TASK-1874.
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
* fix(artifact): enforce item quota + require title on artifact import
Addresses Codex Phase-2 review:
- P1: handleImportArtifact now calls enforcePlanLimit(items_per_workspace)
before create, matching handleCreateItem — imports can't exceed the plan cap.
- P2: reject empty/whitespace-only artifact titles with 400 (Title is required),
matching the normal create path.
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
197 lines
7.0 KiB
Go
197 lines
7.0 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"go.yaml.in/yaml/v4"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/artifact"
|
|
)
|
|
|
|
// defaultImportArtifactMaxBytes caps a single artifact import body. A
|
|
// playbook/convention artifact is a small Markdown file (frontmatter + a
|
|
// few KB of body); 1 MiB is several orders of magnitude above any real
|
|
// artifact while still cheap to hold in memory. Overridable via
|
|
// Server.SetImportArtifactMaxBytes (PAD_IMPORT_ARTIFACT_MAX_BYTES).
|
|
const defaultImportArtifactMaxBytes int64 = 1 << 20 // 1 MiB
|
|
|
|
// YAML-bomb guard limits. The frontmatter region is parsed into a yaml.Node
|
|
// tree and walked BEFORE any struct decode so a malicious artifact can't
|
|
// blow up memory/CPU via billion-laughs (alias expansion), deeply-nested
|
|
// collections, or a huge flat node count.
|
|
const (
|
|
// maxFrontmatterDepth bounds nesting depth. A real artifact's
|
|
// frontmatter is shallow (top-level map + the arguments sequence of
|
|
// small maps → depth ~4). 32 is generous headroom that still stops
|
|
// pathological deep-nesting documents.
|
|
maxFrontmatterDepth = 32
|
|
|
|
// maxFrontmatterNodes bounds the total node count in the frontmatter
|
|
// tree. Defeats a flat document with an enormous number of keys/items
|
|
// designed to exhaust memory. A real artifact has well under 100 nodes.
|
|
maxFrontmatterNodes = 10000
|
|
|
|
// maxFrontmatterAliases bounds YAML alias nodes. The artifact format
|
|
// never legitimately uses anchors/aliases, so any alias is a billion-
|
|
// laughs signal — reject outright (cap of 0).
|
|
maxFrontmatterAliases = 0
|
|
)
|
|
|
|
// ErrArtifactTooLarge is returned when the request body exceeds the
|
|
// configured artifact size cap.
|
|
var ErrArtifactTooLarge = errors.New("artifact import: body exceeds size limit")
|
|
|
|
// ErrArtifactUnsafeYAML is returned when the frontmatter region trips one of
|
|
// the YAML-bomb guard limits (node count, nesting depth, or anchors/aliases).
|
|
var ErrArtifactUnsafeYAML = errors.New("artifact import: frontmatter rejected by safety limits")
|
|
|
|
// parseArtifactRequest is the guarded HTTP-boundary parse used by the import
|
|
// handler. It applies three checks IN ORDER:
|
|
//
|
|
// 1. Byte cap on the raw body (http.MaxBytesReader), so an oversized body is
|
|
// rejected before full materialization.
|
|
// 2. YAML-bomb guard: the frontmatter region is parsed into a yaml.Node tree
|
|
// and walked, enforcing maxFrontmatterNodes / maxFrontmatterDepth /
|
|
// maxFrontmatterAliases. This runs BEFORE the struct decode so an alias-
|
|
// storm or deep-nesting document never reaches the expanding unmarshaler.
|
|
// 3. artifact.Decode, which produces the typed Artifact.
|
|
//
|
|
// Returns the decoded Artifact or a typed error: ErrArtifactTooLarge,
|
|
// ErrArtifactUnsafeYAML, or an artifact.* sentinel (ErrMalformed /
|
|
// ErrUnknownKind / ErrUnsupportedVersion) wrapped for context. The import
|
|
// handler maps these to HTTP statuses.
|
|
func parseArtifactRequest(w http.ResponseWriter, r *http.Request, maxBytes int64) (artifact.Artifact, error) {
|
|
if maxBytes <= 0 {
|
|
maxBytes = defaultImportArtifactMaxBytes
|
|
}
|
|
|
|
// (1) Byte cap. MaxBytesReader makes ReadAll return an error once the
|
|
// cap is exceeded, so we never materialize an oversized body.
|
|
limited := http.MaxBytesReader(w, r.Body, maxBytes)
|
|
data, err := io.ReadAll(limited)
|
|
if err != nil {
|
|
// http.MaxBytesReader surfaces a *http.MaxBytesError when the cap
|
|
// is hit; any read error here means the body is too big or broken.
|
|
var mbErr *http.MaxBytesError
|
|
if errors.As(err, &mbErr) {
|
|
return artifact.Artifact{}, ErrArtifactTooLarge
|
|
}
|
|
return artifact.Artifact{}, fmt.Errorf("artifact import: read body: %w", err)
|
|
}
|
|
|
|
// (2) YAML-bomb guard on the frontmatter region only.
|
|
if err := guardArtifactFrontmatter(data); err != nil {
|
|
return artifact.Artifact{}, err
|
|
}
|
|
|
|
// (3) Typed decode.
|
|
art, err := artifact.Decode(data)
|
|
if err != nil {
|
|
return artifact.Artifact{}, err
|
|
}
|
|
return art, nil
|
|
}
|
|
|
|
// guardArtifactFrontmatter extracts the leading "---\n...\n---" frontmatter
|
|
// region from the artifact bytes and walks it as a yaml.Node tree to enforce
|
|
// the YAML-bomb limits. It deliberately re-parses just the frontmatter (not
|
|
// the whole document) so the guard runs before artifact.Decode's struct
|
|
// unmarshal — the unmarshal is where alias expansion would otherwise blow up.
|
|
//
|
|
// A missing/malformed fence is NOT rejected here; that's left to
|
|
// artifact.Decode so the caller gets the canonical ErrMalformed. The guard
|
|
// only fires on a present-but-dangerous frontmatter.
|
|
func guardArtifactFrontmatter(data []byte) error {
|
|
fm, ok := extractFrontmatterRegion(string(data))
|
|
if !ok {
|
|
// No parseable fence — defer to artifact.Decode for the canonical
|
|
// malformed-frontmatter error.
|
|
return nil
|
|
}
|
|
|
|
var root yaml.Node
|
|
if err := yaml.Unmarshal([]byte(fm), &root); err != nil {
|
|
// Unparseable YAML — defer to artifact.Decode, which wraps it as
|
|
// ErrMalformed. The node-tree unmarshal does not expand aliases
|
|
// (that happens at the typed-decode step), so this is safe.
|
|
return nil
|
|
}
|
|
|
|
var nodes, aliases int
|
|
if err := walkArtifactNode(&root, 0, &nodes, &aliases); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// walkArtifactNode recursively walks a yaml.Node tree enforcing the depth,
|
|
// node-count, and alias limits. It increments *nodes per visited node and
|
|
// *aliases per AliasNode, and returns ErrArtifactUnsafeYAML on the first
|
|
// breach.
|
|
func walkArtifactNode(n *yaml.Node, depth int, nodes, aliases *int) error {
|
|
if n == nil {
|
|
return nil
|
|
}
|
|
if depth > maxFrontmatterDepth {
|
|
return fmt.Errorf("%w: nesting depth exceeds %d", ErrArtifactUnsafeYAML, maxFrontmatterDepth)
|
|
}
|
|
|
|
*nodes++
|
|
if *nodes > maxFrontmatterNodes {
|
|
return fmt.Errorf("%w: node count exceeds %d", ErrArtifactUnsafeYAML, maxFrontmatterNodes)
|
|
}
|
|
|
|
// Reject anchors and aliases. The artifact format never uses them, so
|
|
// their presence is a billion-laughs signal. Counting both an anchored
|
|
// node's definition and any alias referencing it keeps the cap tight.
|
|
if n.Anchor != "" || n.Kind == yaml.AliasNode {
|
|
*aliases++
|
|
if *aliases > maxFrontmatterAliases {
|
|
return fmt.Errorf("%w: anchors/aliases are not allowed", ErrArtifactUnsafeYAML)
|
|
}
|
|
}
|
|
|
|
for _, child := range n.Content {
|
|
if err := walkArtifactNode(child, depth+1, nodes, aliases); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// extractFrontmatterRegion returns the YAML text between the leading "---\n"
|
|
// fence and the next line equal to "---". Returns ("", false) when the
|
|
// opening or closing fence is absent. CRLF is normalized to LF first so the
|
|
// guard sees the same canonical text artifact.Decode does.
|
|
func extractFrontmatterRegion(s string) (string, bool) {
|
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
|
const fence = "---"
|
|
if !strings.HasPrefix(s, fence+"\n") {
|
|
return "", false
|
|
}
|
|
rest := s[len(fence)+1:]
|
|
|
|
offset := 0
|
|
for {
|
|
line := rest[offset:]
|
|
nl := strings.IndexByte(line, '\n')
|
|
var cur string
|
|
if nl >= 0 {
|
|
cur = line[:nl]
|
|
} else {
|
|
cur = line
|
|
}
|
|
if strings.TrimRight(cur, " \t\r") == fence {
|
|
return rest[:offset], true
|
|
}
|
|
if nl < 0 {
|
|
return "", false
|
|
}
|
|
offset += nl + 1
|
|
}
|
|
}
|