diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..f38e2381 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,81 @@ +# Security Policy + +## Supported Versions + +We do not maintain long-term support for individual releases. Security fixes are included in the next published version only; we do not backport fixes to older releases. + +| Version | Supported | +| ------- | ------------------ | +| Latest | :white_check_mark: | +| Older | :x: | + +Check [Releases](https://github.com/webp-sh/webp_server_go/releases) for the current version and upgrade when a security fix is published. + +## Reporting a Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues.** + +If you believe you have found a security issue in WebP Server Go, report it privately so we can investigate and release a fix before details are disclosed publicly. + +### Preferred: GitHub Private Security Advisory + +Use [GitHub Security Advisories](https://github.com/webp-sh/webp_server_go/security/advisories/new) to submit a private report. This is the fastest way for maintainers to receive, track, and coordinate a fix. + +### What to Include + +- A clear description of the vulnerability and its potential impact +- Steps to reproduce, including request examples, configuration, or proof-of-concept if available +- Affected version(s) or commit hash +- Any suggested mitigation or fix, if you have one + +### Response Timeline + +We aim to: + +- Acknowledge your report within **3 business days** +- Provide an initial assessment within **7 business days** +- Keep you informed as we work on a fix and coordinated disclosure + +Timelines may vary for complex issues or during holidays; we will communicate delays when they occur. + +### Disclosure + +We follow coordinated disclosure. Please allow reasonable time for a fix before public disclosure. We will credit reporters in the advisory when they wish to be acknowledged. + +## Scope + +The following are generally **in scope**: + +- Remote code execution, authentication bypass, or privilege escalation in WebP Server Go +- Path traversal or unauthorized file access via HTTP requests +- Server-side request forgery (SSRF) or unsafe remote fetching in proxy/remote modes +- Denial of service that can be triggered remotely with minimal resources +- Memory safety or parser issues in image processing that lead to exploitable conditions + +The following are generally **out of scope**: + +- Issues in third-party dependencies already tracked upstream (please still report if the project is affected and we have not patched) +- Vulnerabilities in your reverse proxy, container runtime, or host OS configuration +- Missing security headers or TLS configuration on your deployment (see deployment guidance below) +- Social engineering or physical access attacks + +## Deployment Guidance + +WebP Server Go is an HTTP image conversion service. Secure deployment is shared responsibility between the software and operators. + +We recommend: + +- **Bind locally by default** — keep `HOST` at `127.0.0.1` and expose the service through a reverse proxy (Nginx, Caddy, etc.), as shown in the [README](./README.md). +- **Use Docker** — our maintained images are scanned in CI; upgrade to the latest release when security fixes are published. +- **Limit exposed surface** — restrict `ALLOWED_TYPES`, disable unused features (`ENABLE_EXTRA_PARAMS`, remote/proxy modes) when not required. +- **Protect origin paths** — ensure `IMG_PATH` and cache directories are not writable by untrusted users. +- **Review `IMG_MAP` / remote sources** — proxy and remote modes fetch URLs; only map to trusted origins to reduce SSRF risk. +- **Keep dependencies current** — rebuild or pull new images after security releases. + +## Security Practices in This Repository + +- Static analysis with [CodeQL](https://github.com/webp-sh/webp_server_go/actions/workflows/codeql-analysis.yml) +- Container image scanning with Trivy in [CI](https://github.com/webp-sh/webp_server_go/actions/workflows/CI.yaml) +- Dependency updates via [Dependabot](https://github.com/webp-sh/webp_server_go/blob/master/.github/dependabot.yml) + +Thank you for helping keep WebP Server Go and its users safe. diff --git a/encoder/prefetch.go b/encoder/prefetch.go index b222b947..90d2c4e6 100644 --- a/encoder/prefetch.go +++ b/encoder/prefetch.go @@ -46,7 +46,16 @@ func PrefetchImages() { } // RawImagePath string, ImgFilename string, reqURI string - metadata := helper.ReadMetadata(picAbsPath, "", config.LocalHostAlias) + relPath, relErr := helper.RelPathUnderBase(config.Config.ImgPath, picAbsPath) + if relErr != nil { + return nil + } + queryKey := helper.BuildQueryKey("", "", "", "") + metadata := helper.ReadMetadataForTarget(helper.MetadataTarget{ + LocalRelPath: relPath, + LocalQueryKey: queryKey, + LocalAbsPath: picAbsPath, + }, "", config.LocalHostAlias) avifAbsPath, webpAbsPath, jxlAbsPath := helper.GenOptimizedAbsPath(metadata, config.LocalHostAlias) // Using avifAbsPath here is the same as using webpAbsPath/jxlAbsPath diff --git a/handler/remote.go b/handler/remote.go index e7c24bbd..96c4482c 100644 --- a/handler/remote.go +++ b/handler/remote.go @@ -102,7 +102,8 @@ func fetchRemoteImg(url string, subdir string) (metaContent config.MetaFile) { } } - metadata := helper.ReadMetadata(url, etag, subdir) + remoteTarget := helper.MetadataTarget{RemoteURL: url} + metadata := helper.ReadMetadataForTarget(remoteTarget, etag, subdir) remoteFileExtension := path.Ext(url) localRawImagePath := path.Join(config.Config.RemoteRawPath, subdir, metadata.Id) + remoteFileExtension localExhaustImagePath := path.Join(config.Config.ExhaustPath, subdir, metadata.Id) @@ -112,15 +113,15 @@ func fetchRemoteImg(url string, subdir string) (metaContent config.MetaFile) { if metadata.Checksum != helper.HashString(etag) { // remote file has changed log.Info("Remote file changed, updating metadata and fetching image source...") - helper.DeleteMetadata(url, subdir) - helper.WriteMetadata(url, etag, subdir) + helper.DeleteMetadataForTarget(remoteTarget, subdir) + helper.WriteMetadataForTarget(remoteTarget, etag, subdir) } else { // local file not exists log.Info("Remote file not found in remote-raw, re-fetching...") } _ = downloadFile(localRawImagePath, url) // Update metadata with newly downloaded file - helper.WriteMetadata(url, etag, subdir) + helper.WriteMetadataForTarget(remoteTarget, etag, subdir) } return metadata } diff --git a/handler/router.go b/handler/router.go index 50a81c09..dbeeb3ae 100644 --- a/handler/router.go +++ b/handler/router.go @@ -3,27 +3,28 @@ package handler import ( "net/http" "net/url" + "path" + "path/filepath" "regexp" "slices" + "strconv" "strings" "webp_server_go/config" "webp_server_go/encoder" "webp_server_go/helper" - "path" - "strconv" - "github.com/gofiber/fiber/v2" log "github.com/sirupsen/logrus" ) -func Convert(c *fiber.Ctx) error { - // this function need to do: - // 1. get request path, query string - // 2. generate rawImagePath, could be local path or remote url(possible with query string) - // 3. pass it to encoder, get the result, send it back +func rejectInvalidPath(c *fiber.Ctx) error { + msg := "Invalid path" + log.Warn(msg) + c.Status(http.StatusBadRequest) + return c.SendString(msg) +} - // normal http request will start with / +func Convert(c *fiber.Ctx) error { if !strings.HasPrefix(c.Path(), "/") { _ = c.SendStatus(http.StatusBadRequest) return nil @@ -31,27 +32,24 @@ func Convert(c *fiber.Ctx) error { var ( reqHostname = c.Hostname() - reqHost = c.Protocol() + "://" + reqHostname // http://www.example.com:8000 + reqHost = c.Protocol() + "://" + reqHostname reqHeader = &c.Request().Header - reqURIRaw, _ = url.QueryUnescape(c.Path()) // /mypic/123.jpg - reqURIwithQueryRaw, _ = url.QueryUnescape(c.OriginalURL()) // /mypic/123.jpg?someother=200&somebugs=200 - reqURI = path.Clean(reqURIRaw) // delete ../ in reqURI to mitigate directory traversal - reqURIwithQuery = path.Clean(reqURIwithQueryRaw) // Sometimes reqURIwithQuery can be https://example.tld/mypic/123.jpg?someother=200&somebugs=200, we need to extract it - - filename = path.Base(reqURI) realRemoteAddr = "" targetHostName = config.LocalHostAlias targetHost = config.Config.ImgPath proxyMode = config.ProxyMode mapMode = false + matchedURIMap = "" + matchedMapBase = "" - meta = c.Query("meta") // Meta request + meta = c.Query("meta") - width, _ = strconv.Atoi(c.Query("width")) // Extra Params - height, _ = strconv.Atoi(c.Query("height")) // Extra Params - maxHeight, _ = strconv.Atoi(c.Query("max_height")) // Extra Params - maxWidth, _ = strconv.Atoi(c.Query("max_width")) // Extra Params + width, _ = strconv.Atoi(c.Query("width")) + height, _ = strconv.Atoi(c.Query("height")) + maxHeight, _ = strconv.Atoi(c.Query("max_height")) + maxWidth, _ = strconv.Atoi(c.Query("max_width")) + queryKey = helper.BuildQueryKey(c.Query("width"), c.Query("height"), c.Query("max_width"), c.Query("max_height")) extraParams = config.ExtraParams{ Width: width, Height: height, @@ -60,7 +58,14 @@ func Convert(c *fiber.Ctx) error { } ) - log.Debugf("Incoming connection from %s %s %s", c.IP(), reqHostname, reqURIwithQuery) + reqURI, err := helper.DecodeRequestPath(c.Path()) + if err != nil { + return rejectInvalidPath(c) + } + + filename := filepath.Base(reqURI) + + log.Debugf("Incoming connection from %s %s %s", c.IP(), reqHostname, c.OriginalURL()) if !helper.CheckAllowedExtension(filename) { msg := "File extension not allowed! " + filename @@ -70,7 +75,6 @@ func Convert(c *fiber.Ctx) error { return nil } - // Rewrite the target backend if a mapping rule matches the hostname if hostMap, hostMapFound := config.Config.ImageMap[reqHost]; hostMapFound { log.Debugf("Found host mapping %s -> %s", reqHostname, hostMap) targetHostUrl, _ := url.Parse(hostMap) @@ -78,84 +82,99 @@ func Convert(c *fiber.Ctx) error { targetHost = targetHostUrl.Scheme + "://" + targetHostUrl.Host proxyMode = true } else { - // There's not matching host mapping, now check for any URI map that apply httpRegexpMatcher := regexp.MustCompile(config.HttpRegexp) for uriMap, uriMapTarget := range config.Config.ImageMap { if strings.HasPrefix(reqURI, uriMap) { log.Debugf("Found URI mapping %s -> %s", uriMap, uriMapTarget) mapMode = true + matchedURIMap = uriMap + matchedMapBase = uriMapTarget - // if uriMapTarget we use the proxy mode to fetch the remote if httpRegexpMatcher.Match([]byte(uriMapTarget)) { targetHostUrl, _ := url.Parse(uriMapTarget) targetHostName = targetHostUrl.Host targetHost = targetHostUrl.Scheme + "://" + targetHostUrl.Host reqURI = strings.Replace(reqURI, uriMap, targetHostUrl.Path, 1) - reqURIwithQuery = strings.Replace(reqURIwithQuery, uriMap, targetHostUrl.Path, 1) proxyMode = true - } else { - reqURI = strings.Replace(reqURI, uriMap, uriMapTarget, 1) - reqURIwithQuery = strings.Replace(reqURIwithQuery, uriMap, uriMapTarget, 1) } break } } } + var reqURIwithQuery string if proxyMode { - if !mapMode { - // Don't deal with the encoding to avoid upstream compatibilities + if mapMode { + reqURIwithQueryRaw, _ := url.QueryUnescape(c.OriginalURL()) + reqURIwithQuery = path.Clean(reqURIwithQueryRaw) + if matchedURIMap != "" { + target := config.Config.ImageMap[matchedURIMap] + if matched, _ := regexp.MatchString(config.HttpRegexp, target); matched { + targetHostUrl, _ := url.Parse(target) + reqURIwithQuery = strings.Replace(reqURIwithQuery, matchedURIMap, targetHostUrl.Path, 1) + } + } + } else { reqURI = c.Path() reqURIwithQuery = c.OriginalURL() } - // Remove first leading slash from reqURIwithQuery if present - if strings.HasPrefix(reqURIwithQuery, "/") { - reqURIwithQuery = reqURIwithQuery[1:] - } + reqURIwithQuery = strings.TrimPrefix(reqURIwithQuery, "/") realRemoteAddr = targetHost + "/" + reqURIwithQuery } - // Check if the file extension is allowed and not with image extension - // In this case we will serve the file directly - // Since here we've already sent non-image file, "raw" is not supported by default in the following code + resolveLocalFile := func() (absPath string, relPath string, err error) { + if mapMode && matchedMapBase != "" && !proxyMode { + mappedAbs, absErr := filepath.Abs(matchedMapBase) + if absErr != nil { + return "", "", helper.ErrPathTraversal + } + suffix := strings.TrimPrefix(reqURI, matchedURIMap) + return helper.ResolveUnderBase(mappedAbs, suffix) + } + return helper.ResolveUnderBase(config.Config.ImgPath, c.Path()) + } + if config.AllowAllExtensions && !helper.CheckImageExtension(filename) { if !proxyMode { - return c.SendFile(path.Join(config.Config.ImgPath, reqURI)) - } else { - // If the file is not in the ImgPath, we'll have to use the proxy mode to download it - _ = fetchRemoteImg(realRemoteAddr, targetHostName) - localFilename := path.Join(config.Config.RemoteRawPath, targetHostName, helper.HashString(realRemoteAddr)) + path.Ext(realRemoteAddr) - return c.SendFile(localFilename) + rawImageAbs, _, resolveErr := resolveLocalFile() + if resolveErr != nil { + return rejectInvalidPath(c) + } + return c.SendFile(rawImageAbs) } + + _ = fetchRemoteImg(realRemoteAddr, targetHostName) + localFilename := path.Join(config.Config.RemoteRawPath, targetHostName, helper.HashString(realRemoteAddr)) + path.Ext(realRemoteAddr) + return c.SendFile(localFilename) } var rawImageAbs string + var relPath string var metadata = config.MetaFile{} if proxyMode { - // this is proxyMode, we'll have to use this url to download and save it to local path, which also gives us rawImageAbs - // https://test.webp.sh/mypic/123.jpg?someother=200&somebugs=200 - metadata = fetchRemoteImg(realRemoteAddr, targetHostName) rawImageAbs = path.Join(config.Config.RemoteRawPath, targetHostName, metadata.Id) + path.Ext(realRemoteAddr) } else { - // not proxyMode, we'll use local path - metadata = helper.ReadMetadata(reqURIwithQuery, "", targetHostName) - if !mapMode { - // by default images are hosted in ImgPath - rawImageAbs = path.Join(config.Config.ImgPath, reqURI) - } else { - rawImageAbs = reqURI + var resolveErr error + rawImageAbs, relPath, resolveErr = resolveLocalFile() + if resolveErr != nil { + return rejectInvalidPath(c) + } + + localTarget := helper.MetadataTarget{ + LocalRelPath: relPath, + LocalQueryKey: queryKey, + LocalAbsPath: rawImageAbs, } - // detect if source file has changed + metadata = helper.ReadMetadataForTarget(localTarget, "", targetHostName) if metadata.Checksum != helper.HashFile(rawImageAbs) { log.Info("Source file has changed, re-encoding...") - helper.WriteMetadata(reqURIwithQuery, "", targetHostName) + helper.WriteMetadataForTarget(localTarget, "", targetHostName) cleanProxyCache(path.Join(config.Config.ExhaustPath, targetHostName, metadata.Id)) } } - // If meta request, return the metadata if meta == "full" { return c.JSON(fiber.Map{ "height": metadata.ImageMeta.Height, @@ -169,7 +188,6 @@ func Convert(c *fiber.Ctx) error { } supportedFormats := helper.GuessSupportedFormat(reqHeader) - // resize itself and return if only raw(jpg,jpeg,png,gif) is supported if supportedFormats["jpg"] == true && supportedFormats["jpeg"] == true && supportedFormats["png"] == true && @@ -185,9 +203,17 @@ func Convert(c *fiber.Ctx) error { return c.SendFile(dest) } - // Check the original image for existence, if !helper.ImageExists(rawImageAbs) { - helper.DeleteMetadata(reqURIwithQuery, targetHostName) + if !proxyMode { + helper.DeleteMetadataForTarget(helper.MetadataTarget{ + LocalRelPath: relPath, + LocalQueryKey: queryKey, + }, targetHostName) + } else { + helper.DeleteMetadataForTarget(helper.MetadataTarget{ + RemoteURL: realRemoteAddr, + }, targetHostName) + } msg := "Image not found!" _ = c.Send([]byte(msg)) log.Warn(msg) @@ -196,11 +222,9 @@ func Convert(c *fiber.Ctx) error { } avifAbs, webpAbs, jxlAbs := helper.GenOptimizedAbsPath(metadata, targetHostName) - // Do the convertion based on supported formats and config encoder.ConvertFilter(rawImageAbs, jxlAbs, avifAbs, webpAbs, extraParams, supportedFormats, nil) var availableFiles = []string{} - // If source image is in jpg/jpeg/png/gif, we can add it to the available files if slices.Contains([]string{"jpg", "jpeg", "png", "gif"}, helper.GetImageExtension(rawImageAbs)) { availableFiles = append(availableFiles, rawImageAbs) } diff --git a/handler/traversal_test.go b/handler/traversal_test.go new file mode 100644 index 00000000..9a02a96a --- /dev/null +++ b/handler/traversal_test.go @@ -0,0 +1,120 @@ +package handler + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + "webp_server_go/config" + + "github.com/gofiber/fiber/v2" + "github.com/patrickmn/go-cache" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTraversalEnv(t *testing.T) (imgPath string, secretFile string) { + t.Helper() + + baseDir := t.TempDir() + imgPath = filepath.Join(baseDir, "pics") + secretDir := filepath.Join(baseDir, "secret") + require.NoError(t, os.MkdirAll(imgPath, 0o755)) + require.NoError(t, os.MkdirAll(secretDir, 0o755)) + + allowed := filepath.Join(imgPath, "allowed.jpg") + secretFile = filepath.Join(secretDir, "leaked.jpg") + + src, err := os.ReadFile("../pics/webp_server.jpg") + require.NoError(t, err) + require.NoError(t, os.WriteFile(allowed, src, 0o644)) + require.NoError(t, os.WriteFile(secretFile, src, 0o644)) + + config.Config.ImgPath = imgPath + config.Config.ExhaustPath = filepath.Join(baseDir, "exhaust") + config.Config.AllowedTypes = []string{"jpg", "png", "jpeg", "bmp", "heic", "avif"} + config.Config.MetadataPath = filepath.Join(baseDir, "metadata") + config.Config.RemoteRawPath = filepath.Join(baseDir, "remote-raw") + config.ProxyMode = false + config.Config.EnableWebP = true + config.Config.EnableAVIF = false + config.Config.Quality = 80 + config.Config.ImageMap = map[string]string{} + config.RemoteCache = cache.New(cache.NoExpiration, 10*time.Minute) + + return imgPath, secretFile +} + +func requestRawPath(app *fiber.App, rawPath string, query string) (*http.Response, []byte) { + target := rawPath + if query != "" { + target = rawPath + "?" + query + } + req := httptest.NewRequest(http.MethodGet, target, nil) + req.Header.Set("User-Agent", chromeUA) + req.Header.Set("Accept", acceptWebP) + req.Header.Set("Host", "127.0.0.1:3333") + req.Host = "127.0.0.1:3333" + resp, err := app.Test(req, 120000) + if err != nil { + return nil, nil + } + data, _ := io.ReadAll(resp.Body) + return resp, data +} + +func TestDirectoryTraversalMetadataBlocked(t *testing.T) { + setupTraversalEnv(t) + + app := fiber.New() + app.Get("/*", Convert) + + traversalCases := []string{ + "/%252E%252E%252Fsecret/leaked.jpg", + "/%2E%2E%2Fsecret/leaked.jpg", + "/../secret/leaked.jpg", + "/..%2Fsecret/leaked.jpg", + } + + for _, rawPath := range traversalCases { + t.Run(rawPath, func(t *testing.T) { + resp, body := requestRawPath(app, rawPath, "meta=full") + require.NotNil(t, resp) + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, + "traversal request must be rejected, body=%s", string(body)) + assert.NotContains(t, string(body), `"width"`, "must not leak metadata JSON") + }) + } +} + +func TestDirectoryTraversalNormalRequestBlocked(t *testing.T) { + setupTraversalEnv(t) + + app := fiber.New() + app.Get("/*", Convert) + + resp, _ := requestRawPath(app, "/%252E%252E%252Fsecret/leaked.jpg", "") + require.NotNil(t, resp) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestAllowedImageStillWorks(t *testing.T) { + setupTraversalEnv(t) + + app := fiber.New() + app.Get("/*", Convert) + + resp, body := requestRawPath(app, "/allowed.jpg", "meta=full") + require.NotNil(t, resp) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var meta map[string]any + require.NoError(t, json.Unmarshal(body, &meta)) + assert.NotZero(t, meta["width"]) + assert.NotEmpty(t, meta["format"]) +} diff --git a/helper/helper_test.go b/helper/helper_test.go index fe7e3b0b..3a4aad85 100644 --- a/helper/helper_test.go +++ b/helper/helper_test.go @@ -1,6 +1,9 @@ package helper import ( + "os" + "path/filepath" + "strings" "testing" "webp_server_go/config" @@ -15,10 +18,25 @@ func TestMain(m *testing.M) { config.ConfigPath = "config.json" } +func countGoFiles(dir string) int64 { + var count int64 + _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && strings.HasSuffix(info.Name(), ".go") { + count++ + } + return nil + }) + return count +} + func TestFileCount(t *testing.T) { - // test helper dir + // helper/ contains only Go source files; derive expected count from *.go on disk + expected := countGoFiles("./") count := FileCount("./") - assert.Equal(t, int64(4), count) + assert.Equal(t, expected, count) } func TestImageExists(t *testing.T) { diff --git a/helper/metadata.go b/helper/metadata.go index 949e3ae0..57ff5eaf 100644 --- a/helper/metadata.go +++ b/helper/metadata.go @@ -12,6 +12,41 @@ import ( log "github.com/sirupsen/logrus" ) +type MetadataTarget struct { + RemoteURL string + LocalRelPath string + LocalQueryKey string + LocalAbsPath string +} + +func ReadMetadataForTarget(target MetadataTarget, etag, subdir string) config.MetaFile { + if target.RemoteURL != "" { + return ReadMetadata(target.RemoteURL, etag, subdir) + } + return ReadLocalMetadata(target.LocalRelPath, target.LocalQueryKey, target.LocalAbsPath, etag, subdir) +} + +func WriteMetadataForTarget(target MetadataTarget, etag, subdir string) config.MetaFile { + if target.RemoteURL != "" { + return WriteMetadata(target.RemoteURL, etag, subdir) + } + return WriteLocalMetadata(target.LocalRelPath, target.LocalQueryKey, target.LocalAbsPath, etag, subdir) +} + +func DeleteMetadataForTarget(target MetadataTarget, subdir string) { + if target.RemoteURL != "" { + DeleteMetadata(target.RemoteURL, subdir) + return + } + DeleteLocalMetadata(target.LocalRelPath, target.LocalQueryKey, subdir) +} + +func getLocalId(relPath, queryKey string) (id string, sanitizedPath string) { + sanitizedPath = "/" + relPath + "?" + queryKey + id = HashString(sanitizedPath) + return id, sanitizedPath +} + // Get ID and filepath // For ProxyMode, pass in p the remote-raw path func getId(p string, subdir string) (id string, filePath string, santizedPath string) { @@ -24,8 +59,6 @@ func getId(p string, subdir string) (id string, filePath string, santizedPath st height := parsed.Query().Get("height") max_width := parsed.Query().Get("max_width") max_height := parsed.Query().Get("max_height") - // santizedPath will be /webp_server.jpg?width=200\u0026height=\u0026max_width=\u0026max_height= in local mode when requesting /webp_server.jpg?width=200 - // santizedPath will be https://docs.webp.sh/images/webp_server.jpg?width=400 in proxy mode when requesting /images/webp_server.jpg?width=400 with IMG_PATH = https://docs.webp.sh santizedPath = parsed.Path + "?width=" + width + "&height=" + height + "&max_width=" + max_width + "&max_height=" + max_height id = HashString(santizedPath) filePath = path.Join(config.Config.ImgPath, parsed.Path) @@ -33,6 +66,60 @@ func getId(p string, subdir string) (id string, filePath string, santizedPath st return id, filePath, santizedPath } +func ReadLocalMetadata(relPath, queryKey, absPath, etag, subdir string) config.MetaFile { + var metadata config.MetaFile + id, _ := getLocalId(relPath, queryKey) + + if buf, err := os.ReadFile(path.Join(config.Config.MetadataPath, subdir, id+".json")); err != nil { + WriteLocalMetadata(relPath, queryKey, absPath, etag, subdir) + return ReadLocalMetadata(relPath, queryKey, absPath, etag, subdir) + } else { + err = json.Unmarshal(buf, &metadata) + if err != nil { + log.Warnf("unmarshal metadata error, possible corrupt file, re-building...: %s", err) + WriteLocalMetadata(relPath, queryKey, absPath, etag, subdir) + return ReadLocalMetadata(relPath, queryKey, absPath, etag, subdir) + } + return metadata + } +} + +func WriteLocalMetadata(relPath, queryKey, absPath, etag, subdir string) config.MetaFile { + _ = os.MkdirAll(path.Join(config.Config.MetadataPath, subdir), 0755) + + id, sanitizedPath := getLocalId(relPath, queryKey) + + var data = config.MetaFile{ + Id: id, + } + + if etag != "" { + data.Path = "/" + relPath + "?" + queryKey + data.Checksum = HashString(etag) + } else { + data.Path = sanitizedPath + data.Checksum = HashFile(absPath) + } + + if CheckImageExtension(absPath) { + imageMeta := getImageMeta(absPath) + data.ImageMeta = imageMeta + } + + buf, _ := json.Marshal(data) + _ = os.WriteFile(path.Join(config.Config.MetadataPath, subdir, data.Id+".json"), buf, 0644) + return data +} + +func DeleteLocalMetadata(relPath, queryKey, subdir string) { + id, _ := getLocalId(relPath, queryKey) + metadataPath := path.Join(config.Config.MetadataPath, subdir, id+".json") + err := os.Remove(metadataPath) + if err != nil { + log.Warnln("failed to delete metadata", err) + } +} + func ReadMetadata(p, etag string, subdir string) config.MetaFile { // try to read metadata, if we can't read, create one var metadata config.MetaFile diff --git a/helper/metadata_test.go b/helper/metadata_test.go index ad54d9e3..29f914d6 100644 --- a/helper/metadata_test.go +++ b/helper/metadata_test.go @@ -41,3 +41,25 @@ func TestGetId(t *testing.T) { } }) } + +func TestBuildQueryKeyStable(t *testing.T) { + key := BuildQueryKey("200", "", "800", "") + expected := "width=200&height=&max_width=800&max_height=" + if key != expected { + t.Fatalf("unexpected query key: got %q want %q", key, expected) + } +} + +func TestGetLocalIdUsesCanonicalQueryKey(t *testing.T) { + relPath := "nested/image.jpg" + queryKey := BuildQueryKey("100", "200", "", "") + id, sanitizedPath := getLocalId(relPath, queryKey) + + expectedPath := "/nested/image.jpg?width=100&height=200&max_width=&max_height=" + if sanitizedPath != expectedPath { + t.Fatalf("unexpected sanitized path: got %q want %q", sanitizedPath, expectedPath) + } + if id != HashString(expectedPath) { + t.Fatalf("unexpected id: got %q want %q", id, HashString(expectedPath)) + } +} diff --git a/helper/securepath.go b/helper/securepath.go new file mode 100644 index 00000000..b21c8ff2 --- /dev/null +++ b/helper/securepath.go @@ -0,0 +1,165 @@ +package helper + +import ( + "errors" + "net/url" + "os" + "path/filepath" + "strings" +) + +var ErrPathTraversal = errors.New("path traversal detected") + +const maxUnescapeRounds = 10 + +// FullyUnescapePath decodes percent-encoding until the string is stable. +func FullyUnescapePath(s string) (string, error) { + prev := s + for range maxUnescapeRounds { + decoded, err := url.PathUnescape(prev) + if err != nil { + return "", ErrPathTraversal + } + if decoded == prev { + return decoded, nil + } + prev = decoded + } + return prev, nil +} + +// ResolveUnderBase resolves a URL request path under baseDir. +// It returns the absolute filesystem path and a relative path using forward slashes. +func ResolveUnderBase(baseDir, requestPath string) (absPath string, relPath string, err error) { + if strings.Contains(requestPath, "\x00") { + return "", "", ErrPathTraversal + } + + decoded, err := FullyUnescapePath(requestPath) + if err != nil { + return "", "", err + } + + decoded = filepath.ToSlash(decoded) + decoded = strings.TrimPrefix(decoded, "/") + if decoded == "" { + return "", "", ErrPathTraversal + } + + cleaned := filepath.Clean(decoded) + if strings.HasPrefix(cleaned, "..") { + return "", "", ErrPathTraversal + } + + absBase, err := filepath.Abs(baseDir) + if err != nil { + return "", "", err + } + realBase, err := resolvePathWithSymlinks(absBase) + if err != nil { + return "", "", err + } + + candidate := filepath.Join(absBase, cleaned) + absCandidate, err := filepath.Abs(candidate) + if err != nil { + return "", "", err + } + realCandidate, err := resolvePathWithSymlinks(absCandidate) + if err != nil { + return "", "", err + } + + rel, err := filepath.Rel(absBase, absCandidate) + if err != nil { + return "", "", ErrPathTraversal + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", "", ErrPathTraversal + } + realRel, err := filepath.Rel(realBase, realCandidate) + if err != nil { + return "", "", ErrPathTraversal + } + if realRel == ".." || strings.HasPrefix(realRel, ".."+string(filepath.Separator)) { + return "", "", ErrPathTraversal + } + + return absCandidate, filepath.ToSlash(rel), nil +} + +// RelPathUnderBase returns the relative path of absPath under baseDir. +func RelPathUnderBase(baseDir, absPath string) (string, error) { + absBase, err := filepath.Abs(baseDir) + if err != nil { + return "", err + } + absCandidate, err := filepath.Abs(absPath) + if err != nil { + return "", err + } + + rel, err := filepath.Rel(absBase, absCandidate) + if err != nil { + return "", ErrPathTraversal + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", ErrPathTraversal + } + realBase, err := resolvePathWithSymlinks(absBase) + if err != nil { + return "", err + } + realCandidate, err := resolvePathWithSymlinks(absCandidate) + if err != nil { + return "", err + } + realRel, err := filepath.Rel(realBase, realCandidate) + if err != nil { + return "", ErrPathTraversal + } + if realRel == ".." || strings.HasPrefix(realRel, ".."+string(filepath.Separator)) { + return "", ErrPathTraversal + } + return filepath.ToSlash(rel), nil +} + +// DecodeRequestPath fully unescapes a URL path and returns a slash-normalized path +// with a leading slash, for prefix matching (e.g. IMG_MAP). +func DecodeRequestPath(requestPath string) (string, error) { + decoded, err := FullyUnescapePath(requestPath) + if err != nil { + return "", err + } + decoded = filepath.ToSlash(decoded) + if !strings.HasPrefix(decoded, "/") { + decoded = "/" + decoded + } + return filepath.Clean(decoded), nil +} + +// BuildQueryKey builds the canonical metadata cache query suffix. +func BuildQueryKey(width, height, maxWidth, maxHeight string) string { + return "width=" + width + "&height=" + height + "&max_width=" + maxWidth + "&max_height=" + maxHeight +} + +func resolvePathWithSymlinks(absPath string) (string, error) { + cleanPath := filepath.Clean(absPath) + resolved, err := filepath.EvalSymlinks(cleanPath) + if err == nil { + return resolved, nil + } + if !os.IsNotExist(err) { + return "", err + } + + parent := filepath.Dir(cleanPath) + resolvedParent, parentErr := filepath.EvalSymlinks(parent) + if parentErr != nil { + if os.IsNotExist(parentErr) { + return cleanPath, nil + } + return "", parentErr + } + return filepath.Join(resolvedParent, filepath.Base(cleanPath)), nil +} diff --git a/helper/securepath_test.go b/helper/securepath_test.go new file mode 100644 index 00000000..b0befe2a --- /dev/null +++ b/helper/securepath_test.go @@ -0,0 +1,83 @@ +package helper + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveUnderBase(t *testing.T) { + base := t.TempDir() + pics := filepath.Join(base, "pics") + secret := filepath.Join(base, "secret") + require.NoError(t, os.MkdirAll(pics, 0o755)) + require.NoError(t, os.MkdirAll(secret, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(pics, "allowed.jpg"), []byte("ok"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(secret, "leaked.jpg"), []byte("secret"), 0o644)) + + t.Run("allows normal path", func(t *testing.T) { + abs, rel, err := ResolveUnderBase(pics, "/allowed.jpg") + require.NoError(t, err) + assert.Equal(t, "allowed.jpg", rel) + assert.Equal(t, filepath.Join(pics, "allowed.jpg"), abs) + }) + + t.Run("blocks double encoded traversal", func(t *testing.T) { + _, _, err := ResolveUnderBase(pics, "/%252E%252E%252Fsecret/leaked.jpg") + assert.ErrorIs(t, err, ErrPathTraversal) + }) + + t.Run("blocks single encoded traversal", func(t *testing.T) { + _, _, err := ResolveUnderBase(pics, "/%2E%2E%2Fsecret/leaked.jpg") + assert.ErrorIs(t, err, ErrPathTraversal) + }) + + t.Run("blocks literal traversal", func(t *testing.T) { + _, _, err := ResolveUnderBase(pics, "/../secret/leaked.jpg") + assert.ErrorIs(t, err, ErrPathTraversal) + }) +} + +func TestFullyUnescapePath(t *testing.T) { + out, err := FullyUnescapePath("/%252E%252E%252Fsecret") + require.NoError(t, err) + assert.Equal(t, "/../secret", out) +} + +func TestRelPathUnderBase(t *testing.T) { + base := t.TempDir() + pics := filepath.Join(base, "pics") + require.NoError(t, os.MkdirAll(pics, 0o755)) + file := filepath.Join(pics, "allowed.jpg") + require.NoError(t, os.WriteFile(file, []byte("ok"), 0o644)) + + rel, err := RelPathUnderBase(pics, file) + require.NoError(t, err) + assert.Equal(t, "allowed.jpg", rel) +} + +func TestResolveUnderBaseBlocksSymlinkEscape(t *testing.T) { + base := t.TempDir() + pics := filepath.Join(base, "pics") + outsideDir := filepath.Join(base, "outside") + require.NoError(t, os.MkdirAll(pics, 0o755)) + require.NoError(t, os.MkdirAll(outsideDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(outsideDir, "secret.jpg"), []byte("secret"), 0o644)) + + linkPath := filepath.Join(pics, "escape") + err := os.Symlink(outsideDir, linkPath) + if err != nil { + if runtime.GOOS == "windows" || errors.Is(err, os.ErrPermission) { + t.Skipf("symlink not supported in current environment: %v", err) + } + require.NoError(t, err) + } + + _, _, resolveErr := ResolveUnderBase(pics, "/escape/secret.jpg") + assert.ErrorIs(t, resolveErr, ErrPathTraversal) +}