Restrict HTTP file artifact inputs

This commit is contained in:
2026-07-04 23:34:44 +00:00
parent 0d45ac6e3c
commit 5c882f26a9
14 changed files with 415 additions and 18 deletions

View File

@@ -74,10 +74,11 @@ type renderConfig struct {
type serveConfig struct {
configPath string
addr string
promptDir string
profileDir string
schemaDir string
addr string
promptDir string
profileDir string
schemaDir string
artifactRoot string
}
type commonCommandSettings struct {
@@ -85,6 +86,7 @@ type commonCommandSettings struct {
profileDir string
schemaDir string
serverAddr string
artifactRoot string
defaultRenderFormat renderformat.PreparedRunOutputFormat
}
@@ -202,7 +204,13 @@ func serveCommand(args []string, stderr io.Writer) int {
return ExitRuntimeError
}
runner := newRunner(cfg.promptDir, cfg.profileDir, cfg.schemaDir, llmClient)
artifactReader, err := artifactadapter.NewRestrictedCompositeReader(cfg.artifactRoot)
if err != nil {
fmt.Fprintf(stderr, "artifact root error: %v\n", err)
return ExitRuntimeError
}
runner := newRunnerWithArtifactReader(cfg.promptDir, cfg.profileDir, cfg.schemaDir, llmClient, artifactReader)
h := httpadapter.NewHandler(runner)
srv := &http.Server{
@@ -281,6 +289,7 @@ func parseServeArgs(args []string) (*serveConfig, error) {
fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definition YAML files")
fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profile YAML files")
fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas")
fs.StringVar(&cfg.artifactRoot, "artifact-root", "", "base directory for HTTP file input artifacts")
if err := fs.Parse(args); err != nil {
return nil, err
@@ -290,10 +299,11 @@ func parseServeArgs(args []string) (*serveConfig, error) {
}
settings, err := resolveCommonSettings(fs, cfg.configPath, appconfig.CLIOverrides{
PromptDir: cfg.promptDirIfSet(fs),
ProfileDir: cfg.profileDirIfSet(fs),
SchemaDir: cfg.schemaDirIfSet(fs),
ServerAddr: cfg.addrIfSet(fs),
PromptDir: cfg.promptDirIfSet(fs),
ProfileDir: cfg.profileDirIfSet(fs),
SchemaDir: cfg.schemaDirIfSet(fs),
ServerAddr: cfg.addrIfSet(fs),
ArtifactRoot: cfg.artifactRootIfSet(fs),
})
if err != nil {
return nil, err
@@ -303,6 +313,7 @@ func parseServeArgs(args []string) (*serveConfig, error) {
cfg.profileDir = settings.profileDir
cfg.schemaDir = settings.schemaDir
cfg.addr = settings.serverAddr
cfg.artifactRoot = settings.artifactRoot
if err := validateRequiredLibraryDirs(cfg.promptDir); err != nil {
return nil, err
@@ -313,6 +324,9 @@ func parseServeArgs(args []string) (*serveConfig, error) {
cfg.profileDir = filepath.Clean(cfg.profileDir)
}
cfg.schemaDir = filepath.Clean(cfg.schemaDir)
if strings.TrimSpace(cfg.artifactRoot) != "" {
cfg.artifactRoot = filepath.Clean(cfg.artifactRoot)
}
return cfg, nil
}
@@ -429,6 +443,13 @@ func (c *serveConfig) addrIfSet(fs *flag.FlagSet) string {
return ""
}
func (c *serveConfig) artifactRootIfSet(fs *flag.FlagSet) string {
if flagWasSet(fs, "artifact-root") {
return c.artifactRoot
}
return ""
}
func registerConfigPathFlag(fs *flag.FlagSet, target *string) {
fs.StringVar(
target,
@@ -466,6 +487,7 @@ func resolveCommonSettings(fs *flag.FlagSet, configPath string, overrides appcon
profileDir: settings.ProfileDir,
schemaDir: settings.SchemaDir,
serverAddr: settings.ServerAddr,
artifactRoot: settings.ArtifactRoot,
defaultRenderFormat: settings.DefaultRenderFormat,
}, nil
}
@@ -478,10 +500,17 @@ func validateRequiredLibraryDirs(promptDir string) error {
}
func newRunner(promptDir, profileDir, schemaDir string, llmClient llm.Client) *usecase.Runner {
return newRunnerWithArtifactReader(promptDir, profileDir, schemaDir, llmClient, artifactadapter.NewCompositeReader())
}
func newRunnerWithArtifactReader(promptDir, profileDir, schemaDir string, llmClient llm.Client, artifactReader artifactadapter.Reader) *usecase.Runner {
if artifactReader == nil {
artifactReader = artifactadapter.NewCompositeReader()
}
return usecase.NewRunner(
promptdef.NewFilesystemRepository(promptDir),
builtin.NewRepositoryWithDirectory(profileDir),
artifactadapter.NewCompositeReader(),
artifactReader,
prompt.NewGoRenderer(),
llmClient,
validate.NewStandardValidator(schemaDir),

View File

@@ -435,11 +435,13 @@ profile_dir: ./from-config/profiles
schema_dir: ./from-config/schemas
server:
addr: 127.0.0.1:9000
artifact_root: ./from-config/artifacts
`)
cfg, err := parseServeArgs([]string{
"--config", configPath,
"--addr", ":7777",
"--artifact-root", "./from-cli/artifacts",
})
if err != nil {
t.Fatalf("expected valid args, got %v", err)
@@ -457,6 +459,9 @@ server:
if cfg.addr != ":7777" {
t.Fatalf("expected CLI addr override, got %q", cfg.addr)
}
if cfg.artifactRoot != filepath.Clean("./from-cli/artifacts") {
t.Fatalf("expected CLI artifact root override, got %q", cfg.artifactRoot)
}
}
func TestParseServeArgsWithConfigProvidesRequiredDirectoriesAndAddr(t *testing.T) {
@@ -466,6 +471,7 @@ profile_dir: ./from-config/profiles
schema_dir: ./from-config/schemas
server:
addr: 127.0.0.1:9000
artifact_root: ./from-config/artifacts
`)
cfg, err := parseServeArgs([]string{
@@ -487,6 +493,9 @@ server:
if cfg.addr != "127.0.0.1:9000" {
t.Fatalf("expected addr from config, got %q", cfg.addr)
}
if cfg.artifactRoot != filepath.Clean("./from-config/artifacts") {
t.Fatalf("expected artifact root from config, got %q", cfg.artifactRoot)
}
}
func TestRunAndRenderBuildEquivalentRuntimeOverrideRequestsForSharedFlags(t *testing.T) {

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"strings"
"gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
@@ -187,6 +188,8 @@ func mapRunError(err error) (int, string, string) {
return http.StatusBadRequest, "prompt_load_failed", "failed to load prompt definition"
case errors.Is(err, usecase.ErrProfileLoad):
return http.StatusBadRequest, "profile_load_failed", "failed to load execution profile"
case errors.Is(err, artifact.ErrFileNotAllowed), errors.Is(err, artifact.ErrFileOutsideRoot):
return http.StatusBadRequest, "artifact_not_allowed", "file input artifact is not allowed"
case errors.Is(err, usecase.ErrArtifactLoad):
return http.StatusBadRequest, "artifact_read_failed", "failed to read input artifact"
case errors.Is(err, usecase.ErrPromptRender):

View File

@@ -7,11 +7,14 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"gitea.maximumdirect.net/eric/scriptorium/internal/llm"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
@@ -61,6 +64,12 @@ func (handlerRenderer) Render(ctx context.Context, definition *domain.PromptDefi
return &domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}}, nil
}
type handlerLLMClient struct{}
func (handlerLLMClient) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
return &domain.GenerateResponse{Content: "ok"}, nil
}
func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
start := time.Now().UTC()
end := start.Add(2 * time.Second)
@@ -184,6 +193,87 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
}
}
func TestHandlerInlineRefsWorkWithoutArtifactRoot(t *testing.T) {
h := newArtifactRootHandler(t, "")
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
"prompt_id":"p",
"inputs":{"x":{"type":"inline","body":"inline body"}}
}`))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
}
func TestHandlerFileRefsWithoutArtifactRootAreRejected(t *testing.T) {
h := newArtifactRootHandler(t, "")
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
"prompt_id":"p",
"inputs":{"x":{"type":"file","uri":"input.txt"}}
}`))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assertHTTPErrorCode(t, w, http.StatusBadRequest, "artifact_not_allowed")
}
func TestHandlerFileRefsUnderArtifactRootWork(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "input.txt"), []byte("allowed"), 0o644); err != nil {
t.Fatal(err)
}
h := newArtifactRootHandler(t, root)
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
"prompt_id":"p",
"inputs":{"x":{"type":"file","uri":"input.txt"}}
}`))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
}
func TestHandlerFileRefsOutsideArtifactRootAreRejected(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("denied"), 0o644); err != nil {
t.Fatal(err)
}
h := newArtifactRootHandler(t, root)
tests := []struct {
name string
uri string
}{
{name: "relative traversal", uri: filepath.Join("..", filepath.Base(outside), "secret.txt")},
{name: "absolute outside root", uri: filepath.Join(outside, "secret.txt")},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body := fmt.Sprintf(`{
"prompt_id":"p",
"inputs":{"x":{"type":"file","uri":%q}}
}`, tc.uri)
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(body))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assertHTTPErrorCode(t, w, http.StatusBadRequest, "artifact_not_allowed")
})
}
}
func TestHandlerPostRunsSuccessUsingPromptDefaultProfile(t *testing.T) {
r := &fakeRunner{result: &domain.RunResult{
Artifact: domain.Artifact{Body: []byte("ok")},
@@ -676,3 +766,48 @@ func TestHandlerValidationFailureStillSuccessAndRawOutputOptIn(t *testing.T) {
func wrap(stage error, cause error) error {
return fmt.Errorf("%w: %w", stage, cause)
}
func newArtifactRootHandler(t *testing.T, root string) *Handler {
t.Helper()
reader, err := artifact.NewRestrictedCompositeReader(root)
if err != nil {
t.Fatalf("expected restricted artifact reader: %v", err)
}
runner := usecase.NewRunner(
handlerPromptRepo{def: &domain.PromptDefinition{
ID: "p",
Version: "1",
DefaultProfile: "exec",
Templates: []domain.PromptMessageTemplate{{Role: "user", Content: "hi"}},
OutputFormat: domain.FormatText,
Validation: domain.OutputContract{Format: domain.FormatText, ValidationMode: domain.ValidationNone},
}},
handlerProfileRepo{profile: &domain.ExecutionProfile{
ID: "exec",
Endpoint: "http://example.invalid/v1",
Model: "model",
}},
reader,
handlerRenderer{},
handlerLLMClient{},
nil,
)
return NewHandler(runner)
}
func assertHTTPErrorCode(t *testing.T, w *httptest.ResponseRecorder, status int, code string) {
t.Helper()
if w.Code != status {
t.Fatalf("expected %d, got %d body=%s", status, w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
errBody := resp["error"].(map[string]any)
if errBody["code"] != code {
t.Fatalf("expected code %q, got %#v", code, errBody["code"])
}
}