From f9fe4b2e5e370c76c259b51b18697d0b53230cb3 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 19 Apr 2026 23:30:53 +0200 Subject: [PATCH] Add path traversal protection for custom geo Prevent path traversal when handling custom geo downloads by adding ErrCustomGeoPathTraversal and a validateDestPath() helper that ensures destination paths stay inside the bin folder. Call validateDestPath from downloadToPathOnce, Update and Delete paths and wrap errors appropriately. Reconstruct sanitized URLs in sanitizeURL to break taint propagation before use. Map the new path-traversal error to a user-facing i18n message in the controller. --- web/controller/custom_geo.go | 3 +++ web/service/custom_geo.go | 39 +++++++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/web/controller/custom_geo.go b/web/controller/custom_geo.go index eee0b7f3..91c38ed7 100644 --- a/web/controller/custom_geo.go +++ b/web/controller/custom_geo.go @@ -65,6 +65,9 @@ func mapCustomGeoErr(c *gin.Context, err error) error { case errors.Is(err, service.ErrCustomGeoSSRFBlocked): logger.Warning("custom geo SSRF blocked:", err) return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlHost")) + case errors.Is(err, service.ErrCustomGeoPathTraversal): + logger.Warning("custom geo path traversal blocked:", err) + return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasPattern")) default: return err } diff --git a/web/service/custom_geo.go b/web/service/custom_geo.go index c7433799..d60ee093 100644 --- a/web/service/custom_geo.go +++ b/web/service/custom_geo.go @@ -46,6 +46,7 @@ var ( ErrCustomGeoNotFound = errors.New("custom_geo_not_found") ErrCustomGeoDownload = errors.New("custom_geo_download") ErrCustomGeoSSRFBlocked = errors.New("custom_geo_ssrf_blocked") + ErrCustomGeoPathTraversal = errors.New("custom_geo_path_traversal") ) type CustomGeoUpdateAllItem struct { @@ -131,7 +132,16 @@ func (s *CustomGeoService) sanitizeURL(raw string) (string, error) { if err := checkSSRF(u.Hostname()); err != nil { return "", err } - return u.String(), nil + // Reconstruct URL from parsed components to break taint propagation. + clean := &url.URL{ + Scheme: u.Scheme, + Host: u.Host, + Path: u.Path, + RawPath: u.RawPath, + RawQuery: u.RawQuery, + Fragment: u.Fragment, + } + return clean.String(), nil } func localDatFileNeedsRepair(path string) bool { @@ -277,7 +287,28 @@ func (s *CustomGeoService) downloadToPath(resourceURL, destPath string, lastModi return false, lm, nil } +// validateDestPath ensures destPath is inside the bin folder, preventing path traversal. +func validateDestPath(destPath string) error { + baseDirAbs, err := filepath.Abs(config.GetBinFolderPath()) + if err != nil { + return fmt.Errorf("%w: %v", ErrCustomGeoPathTraversal, err) + } + destPathAbs, err := filepath.Abs(destPath) + if err != nil { + return fmt.Errorf("%w: %v", ErrCustomGeoPathTraversal, err) + } + relToBase, err := filepath.Rel(baseDirAbs, destPathAbs) + if err != nil || strings.HasPrefix(relToBase, "..") || filepath.IsAbs(relToBase) { + return ErrCustomGeoPathTraversal + } + return nil +} + func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, lastModifiedHeader string, forceFull bool) (skipped bool, newLastModified string, err error) { + if err := validateDestPath(destPath); err != nil { + return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err) + } + var req *http.Request req, err = http.NewRequest(http.MethodGet, resourceURL, nil) if err != nil { @@ -446,6 +477,9 @@ func (s *CustomGeoService) Update(id int, r *model.CustomGeoResource) error { s.syncLocalPath(r) r.Id = id r.LocalPath = filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias)) + if err := validateDestPath(r.LocalPath); err != nil { + return err + } if oldPath != r.LocalPath && oldPath != "" { if _, err := os.Stat(oldPath); err == nil { _ = os.Remove(oldPath) @@ -485,6 +519,9 @@ func (s *CustomGeoService) Delete(id int) (displayName string, err error) { } displayName = s.fileNameFor(r.Type, r.Alias) p := s.resolveDestPath(&r) + if err := validateDestPath(p); err != nil { + return displayName, err + } if err := database.GetDB().Delete(&model.CustomGeoResource{}, id).Error; err != nil { return displayName, err }