photoprism/pkg/http/security/web_overlay.go
Michael Mayer b04233f2f4 Backend: Use strings.SplitSeq for split-and-iterate loops #5599
Replace strings.Split with the lazy strings.SplitSeq iterator (Go 1.23+)
in loops that only range over the tokens, avoiding the intermediate
slice allocation. The yielded substrings are identical to Split, so
behavior is unchanged. Applies the suggestion from #5599 to its three
sites (CIDR parsing, WebDAV hidden-path, overlay path checks) and
extends it to two more split-and-iterate loops in the server compression
negotiator and MCP config-options builder for consistency. The slice
returning splitPath helper is intentionally left on strings.Split.
2026-05-30 09:49:47 +00:00

199 lines
5.4 KiB
Go

package security
import (
"path"
"path/filepath"
"strings"
"github.com/photoprism/photoprism/pkg/fs"
)
// overlayBlockedFileNames lists sensitive file base names that must never be
// served by the web overlay, even when present under /storage/web.
var overlayBlockedFileNames = map[string]struct{}{
"id_ed25519": {},
"id_ed25519.pub": {},
"id_rsa": {},
"id_rsa.pub": {},
"config.toml": {},
"config.yaml": {},
"config.yml": {},
"defaults.yaml": {},
"defaults.yml": {},
"options.yaml": {},
"options.yml": {},
"hub.yml": {},
"hub.yaml": {},
"auth.json": {},
"join_token": {},
"client_secret": {},
}
// overlayBlockedFileExt lists sensitive file extensions that must never be
// served by the web overlay, even when present under /storage/web.
var overlayBlockedFileExt = map[string]struct{}{
".key": {},
".rsa": {},
".jwk": {},
".pem": {},
".sql": {},
".toml": {},
}
// overlayBlockedPathPrefixes lists sensitive slash paths rooted under the
// overlay tree that must always be denied (path-prefix match).
var overlayBlockedPathPrefixes = []string{
"node/secrets",
"config/portal",
"config/certificates",
}
// OverlayHasAmbiguousPath returns true when a request path is unsafe due to
// ambiguity, traversal indicators, hidden/special segments, or encoded probes.
func OverlayHasAmbiguousPath(requestPath, escapedPath string) bool {
if requestPath == "" {
return true
}
if strings.Contains(requestPath, "\\") || strings.Contains(requestPath, "//") {
return true
}
if strings.Contains(requestPath, "/./") || strings.Contains(requestPath, "/../") ||
strings.HasSuffix(requestPath, "/.") || strings.HasSuffix(requestPath, "/..") {
return true
}
cleanPath := path.Clean(requestPath)
if cleanPath == "." {
return true
} else if !strings.HasPrefix(cleanPath, "/") {
cleanPath = "/" + cleanPath
}
// Allow only trailing slash normalization; reject all other material changes.
if requestPath != cleanPath && requestPath != cleanPath+"/" {
return true
}
if relPath := strings.Trim(cleanPath, "/"); relPath != "" {
for segment := range strings.SplitSeq(relPath, "/") {
if segment == "" || segment == "." || segment == ".." || fs.FileNameHidden(segment) {
return true
}
}
}
lowerEscaped := strings.ToLower(escapedPath)
return strings.Contains(lowerEscaped, "%2e") || strings.Contains(lowerEscaped, "%2f") || strings.Contains(lowerEscaped, "%5c")
}
// OverlayRelativePath maps a request path to an overlay-relative path while
// enforcing the configured base path scope.
func OverlayRelativePath(requestPath, webBase string) (string, bool) {
if requestPath == "" {
return "", false
}
absPath := strings.Trim(strings.ReplaceAll(requestPath, "\\", "/"), "/")
switch {
case webBase != "" && absPath == webBase:
// Request targets the base path itself (e.g. "/i/acme"), so map it to index.
return "", true
case webBase != "" && strings.HasPrefix(absPath, webBase+"/"):
// Request is under the configured base path; strip the prefix for web storage lookup.
return strings.TrimPrefix(absPath, webBase+"/"), true
case webBase != "":
// Ignore requests outside the configured base path when a base path is enforced.
return "", false
default:
return absPath, true
}
}
// OverlayPathBlocked returns true when an overlay path targets hidden/special
// path segments or files and directories blocked by denylist rules.
func OverlayPathBlocked(webPath string) bool {
relPath := strings.Trim(strings.ReplaceAll(webPath, "\\", "/"), "/")
if relPath == "" {
return false
}
segments := strings.SplitSeq(relPath, "/")
for segment := range segments {
if segment == "" || fs.FileNameHidden(segment) {
return true
}
}
lowerPath := strings.ToLower(relPath)
lowerBase := path.Base(lowerPath)
// Block requests for sensitive file names like "auth.json".
if _, blocked := overlayBlockedFileNames[lowerBase]; blocked {
return true
}
// Block file extensions commonly used for private keys or backups.
if _, blocked := overlayBlockedFileExt[path.Ext(lowerBase)]; blocked {
return true
}
// Additionally check for sensitive path prefixes that should not be served.
for _, prefix := range overlayBlockedPathPrefixes {
if lowerPath == prefix || strings.HasPrefix(lowerPath, prefix+"/") {
return true
}
}
return false
}
// OverlayResolveFile resolves an overlay file path and returns false when the
// target cannot be served safely from inside the canonical overlay root.
func OverlayResolveFile(webDir, webPath string) (string, bool) {
if webDir == "" || webPath == "" {
return "", false
}
overlayPath := strings.Trim(strings.ReplaceAll(webPath, "\\", "/"), "/")
if overlayPath == "" {
return "", false
}
candidate := filepath.Join(webDir, filepath.FromSlash(overlayPath))
if !fs.FileExists(candidate) {
return "", false
}
rootRealPath, err := filepath.EvalSymlinks(webDir)
if err != nil {
rootRealPath = webDir
}
rootAbsPath, err := filepath.Abs(rootRealPath)
if err != nil {
return "", false
}
fileRealPath, err := filepath.EvalSymlinks(candidate)
if err != nil {
return "", false
}
fileAbsPath, err := filepath.Abs(fileRealPath)
if err != nil {
return "", false
}
relPath, err := filepath.Rel(rootAbsPath, fileAbsPath)
if err != nil {
return "", false
} else if relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) {
return "", false
}
return fileAbsPath, true
}