Security: Extend denylist coverage for names, paths and extensions #5459

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2026-02-26 11:01:15 +01:00
parent 31515f9524
commit 038d38d552
3 changed files with 57 additions and 4 deletions

View file

@ -159,13 +159,24 @@ func TestStaticRoutesWebOverlay(t *testing.T) {
conf := config.NewMinimalTestConfig(t.TempDir())
webDir := conf.WebStoragePath()
require.NoError(t, os.MkdirAll(filepath.Join(webDir, "node", "secrets"), fs.ModeDir))
require.NoError(t, os.MkdirAll(filepath.Join(webDir, "config", "portal"), fs.ModeDir))
require.NoError(t, os.MkdirAll(filepath.Join(webDir, "config", "certificates"), fs.ModeDir))
require.NoError(t, os.MkdirAll(filepath.Join(webDir, "tls"), fs.ModeDir))
require.NoError(t, os.MkdirAll(filepath.Join(webDir, "db"), fs.ModeDir))
require.NoError(t, os.MkdirAll(filepath.Join(webDir, "docs"), fs.ModeDir))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "options.yml"), []byte("blocked"), fs.ModeFile))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "Options.YML"), []byte("blocked"), fs.ModeFile))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "config.yaml"), []byte("blocked"), fs.ModeFile))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "id_rsa"), []byte("blocked"), fs.ModeFile))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "auth.json"), []byte("blocked"), fs.ModeFile))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "join_token"), []byte("blocked"), fs.ModeFile))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "tls", "server.pem"), []byte("blocked"), fs.ModeFile))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "db", "dump.sql"), []byte("blocked"), fs.ModeFile))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "docs", "public.toml"), []byte("blocked"), fs.ModeFile))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "docs", "client_secret"), []byte("blocked"), fs.ModeFile))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "node", "secrets", "token.txt"), []byte("blocked"), fs.ModeFile))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "config", "portal", "options.yml"), []byte("blocked"), fs.ModeFile))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "config", "certificates", "fullchain.pem"), []byte("blocked"), fs.ModeFile))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "docs", "public.txt"), []byte("ok"), fs.ModeFile))
r := gin.New()
@ -177,8 +188,15 @@ func TestStaticRoutesWebOverlay(t *testing.T) {
"/Options.YML",
"/config.yaml",
"/id_rsa",
"/auth.json",
"/join_token",
"/tls/server.pem",
"/db/dump.sql",
"/docs/public.toml",
"/docs/client_secret",
"/node/secrets/token.txt",
"/config/portal/options.yml",
"/config/certificates/fullchain.pem",
}
for _, filePath := range blocked {

View file

@ -11,21 +11,41 @@ import (
// 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{}{
"client_secret": {},
"config.yaml": {},
"config.yml": {},
"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
@ -109,11 +129,19 @@ func OverlayPathBlocked(webPath string) bool {
}
lowerPath := strings.ToLower(relPath)
lowerBase := path.Base(lowerPath)
if _, blocked := overlayBlockedFileNames[path.Base(lowerPath)]; blocked {
// 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

View file

@ -75,7 +75,14 @@ func TestOverlayPathBlocked(t *testing.T) {
{name: "SpecialDirBlocked", webPath: "__MACOSX/test.txt", blocked: true},
{name: "BlockedByName", webPath: "options.yml", blocked: true},
{name: "BlockedByNameCaseInsensitive", webPath: "Options.YML", blocked: true},
{name: "BlockedByNameAuthJson", webPath: "auth.json", blocked: true},
{name: "BlockedByNameJoinToken", webPath: "join_token", blocked: true},
{name: "BlockedByExtensionPem", webPath: "tls/server.pem", blocked: true},
{name: "BlockedByExtensionToml", webPath: "docs/public.toml", blocked: true},
{name: "BlockedByExtensionSQL", webPath: "backup/database.sql", blocked: true},
{name: "BlockedByPrefix", webPath: "node/secrets/token.txt", blocked: true},
{name: "BlockedByPrefixConfigPortal", webPath: "config/portal/options.yml", blocked: true},
{name: "BlockedByPrefixCertificates", webPath: "config/certificates/fullchain.pem", blocked: true},
}
for _, tc := range tests {