From 7e1a4dea6cab5bfb2cc78f4df25b277a92c4b1c0 Mon Sep 17 00:00:00 2001 From: James Shubin Date: Sun, 28 Jan 2024 23:13:19 -0500 Subject: [PATCH] engine: resources: The http:file resource should allow directories This expands the use of the http:file resource to allow it to be used as a directory root. --- engine/resources/http.go | 74 +++++++++++++++++++++++++++++++++++++--- examples/lang/http1.mcl | 46 +++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 examples/lang/http1.mcl diff --git a/engine/resources/http.go b/engine/resources/http.go index 91912e61..3ef690e9 100644 --- a/engine/resources/http.go +++ b/engine/resources/http.go @@ -34,6 +34,7 @@ import ( "github.com/purpleidea/mgmt/engine/traits" "github.com/purpleidea/mgmt/pgraph" "github.com/purpleidea/mgmt/util/errwrap" + "github.com/purpleidea/mgmt/util/safepath" securefilepath "github.com/cyphar/filepath-securejoin" ) @@ -831,6 +832,9 @@ type HTTPFileRes struct { // Path is the absolute path to a file that should be used as the source // for this file resource. It must not be combined with the data field. + // If this corresponds to a directory, then it will used as a root dir + // that will be served as long as the resource name or Filename are also + // a directory ending with a slash. Path string `lang:"path" yaml:"path"` // Data is the file content that should be used as the source for this @@ -858,17 +862,63 @@ func (obj *HTTPFileRes) getPath() string { // getContent returns the content that we expect from this resource. It depends // on whether the user specified the Path or Data fields, and whether the Path // exists or not. -func (obj *HTTPFileRes) getContent() (io.ReadSeeker, error) { +func (obj *HTTPFileRes) getContent(requestPath safepath.AbsPath) (io.ReadSeeker, error) { if obj.Path != "" && obj.Data != "" { // programming error! this should have been caught in Validate! return nil, fmt.Errorf("must not specify Path and Data") } - if obj.Path != "" { + if obj.Data != "" { + return bytes.NewReader([]byte(obj.Data)), nil + } + + absFile, err := obj.getContentRelative(requestPath) + if err != nil { // on error, we just assume no root/prefix stuff happens return os.Open(obj.Path) } - return bytes.NewReader([]byte(obj.Data)), nil + return os.Open(absFile.Path()) +} + +// getContentRelative takes a request, and returns the absolute path to the file +// that we want to request, if it's safely under what we can provide. +func (obj *HTTPFileRes) getContentRelative(requestPath safepath.AbsPath) (safepath.AbsFile, error) { + // the location on disk of the data + srcPath, err := safepath.SmartParseIntoPath(obj.Path) // (safepath.Path, error) + if err != nil { + return safepath.AbsFile{}, err + } + srcAbsDir, ok := srcPath.(safepath.AbsDir) + if !ok { + return safepath.AbsFile{}, fmt.Errorf("the Path is not an abs dir") + } + + // the public path we respond to (might be a dir prefix or just a file) + pubPath, err := safepath.SmartParseIntoPath(obj.getPath()) // (safepath.Path, error) + if err != nil { + return safepath.AbsFile{}, err + } + pubAbsDir, ok := pubPath.(safepath.AbsDir) + if !ok { + return safepath.AbsFile{}, fmt.Errorf("the name is not an abs dir") + } + + // is the request underneath what we're providing? + if !safepath.HasPrefix(requestPath, pubAbsDir) { + return safepath.AbsFile{}, fmt.Errorf("wrong prefix") + } + + // make the delta + delta, err := safepath.StripPrefix(requestPath, pubAbsDir) // (safepath.Path, error) + if err != nil { + return safepath.AbsFile{}, err + } + relFile, ok := delta.(safepath.RelFile) + if !ok { + return safepath.AbsFile{}, fmt.Errorf("the delta is not a rel file") + } + + return safepath.JoinToAbsFile(srcAbsDir, relFile), nil // AbsFile } // ParentName is used to limit which resources autogroup into this one. If it's @@ -882,6 +932,14 @@ func (obj *HTTPFileRes) ParentName() string { // accept, or any error to pass. func (obj *HTTPFileRes) AcceptHTTP(req *http.Request) error { requestPath := req.URL.Path // TODO: is this what we want here? + + if strings.HasSuffix(obj.Path, "/") { // a dir! + if strings.HasPrefix(requestPath, obj.getPath()) { + // relative dir root + return nil + } + } + if requestPath != obj.getPath() { return fmt.Errorf("unhandled path") } @@ -898,7 +956,15 @@ func (obj *HTTPFileRes) ServeHTTP(w http.ResponseWriter, req *http.Request) { requestPath := req.URL.Path // TODO: is this what we want here? - handle, err := obj.getContent() + absPath, err := safepath.ParseIntoAbsPath(requestPath) + if err != nil { + obj.init.Logf("invalid input path: %s", requestPath) + msg, httpStatus := toHTTPError(err) + http.Error(w, msg, httpStatus) + return + } + + handle, err := obj.getContent(absPath) if err != nil { obj.init.Logf("could not get content for: %s", requestPath) msg, httpStatus := toHTTPError(err) diff --git a/examples/lang/http1.mcl b/examples/lang/http1.mcl new file mode 100644 index 00000000..45096af9 --- /dev/null +++ b/examples/lang/http1.mcl @@ -0,0 +1,46 @@ +$root = "/tmp/httproot/" +file $root { + state => $const.res.file.state.exists, +} +file "${root}file0" { + content => "i'm file0 in ${root}\n", + state => $const.res.file.state.exists, +} +file "${root}file1" { + content => "i'm file1 in ${root}\n", + state => $const.res.file.state.exists, +} + +file "${root}dir1/" { + state => $const.res.file.state.exists, +} +file "${root}dir2/" { + state => $const.res.file.state.exists, +} +file "${root}dir1/file2" { + content => "i'm file2 in ${root}dir1/\n", + state => $const.res.file.state.exists, +} +file "${root}dir2/file3" { + content => "i'm file3 in ${root}dir2/\n", + state => $const.res.file.state.exists, +} + +# test with: wget -q -O - http://127.0.0.1:8080/secret/folder/file1 +http:server ":8080" { # by default http uses :80 but using :8080 avoids needing root! + #address => ":8080", # you can override the name like this + #timeout => 60, # add a timeout (seconds) + #root => $root, # add a httproot (optional) +} + +# you can add a raw file like this... +http:file "/file1" { + data => "hello, world, i'm file1 and i don't exist on disk!\n", +} + +# this pulls in a whole folder, since path is a folder! +http:file "/secret/folder/" { + path => "${root}", + + Depend => File["${root}"], # TODO: add autoedges +}