Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions internal/sourceconfig/sources.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,21 +157,44 @@ func validate(s *Source, baseDir string) error {
if s.Swagger == nil || len(s.Swagger.Files) == 0 {
return fmt.Errorf("backend=swagger requires non-empty swagger.files")
}
if err := validateRelPathList("swagger.files", s.Swagger.Files); err != nil {
return err
}
case BackendProto:
if s.Proto == nil || len(s.Proto.Entries) == 0 {
return fmt.Errorf("backend=proto requires non-empty proto.entries")
}
if len(s.Proto.Staging) == 0 {
return fmt.Errorf("backend=proto requires non-empty proto.staging")
}
for _, st := range s.Proto.Staging {
if err := ValidateRelPath("proto.staging.from", st.From); err != nil {
return err
}
if err := ValidateRelPath("proto.staging.to", st.To); err != nil {
return err
}
}
if err := validateRelPathList("proto.entries", s.Proto.Entries); err != nil {
return err
}
if err := validateRelPathList("proto.import_roots", s.Proto.ImportRoots); err != nil {
return err
}
case BackendOpenAPI3:
if s.OpenAPI3 == nil || len(s.OpenAPI3.Files) == 0 {
return fmt.Errorf("backend=openapi3 requires non-empty openapi3.files")
}
if err := validateRelPathList("openapi3.files", s.OpenAPI3.Files); err != nil {
return err
}
case BackendGraphQL:
if s.GraphQL == nil || s.GraphQL.Schema == "" {
return fmt.Errorf("backend=graphql requires graphql.schema")
}
if err := ValidateRelPath("graphql.schema", s.GraphQL.Schema); err != nil {
return err
}
if s.GraphQL.Expose == nil || (len(s.GraphQL.Expose.Queries) == 0 && len(s.GraphQL.Expose.Mutations) == 0) {
return fmt.Errorf("backend=graphql requires an explicit graphql.expose policy (queries and/or mutations); refusing to expose the whole schema")
}
Expand All @@ -186,6 +209,30 @@ func validate(s *Source, baseDir string) error {
return rejectForeignBlocks(s)
}

func validateRelPathList(field string, paths []string) error {
for _, p := range paths {
if err := ValidateRelPath(field, p); err != nil {
return err
}
}
return nil
}

func ValidateRelPath(field, value string) error {
if value == "" {
return fmt.Errorf("unsafe path %s: empty path", field)
}
if filepath.IsAbs(value) || !filepath.IsLocal(value) {
return fmt.Errorf("unsafe path %s: %q", field, value)
}
for _, part := range strings.Split(strings.ReplaceAll(value, "\\", "/"), "/") {
if part == "" || part == ".." {
return fmt.Errorf("unsafe path %s: %q", field, value)
}
}
return nil
}

func resolveLocalPath(baseDir, raw string) (string, error) {
if raw == "" {
return "", fmt.Errorf("local_path must not be empty")
Expand Down
85 changes: 85 additions & 0 deletions internal/sourceconfig/sources_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,91 @@ func TestLoad_RejectsOpenAPI3WithoutFiles(t *testing.T) {
}
}

func TestValidate_RejectsUnsafeSourcePaths(t *testing.T) {
remote := func(src Source) Source {
src.RepoURL = "https://example.com/repo.git"
src.PinnedTag = "v2.0.0"
return src
}
cases := []struct {
name string
src Source
}{
{
name: "swagger absolute file",
src: remote(Source{Backend: BackendSwagger, Swagger: &SwaggerConfig{Files: []string{"/tmp/api.json"}}}),
},
{
name: "openapi empty segment",
src: remote(Source{Backend: BackendOpenAPI3, OpenAPI3: &OpenAPI3Config{Files: []string{"api//openapi.yaml"}}}),
},
{
name: "proto staging from traversal",
src: remote(Source{
Backend: BackendProto,
Proto: &ProtoConfig{
Staging: []StagingEntry{{From: "../proto", To: "proto"}},
Entries: []string{"api/v1/service.proto"},
},
}),
},
{
name: "proto staging to absolute",
src: remote(Source{
Backend: BackendProto,
Proto: &ProtoConfig{
Staging: []StagingEntry{{From: "proto", To: "/tmp/proto"}},
Entries: []string{"api/v1/service.proto"},
},
}),
},
{
name: "proto entry traversal",
src: remote(Source{
Backend: BackendProto,
Proto: &ProtoConfig{
Staging: []StagingEntry{{From: "proto", To: "proto"}},
Entries: []string{"../api/v1/service.proto"},
},
}),
},
{
name: "proto import root traversal",
src: remote(Source{
Backend: BackendProto,
Proto: &ProtoConfig{
Staging: []StagingEntry{{From: "proto", To: "proto"}},
Entries: []string{"api/v1/service.proto"},
ImportRoots: []string{"../includes"},
},
}),
},
{
name: "graphql schema traversal",
src: remote(Source{
Backend: BackendGraphQL,
GraphQL: &GraphQLConfig{
Schema: "../schema.graphql",
Expose: &GraphQLExpose{
Queries: []string{"viewer"},
},
},
}),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validate(&tc.src, t.TempDir())
if err == nil {
t.Fatal("validate accepted unsafe source path; want rejection")
}
if !strings.Contains(err.Error(), "unsafe path") {
t.Errorf("error = %v, want to mention unsafe path", err)
}
})
}
}

func TestLoad_RejectsOpenAPI3WithSwaggerBlock(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sources.yaml")
Expand Down
11 changes: 8 additions & 3 deletions internal/specsync/graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@ package specsync
import (
"fmt"
"os"
"path/filepath"

"github.com/lathe-cli/lathe/internal/sourceconfig"
)

func syncGraphQL(src *sourceconfig.Source, workDir, syncDir string) error {
rel := src.GraphQL.Schema
srcPath := filepath.Join(workDir, rel)
dstPath := filepath.Join(syncDir, rel)
srcPath, err := safeJoin(workDir, rel)
if err != nil {
return err
}
dstPath, err := safeJoin(syncDir, rel)
if err != nil {
return err
}
if _, err := os.Stat(srcPath); err != nil {
return fmt.Errorf("missing %s in %s@%s", rel, src.Name, src.PinnedTag)
}
Expand Down
11 changes: 8 additions & 3 deletions internal/specsync/openapi3.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@ package specsync
import (
"fmt"
"os"
"path/filepath"

"github.com/lathe-cli/lathe/internal/sourceconfig"
)

func syncOpenAPI3(src *sourceconfig.Source, workDir, syncDir string) error {
for i, rel := range src.OpenAPI3.Files {
srcPath := filepath.Join(workDir, rel)
dstPath := filepath.Join(syncDir, rel)
srcPath, err := safeJoin(workDir, rel)
if err != nil {
return err
}
dstPath, err := safeJoin(syncDir, rel)
if err != nil {
return err
}
if _, err := os.Stat(srcPath); err != nil {
return fmt.Errorf("missing %s in %s@%s", rel, src.Name, src.PinnedTag)
}
Expand Down
28 changes: 28 additions & 0 deletions internal/specsync/path.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package specsync

import (
"fmt"
"path/filepath"
"strings"

"github.com/lathe-cli/lathe/internal/sourceconfig"
)

func safeJoin(root, rel string) (string, error) {
if err := sourceconfig.ValidateRelPath("source path", rel); err != nil {
return "", err
}
rootAbs, err := filepath.Abs(root)
if err != nil {
return "", fmt.Errorf("resolve root path: %w", err)
}
full := filepath.Join(rootAbs, rel)
inside, err := filepath.Rel(rootAbs, full)
if err != nil {
return "", fmt.Errorf("resolve source path %q: %w", rel, err)
}
if inside == ".." || strings.HasPrefix(inside, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("unsafe path source path: %q", rel)
}
return full, nil
}
21 changes: 17 additions & 4 deletions internal/specsync/proto.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,14 @@ import (

func syncProto(src *sourceconfig.Source, workDir, syncDir string) error {
for _, st := range src.Proto.Staging {
from := filepath.Join(workDir, st.From)
to := filepath.Join(syncDir, st.To)
from, err := safeJoin(workDir, st.From)
if err != nil {
return err
}
to, err := safeJoin(syncDir, st.To)
if err != nil {
return err
}
if _, err := os.Stat(from); err != nil {
return fmt.Errorf("staging %s: source missing: %w", st.From, err)
}
Expand All @@ -24,7 +30,10 @@ func syncProto(src *sourceconfig.Source, workDir, syncDir string) error {
}
entries := make([]string, 0, len(src.Proto.Entries))
for _, e := range src.Proto.Entries {
full := filepath.Join(syncDir, e)
full, err := safeJoin(syncDir, e)
if err != nil {
return err
}
if _, err := os.Stat(full); err != nil {
return fmt.Errorf("entry %s not found in staged tree: %w", e, err)
}
Expand All @@ -38,7 +47,11 @@ func syncProto(src *sourceconfig.Source, workDir, syncDir string) error {
"--descriptor_set_out=" + descOut,
}
for _, r := range src.Proto.ImportRoots {
args = append(args, "-I", filepath.Join(syncDir, r))
full, err := safeJoin(syncDir, r)
if err != nil {
return err
}
args = append(args, "-I", full)
}
args = append(args, entries...)
cmd := exec.Command("protoc", args...)
Expand Down
10 changes: 8 additions & 2 deletions internal/specsync/swagger.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,14 @@ import (

func syncSwagger(src *sourceconfig.Source, workDir, syncDir string) error {
for i, rel := range src.Swagger.Files {
srcPath := filepath.Join(workDir, rel)
dstPath := filepath.Join(syncDir, rel)
srcPath, err := safeJoin(workDir, rel)
if err != nil {
return err
}
dstPath, err := safeJoin(syncDir, rel)
if err != nil {
return err
}
if _, err := os.Stat(srcPath); err != nil {
return fmt.Errorf("missing %s in %s@%s", rel, src.Name, src.PinnedTag)
}
Expand Down