Clarify artifact root symlink behavior

This commit is contained in:
2026-07-05 00:22:54 +00:00
parent 6742def4d3
commit 872c166ed7
6 changed files with 49 additions and 17 deletions

View File

@@ -77,9 +77,9 @@ HTTP artifact root behavior:
- `server.artifact_root` applies only to `serve`. - `server.artifact_root` applies only to `serve`.
- HTTP `inline` input references work without an artifact root. - HTTP `inline` input references work without an artifact root.
- HTTP `file` input references are resolved against `server.artifact_root` and must stay inside it. - HTTP `file` input references are resolved against `server.artifact_root` with lexical path checks.
- Relative traversal and absolute paths outside the root are rejected. - Relative traversal and absolute paths that are lexically outside the root are rejected.
- Symlinks inside the root are followed by the operating system; do not make the artifact root writable by untrusted users. - Symlinks inside the root are followed by the operating system, including symlinks that point outside the root. Do not make the artifact root writable by untrusted users.
- CLI `run` and `render` file inputs keep their normal direct filesystem path behavior. - CLI `run` and `render` file inputs keep their normal direct filesystem path behavior.
HTTP size-limit behavior: HTTP size-limit behavior:
@@ -298,9 +298,9 @@ Rules:
- Invalid generated JSON causes validation status `failed` (not a runtime error). - Invalid generated JSON causes validation status `failed` (not a runtime error).
Supported artifact reference types for request inputs are `file` and `inline`. Supported artifact reference types for request inputs are `file` and `inline`.
For HTTP `serve`, `file` references require `server.artifact_root` and must stay For HTTP `serve`, `file` references require `server.artifact_root` and must pass
inside that root. CLI `run` and `render` file inputs are not restricted by lexical containment checks against that root. CLI `run` and `render` file inputs
`server.artifact_root`. are not restricted by `server.artifact_root`.
## Secrets Handling ## Secrets Handling

View File

@@ -71,10 +71,13 @@ Input reference types currently supported by runtime artifact loading:
- `inline` - `inline`
HTTP `file` references require `server.artifact_root` or `serve --artifact-root`. HTTP `file` references require `server.artifact_root` or `serve --artifact-root`.
Relative file URIs resolve inside that root. Absolute file URIs are accepted Relative file URIs resolve against that root. Absolute file URIs are accepted
only when they remain inside the root. Requests that escape the root, including only when they are lexically inside the root. Requests that escape the root by
`..` traversal and absolute paths outside the root, return lexical traversal, including `..` traversal and absolute paths outside the root,
`400 artifact_not_allowed`. `inline` references do not require an artifact root. return `400 artifact_not_allowed`. Symlinks inside the root are followed by the
operating system, including symlinks that point outside the root. The artifact
root must not be writable by untrusted users. `inline` references do not require
an artifact root.
HTTP file artifacts above the configured artifact limit return HTTP file artifacts above the configured artifact limit return
`413 artifact_too_large`. Inline bodies are bounded by the request body limit. `413 artifact_too_large`. Inline bodies are bounded by the request body limit.

View File

@@ -124,10 +124,10 @@ Artifact refs:
- Supported reference types: `inline`, `file`. - Supported reference types: `inline`, `file`.
- Unsupported types return `ErrUnsupportedRefType`. - Unsupported types return `ErrUnsupportedRefType`.
- CLI `run` and `render` use direct filesystem file reads for `file` references. - CLI `run` and `render` use direct filesystem file reads for `file` references.
- HTTP `serve` uses a restricted artifact reader: `inline` references work without a root, while `file` references require `server.artifact_root` or `--artifact-root` and must stay inside that root. - HTTP `serve` uses a restricted artifact reader: `inline` references work without a root, while `file` references require `server.artifact_root` or `--artifact-root` and must pass lexical containment checks against that root.
- HTTP `serve` applies request-body, file-artifact, and encoded-response size limits. CLI `run` and `render` do not use these HTTP limits. - HTTP `serve` applies request-body, file-artifact, and encoded-response size limits. CLI `run` and `render` do not use these HTTP limits.
- HTTP file paths are resolved with clean absolute paths and containment checks, not string-prefix checks. - HTTP file paths are resolved with clean absolute paths and lexical containment checks, not string-prefix checks.
- Symlinks inside the root are followed by the operating system; the configured root must not be writable by untrusted users. - Symlinks inside the root are followed by the operating system, including symlinks that point outside the root; the configured root must not be writable by untrusted users.
LLM adapter: LLM adapter:

View File

@@ -80,14 +80,14 @@ Current inbound API behavior:
- JSON request parsing rejects unknown fields. - JSON request parsing rejects unknown fields.
- Validation content failures still return `200 OK` with `validation.status: "failed"`. - Validation content failures still return `200 OK` with `validation.status: "failed"`.
- `inline` input references work without filesystem configuration. - `inline` input references work without filesystem configuration.
- `file` input references require `server.artifact_root` or `serve --artifact-root`; relative paths resolve inside that root and paths outside it are rejected. - `file` input references require `server.artifact_root` or `serve --artifact-root`; relative traversal and absolute paths that are lexically outside that root are rejected.
- Request bodies, HTTP file input artifacts, and encoded JSON responses are limited by `server.max_request_bytes`, `server.max_artifact_bytes`, and `server.max_response_bytes`. - Request bodies, HTTP file input artifacts, and encoded JSON responses are limited by `server.max_request_bytes`, `server.max_artifact_bytes`, and `server.max_response_bytes`.
Security caveat: Security caveat:
- `serve` has no built-in authentication or authorization. - `serve` has no built-in authentication or authorization.
- Deploy only behind trusted controls (private network boundary, authenticated reverse proxy, API gateway, or equivalent). - Deploy only behind trusted controls (private network boundary, authenticated reverse proxy, API gateway, or equivalent).
- Keep the HTTP artifact root as narrow as practical and do not make it writable by untrusted users. - Keep the HTTP artifact root as narrow as practical and do not make it writable by untrusted users. Symlinks inside the root are followed by the operating system, including symlinks that point outside the root.
Sizing guidance: Sizing guidance:

View File

@@ -158,14 +158,15 @@ func (r *restrictedFileReader) Read(ctx context.Context, ref domain.ArtifactRef)
return nil, ErrMissingFilePath return nil, ErrMissingFilePath
} }
path, err := r.resolve(ref.URI) path, err := r.resolveLexicalPath(ref.URI)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return readFileArtifactWithLimit(path, r.maxBytes) return readFileArtifactWithLimit(path, r.maxBytes)
} }
func (r *restrictedFileReader) resolve(rawPath string) (string, error) { // resolveLexicalPath checks cleaned path containment without resolving symlinks.
func (r *restrictedFileReader) resolveLexicalPath(rawPath string) (string, error) {
cleanPath := filepath.Clean(strings.TrimSpace(rawPath)) cleanPath := filepath.Clean(strings.TrimSpace(rawPath))
var candidate string var candidate string
if filepath.IsAbs(cleanPath) { if filepath.IsAbs(cleanPath) {

View File

@@ -112,6 +112,34 @@ func TestRestrictedCompositeReader(t *testing.T) {
}) })
} }
func TestRestrictedCompositeReaderFollowsSymlinkInsideRoot(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
outside := t.TempDir()
target := filepath.Join(outside, "linked.txt")
if err := os.WriteFile(target, []byte("linked outside root"), 0o644); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "linked.txt")
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlink creation unavailable: %v", err)
}
reader, err := NewRestrictedCompositeReader(root)
if err != nil {
t.Fatalf("expected restricted reader construction, got %v", err)
}
art, err := reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: "linked.txt"})
if err != nil {
t.Fatalf("expected symlink inside root to be followed, got %v", err)
}
if string(art.Body) != "linked outside root" {
t.Fatalf("unexpected artifact body: %q", string(art.Body))
}
}
func TestRestrictedCompositeReaderWithoutRootDeniesFileRefs(t *testing.T) { func TestRestrictedCompositeReaderWithoutRootDeniesFileRefs(t *testing.T) {
reader, err := NewRestrictedCompositeReader("") reader, err := NewRestrictedCompositeReader("")
if err != nil { if err != nil {