fix(transcode): allow catalogued symlink media (#644)

This commit is contained in:
Quick
2026-08-14 23:16:09 -04:00
committed by GitHub
parent 54507d236a
commit febf5c4a28
6 changed files with 161 additions and 99 deletions
+1 -1
View File
@@ -730,7 +730,7 @@ func main() {
handler = srv.Handler()
} else {
srv := transcodenode.NewServer(watcher, tracker)
srv.SetInputPathAuthorizer(transcodenode.NewMediaRootAuthorizer(catalog.NewFolderRepository(pool)))
srv.SetInputPathAuthorizer(transcodenode.NewCatalogPathAuthorizer(scanner.NewFileRepository(pool)))
srv.SetFFmpegLogSink(playback.NewSlogFFmpegLogSink(slog.Default(), nodeID))
// Read jellycompat reconstruction recipes central wrote at transcode
// start, so this node can rebuild a Jellyfin transcode after its own
+20
View File
@@ -1943,6 +1943,26 @@ func (r *FileRepository) GetByPath(ctx context.Context, path string) (*models.Me
return scanMediaFile(r.pool.QueryRow(ctx, query, path))
}
// IsActivePath reports whether path is the exact logical path of a media file
// that is still active in the catalog. Scanner paths are authoritative here:
// they deliberately preserve readable symlinks instead of replacing them with
// their physical targets.
func (r *FileRepository) IsActivePath(ctx context.Context, path string) (bool, error) {
var active bool
err := r.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM media_files
WHERE file_path = $1
AND missing_since IS NULL
)
`, path).Scan(&active)
if err != nil {
return false, fmt.Errorf("checking active media file path: %w", err)
}
return active, nil
}
// GetByHash retrieves a media file by its file hash.
func (r *FileRepository) GetByHash(ctx context.Context, hash string) (*models.MediaFile, error) {
query := `SELECT ` + fileColumns + ` FROM media_files WHERE file_hash = $1 LIMIT 1`
@@ -0,0 +1,69 @@
package scanner
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestFileRepositoryIsActivePath(t *testing.T) {
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
suffix := time.Now().UnixNano()
var folderID int
if err := pool.QueryRow(ctx, `
INSERT INTO media_folders (type, name, enabled)
VALUES ('movies', 'Active Path Test', true)
RETURNING id
`).Scan(&folderID); err != nil {
t.Fatalf("seed folder: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_files WHERE media_folder_id = $1`, folderID)
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID)
})
activePath := fmt.Sprintf("/tmp/silo-active-path-%d.mkv", suffix)
missingPath := fmt.Sprintf("/tmp/silo-missing-path-%d.mkv", suffix)
if _, err := pool.Exec(ctx, `
INSERT INTO media_files (media_folder_id, file_path, missing_since)
VALUES ($1, $2, NULL), ($1, $3, NOW())
`, folderID, activePath, missingPath); err != nil {
t.Fatalf("seed media files: %v", err)
}
repo := NewFileRepository(pool)
for _, test := range []struct {
name string
path string
want bool
}{
{name: "active", path: activePath, want: true},
{name: "missing", path: missingPath, want: false},
{name: "unknown", path: fmt.Sprintf("/tmp/silo-unknown-path-%d.mkv", suffix), want: false},
} {
t.Run(test.name, func(t *testing.T) {
got, err := repo.IsActivePath(ctx, test.path)
if err != nil {
t.Fatalf("IsActivePath: %v", err)
}
if got != test.want {
t.Fatalf("IsActivePath(%q) = %v, want %v", test.path, got, test.want)
}
})
}
}
+25 -48
View File
@@ -5,54 +5,50 @@ import (
"os"
"path/filepath"
"strings"
"github.com/Silo-Server/silo-server/internal/models"
)
// InputPathAuthorizer approves a local media input before a node passes it to
// FFmpeg. Implementations must reject protocol URLs and paths outside managed
// library roots.
// FFmpeg. Implementations must reject protocol URLs and paths outside the
// authoritative media catalog.
type InputPathAuthorizer interface {
Allowed(ctx context.Context, path string) (bool, error)
}
type mediaFolderSource interface {
List(ctx context.Context) ([]*models.MediaFolder, error)
type catalogPathSource interface {
IsActivePath(ctx context.Context, path string) (bool, error)
}
// MediaRootAuthorizer resolves the deployment's current library roots and
// permits only existing, absolute filesystem paths contained by those roots.
type MediaRootAuthorizer struct {
folders mediaFolderSource
// CatalogPathAuthorizer permits only existing regular files whose exact
// logical path is active in the media catalog. The scanner deliberately keeps
// logical paths for readable symlinks, so catalog membership is the correct
// authority: resolving the target and requiring it to remain under the logical
// library root would reject media layouts the scanner explicitly supports.
type CatalogPathAuthorizer struct {
paths catalogPathSource
}
// NewMediaRootAuthorizer creates an FFmpeg input authorizer backed by the
// authoritative media-folder repository.
func NewMediaRootAuthorizer(folders mediaFolderSource) *MediaRootAuthorizer {
return &MediaRootAuthorizer{folders: folders}
// NewCatalogPathAuthorizer creates an FFmpeg input authorizer backed by the
// authoritative media-file catalog.
func NewCatalogPathAuthorizer(paths catalogPathSource) *CatalogPathAuthorizer {
return &CatalogPathAuthorizer{paths: paths}
}
// Allowed reports whether path resolves inside one of the configured media
// roots. Symlinks are resolved on both sides so a link cannot escape a root.
func (a *MediaRootAuthorizer) Allowed(ctx context.Context, path string) (bool, error) {
if a == nil || a.folders == nil || !plainAbsolutePath(path) {
// Allowed reports whether path is an active catalog entry that resolves to a
// regular file on this node. os.Stat follows scanner-approved symlinks while
// rejecting dangling links, directories, and other non-regular inputs.
func (a *CatalogPathAuthorizer) Allowed(ctx context.Context, path string) (bool, error) {
if a == nil || a.paths == nil || !plainAbsolutePath(path) {
return false, nil
}
folders, err := a.folders.List(ctx)
active, err := a.paths.IsActivePath(ctx, path)
if err != nil {
return false, err
}
for _, folder := range folders {
if folder == nil {
continue
}
for _, root := range folder.Paths {
if existingPathWithinRoot(root, path) {
return true, nil
}
}
if !active {
return false, nil
}
return false, nil
info, err := os.Stat(path)
return err == nil && info.Mode().IsRegular(), nil
}
func plainAbsolutePath(path string) bool {
@@ -60,25 +56,6 @@ func plainAbsolutePath(path string) bool {
return path != "" && !strings.ContainsRune(path, '\x00') && filepath.IsAbs(path)
}
func existingPathWithinRoot(root, target string) bool {
if !plainAbsolutePath(root) || !plainAbsolutePath(target) {
return false
}
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
return false
}
resolvedTarget, err := filepath.EvalSymlinks(filepath.Clean(target))
if err != nil {
return false
}
info, err := os.Stat(resolvedTarget)
if err != nil || !info.Mode().IsRegular() {
return false
}
return resolvedPathContained(resolvedRoot, resolvedTarget)
}
// pathWithinRoot validates a not-yet-created output by resolving the root and
// target parent. The caller creates the basename only after this check.
func pathWithinRoot(root, target string) bool {
+45 -49
View File
@@ -7,41 +7,63 @@ import (
"path/filepath"
"runtime"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
type staticMediaFolders struct {
folders []*models.MediaFolder
err error
type staticCatalogPaths struct {
active map[string]bool
err error
}
func (s staticMediaFolders) List(context.Context) ([]*models.MediaFolder, error) {
return s.folders, s.err
func (s staticCatalogPaths) IsActivePath(_ context.Context, path string) (bool, error) {
return s.active[path], s.err
}
func TestMediaRootAuthorizerAllowsOnlyExistingFilesWithinLibraryRoots(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "movie.mkv")
func TestCatalogPathAuthorizerAllowsCataloguedRegularFilesAndSymlinks(t *testing.T) {
mediaPath := filepath.Join(t.TempDir(), "movie.mkv")
if err := os.WriteFile(mediaPath, []byte("media"), 0o600); err != nil {
t.Fatal(err)
}
authorizer := NewMediaRootAuthorizer(staticMediaFolders{
folders: []*models.MediaFolder{{Paths: []string{root}}},
})
allowed, err := authorizer.Allowed(context.Background(), mediaPath)
if err != nil || !allowed {
t.Fatalf("approved media path: allowed = %v, err = %v", allowed, err)
target := filepath.Join(t.TempDir(), "outside.mkv")
if err := os.WriteFile(target, []byte("media"), 0o600); err != nil {
t.Fatal(err)
}
link := filepath.Join(t.TempDir(), "linked.mkv")
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlinks not supported on this platform: %v", err)
}
authorizer := NewCatalogPathAuthorizer(staticCatalogPaths{active: map[string]bool{
mediaPath: true,
link: true,
}})
for _, path := range []string{mediaPath, link} {
allowed, err := authorizer.Allowed(context.Background(), path)
if err != nil || !allowed {
t.Fatalf("approved media path %q: allowed = %v, err = %v", path, allowed, err)
}
}
}
func TestCatalogPathAuthorizerRejectsUnsafeOrUncataloguedInputs(t *testing.T) {
existingUncatalogued := filepath.Join(t.TempDir(), "outside.mkv")
if err := os.WriteFile(existingUncatalogued, []byte("media"), 0o600); err != nil {
t.Fatal(err)
}
missingCatalogued := filepath.Join(t.TempDir(), "missing.mkv")
directoryCatalogued := t.TempDir()
authorizer := NewCatalogPathAuthorizer(staticCatalogPaths{active: map[string]bool{
missingCatalogued: true,
directoryCatalogued: true,
}})
for _, candidate := range []string{
"relative/movie.mkv",
"http://example.test/movie.mkv",
"file:" + mediaPath,
"concat:" + mediaPath + "|" + mediaPath,
filepath.Join(t.TempDir(), "outside.mkv"),
filepath.Join(root, "missing.mkv"),
"file:" + existingUncatalogued,
"concat:" + existingUncatalogued + "|" + existingUncatalogued,
existingUncatalogued,
missingCatalogued,
directoryCatalogued,
} {
allowed, err := authorizer.Allowed(context.Background(), candidate)
if err != nil {
@@ -53,35 +75,9 @@ func TestMediaRootAuthorizerAllowsOnlyExistingFilesWithinLibraryRoots(t *testing
}
}
func TestMediaRootAuthorizerRejectsSymlinkEscape(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink semantics differ on Windows")
}
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "outside.mkv")
if err := os.WriteFile(outside, []byte("media"), 0o600); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "linked.mkv")
if err := os.Symlink(outside, link); err != nil {
t.Fatal(err)
}
authorizer := NewMediaRootAuthorizer(staticMediaFolders{
folders: []*models.MediaFolder{{Paths: []string{root}}},
})
allowed, err := authorizer.Allowed(context.Background(), link)
if err != nil {
t.Fatal(err)
}
if allowed {
t.Fatal("symlink escape was allowed")
}
}
func TestMediaRootAuthorizerPropagatesRepositoryErrors(t *testing.T) {
func TestCatalogPathAuthorizerPropagatesRepositoryErrors(t *testing.T) {
wantErr := errors.New("database unavailable")
authorizer := NewMediaRootAuthorizer(staticMediaFolders{err: wantErr})
authorizer := NewCatalogPathAuthorizer(staticCatalogPaths{err: wantErr})
allowed, err := authorizer.Allowed(context.Background(), "/media/movie.mkv")
if allowed || !errors.Is(err, wantErr) {
t.Fatalf("allowed = %v, err = %v", allowed, err)
+1 -1
View File
@@ -375,7 +375,7 @@ func TestHandleDownloadPrepareRejectsUnavailableConfig(t *testing.T) {
func TestHandleStartRejectsUnapprovedInputPath(t *testing.T) {
server := newTestServer(t)
server.inputPaths = NewMediaRootAuthorizer(staticMediaFolders{})
server.inputPaths = NewCatalogPathAuthorizer(staticCatalogPaths{})
body := []byte(`{"session_id":"unsafe-input","input_path":"http://example.test/movie.mkv"}`)
req := httptest.NewRequest(http.MethodPost, "/transcode/start", bytes.NewReader(body))