Files
pad/internal/server/handlers_export_bundle.go
xarmian b633144009 fix(server): deny workspace export to restricted owners (BUG-1922) (#813)
Workspace export (both the JSON and tar.gz bundle forms of GET
/workspaces/{slug}/export) streamed the full unfiltered workspace
regardless of the caller's collection_access, letting a restricted
owner exfiltrate collections hidden from them. Per Dave's ruling,
export is a backup/portability affordance rather than a
visibility-scoped view, so a restricted caller is denied outright
(403) instead of receiving a filtered subset.
2026-07-04 13:54:08 -04:00

314 lines
11 KiB
Go

package server
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
)
// exportBundleVersion pins the on-disk bundle layout. Bumped if the
// internal structure changes in a way the import path can't handle
// transparently. Independent of WorkspaceExport.Version so we can
// evolve the JSON schema and the bundle format on different cadences.
const exportBundleVersion = 1
// requireUnrestrictedExportAccess denies workspace export (both the JSON
// and tar.gz bundle forms) to a caller whose workspace access is scoped to
// specific collections (BUG-1922). Dave's ruling: workspace export is an
// owner affordance for backup/portability, not a visibility-scoped view —
// so rather than filtering the export contents to what the caller can see
// (which would silently produce incomplete backups), a restricted caller
// is denied the export outright. member_collection_access has no role
// exclusion (see BUG-1920), so a workspace-role "owner" can still be
// restricted; requireMinRole("owner") alone doesn't catch that.
//
// Must be called after requireMinRole(w, r, "owner") has already passed,
// and before any export content is read from the store or written to the
// response — a 403 here must never follow partial output.
func (s *Server) requireUnrestrictedExportAccess(w http.ResponseWriter, r *http.Request, workspaceID string) bool {
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return false
}
if visibleIDs != nil {
writeError(w, http.StatusForbidden, "forbidden", "Workspace export requires unrestricted workspace access")
return false
}
return true
}
// handleExportWorkspaceBundle streams a tar.gz containing the
// workspace JSON export plus every original (non-thumbnail)
// attachment blob and a manifest. Bundle layout:
//
// pad-export.json
// attachments/manifest.json
// attachments/<uuid>.<ext>
//
// Tar entries are written in a deterministic order so byte-identical
// workspaces produce byte-identical bundles (modulo `exported_at`):
//
// 1. pad-export.json
// 2. attachments/manifest.json
// 3. attachments/<uuid>.<ext> — sorted by (created_at, id) via the
// store's ORDER BY in WorkspaceAttachmentsForExport.
//
// We stream chunks straight to the response writer rather than
// buffering — a workspace with multi-GB of attachments would otherwise
// pin that much memory for the duration of the download.
//
// On error mid-stream the connection is dropped (the client sees a
// truncated tar that gunzip will fail to decompress); that's the
// least-bad option since headers + early bytes are already on the
// wire. The error is logged with attachment_id context so operators
// can diagnose without re-running the export.
//
// Auth: owner, and requireUnrestrictedExportAccess further denies a
// restricted owner (BUG-1922). The plain JSON path is owner-only too; the
// bundle has the same access scope plus the user-uploaded attachment
// blobs, so don't loosen.
func (s *Server) handleExportWorkspaceBundle(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
ws, ok := s.getWorkspace(w, r)
if !ok {
return
}
if !s.requireUnrestrictedExportAccess(w, r, ws.ID) {
return
}
export, err := s.store.ExportWorkspace(ws.Slug)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
// Build the manifest before writing anything to the response so a
// store error doesn't strand half a tar header. Streaming the
// blobs themselves still happens after we commit to the response.
attachments, err := s.store.WorkspaceAttachmentsForExport(ws.ID)
if err != nil {
writeInternalError(w, err)
return
}
// If attachments exist, the registry must be wired — otherwise
// the blobs aren't reachable and the bundle would be a lie.
if len(attachments) > 0 && s.attachments == nil {
writeError(w, http.StatusServiceUnavailable, "attachments_disabled",
"Attachment storage is not configured on this server")
return
}
manifest := models.AttachmentManifest{
Version: exportBundleVersion,
Entries: make([]models.AttachmentManifestEntry, 0, len(attachments)),
}
for _, a := range attachments {
entry := models.AttachmentManifestEntry{
ID: a.ID,
Filename: a.Filename,
MIME: a.MimeType,
SizeBytes: a.SizeBytes,
ContentHash: a.ContentHash,
Width: a.Width,
Height: a.Height,
UploadedBy: a.UploadedBy,
CreatedAt: a.CreatedAt.UTC().Format(time.RFC3339),
}
if a.ItemID != nil {
entry.ItemID = *a.ItemID
}
// ParentID + Variant stay empty for shipped entries — derived
// rows are filtered out in WorkspaceAttachmentsForExport.
manifest.Entries = append(manifest.Entries, entry)
}
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="%s-export.tar.gz"`, ws.Slug))
// Bundles are streamed; we don't know the final size up front. No
// Content-Length header — http.Server falls through to chunked
// transfer-encoding, which the gzip+tar pair handles fine.
//
// X-Bundle-Status is an HTTP trailer that CLI clients check after
// streaming — without it, mid-stream errors are invisible because
// headers + the first tar entries are already on the wire. The
// trailer is set to "ok" only when every entry wrote cleanly;
// otherwise the client treats the file as corrupt and discards it.
// Codex caught the silent-corruption gap on PR #305 round 3.
w.Header().Set("Trailer", BundleStatusTrailer)
gzw := gzip.NewWriter(w)
tw := tar.NewWriter(gzw)
// Track streaming success. On error we want the gzip stream to
// terminate WITHOUT a clean trailer so a client that ignores the
// HTTP trailer still detects corruption via gunzip failure.
// Closing tw/gzw flushes the gzip trailer (CRC + size); skipping
// those calls leaves the gzip footer unwritten and the client's
// gzip reader returns ErrUnexpectedEOF.
streamOK := false
defer func() {
if !streamOK {
// Mid-stream failure path. Don't write a clean gzip
// trailer — CLI clients will see an io error reading
// the gzip stream AND a missing X-Bundle-Status:ok
// trailer. Either signal is sufficient on its own.
return
}
if err := tw.Close(); err != nil {
s.logBundleStreamError(r.Context(), "tar close", err)
return
}
if err := gzw.Close(); err != nil {
s.logBundleStreamError(r.Context(), "gzip close", err)
return
}
// Only mark the bundle ok in the trailer once every byte is
// flushed and both close calls returned without error.
w.Header().Set(BundleStatusTrailer, BundleStatusOK)
}()
// 1. pad-export.json
exportJSON, err := json.MarshalIndent(export, "", " ")
if err != nil {
s.logBundleStreamError(r.Context(), "marshal export", err)
return
}
if err := writeTarFile(tw, "pad-export.json", exportJSON); err != nil {
s.logBundleStreamError(r.Context(), "write pad-export.json", err)
return
}
// 2. attachments/manifest.json
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
s.logBundleStreamError(r.Context(), "marshal manifest", err)
return
}
if err := writeTarFile(tw, "attachments/manifest.json", manifestJSON); err != nil {
s.logBundleStreamError(r.Context(), "write manifest", err)
return
}
// 3. attachment blobs
for _, a := range attachments {
if err := s.streamAttachmentToTar(r.Context(), tw, &a); err != nil {
s.logBundleStreamError(r.Context(), "stream attachment", err,
"attachment_id", a.ID, "storage_key", a.StorageKey)
return
}
}
// All entries wrote cleanly. The deferred close + trailer set
// run after this returns; nothing else to do here.
streamOK = true
}
// BundleStatusTrailer is the HTTP response trailer that signals
// whether the export bundle stream completed without errors. A
// successful response sets it to BundleStatusOK; mid-stream errors
// leave it absent. Exported so the CLI can check after streaming.
const (
BundleStatusTrailer = "X-Bundle-Status"
BundleStatusOK = "ok"
)
// streamAttachmentToTar resolves the storage backend for one
// attachment row, writes a tar header sized to size_bytes, and copies
// the blob from the backend into the tar writer in 32 KiB chunks.
//
// Filename inside the tar is `attachments/<uuid><ext>` where ext
// comes from the original filename. Falls back to .bin when the
// upload had no extension. Using <uuid> avoids name collisions when
// two distinct attachments share the same display filename, which
// happens routinely with screenshots ("Screenshot 2025-...png").
func (s *Server) streamAttachmentToTar(ctx context.Context, tw *tar.Writer, a *models.Attachment) error {
store, err := s.attachments.Resolve(a.StorageKey)
if err != nil {
return fmt.Errorf("resolve storage backend: %w", err)
}
body, err := store.Get(ctx, a.StorageKey)
if err != nil {
return fmt.Errorf("get blob: %w", err)
}
defer body.Close()
hdr := &tar.Header{
Name: bundleAttachmentPath(a.ID, a.Filename),
Mode: 0o644,
Size: a.SizeBytes,
ModTime: a.CreatedAt.UTC(),
}
if err := tw.WriteHeader(hdr); err != nil {
return fmt.Errorf("tar header: %w", err)
}
n, err := io.Copy(tw, body)
if err != nil {
return fmt.Errorf("copy blob: %w", err)
}
// io.Copy on a backend that returns fewer bytes than expected
// would otherwise return nil and the tar writer would surface a
// "missed N bytes" error only at Close. Catch the truncation
// here so the per-blob log carries the attachment id +
// storage_key; the deferred tw.Close() then trips its own
// missed-bytes error which we already log.
if n != a.SizeBytes {
return fmt.Errorf("blob truncated: copied %d bytes, expected %d (size_bytes column out of sync with backend?)",
n, a.SizeBytes)
}
return nil
}
// bundleAttachmentPath builds the tar entry name for an attachment.
// Exported as a function (not a const helper) so the import path can
// import it and resolve manifest entries without duplicating the
// filename logic.
func bundleAttachmentPath(id, filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
if ext == "" {
ext = ".bin"
}
return "attachments/" + id + ext
}
// writeTarFile writes a single buffered file into the tar archive.
// Used for the small JSON entries (pad-export.json + manifest.json);
// blob entries stream through streamAttachmentToTar instead.
func writeTarFile(tw *tar.Writer, name string, data []byte) error {
hdr := &tar.Header{
Name: name,
Mode: 0o644,
Size: int64(len(data)),
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if _, err := tw.Write(data); err != nil {
return err
}
return nil
}
// logBundleStreamError logs at warn level with structured context.
// Mid-stream failures can't be turned into a clean HTTP error response
// (we've already started the response body), so the operator-facing
// log is the best we can do for diagnostics.
func (s *Server) logBundleStreamError(_ context.Context, op string, err error, kv ...any) {
args := append([]any{"op", op, "error", err}, kv...)
slog.Warn("export bundle stream failed", args...)
}