Skip to content

Commit f5ae27f

Browse files
author
Vyacheslav
committed
v1.11.1: повышение устойчивости офлайн анализа подсистем из выгрузки к некорректным и специально сформированным каталогам
2 parents 11d0fd7 + 564dcd9 commit f5ae27f

4 files changed

Lines changed: 420 additions & 10 deletions

File tree

dump/subsystem_dirposition_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//go:build unix
2+
3+
package dump
4+
5+
import (
6+
"path/filepath"
7+
"testing"
8+
"time"
9+
)
10+
11+
// These tests extend the writer-less-FIFO DoS coverage from the subsystem FILE
12+
// positions (subsystem_nonregular_test.go) to the subsystem DIRECTORY positions. A
13+
// directory open that is not type-checked first blocks forever on a writer-less FIFO
14+
// planted at that position, and (unlike a bounded read) the blocked open() is not
15+
// interruptible by ctx, so parseBounded's timeout IS the reproduction. plantFIFO,
16+
// parseBounded and the assertion helpers are shared from subsystem_nonregular_test.go
17+
// / subsystem_testhelper_test.go.
18+
19+
// enumBounded runs EnumerateAppliedObjects under a deadline; a non-regular applied
20+
// folder that reached an unguarded open would block it forever (a DoS), so a timeout
21+
// here reproduces that. EnumerateAppliedObjects has no ctx, so bounding is external.
22+
func enumBounded(t *testing.T, dir string) []string {
23+
t.Helper()
24+
ch := make(chan []string, 1)
25+
go func() { ch <- EnumerateAppliedObjects(dir) }()
26+
select {
27+
case r := <-ch:
28+
return r
29+
case <-time.After(10 * time.Second):
30+
t.Fatal("EnumerateAppliedObjects did not return: a non-regular folder blocked enumeration (DoS)")
31+
return nil
32+
}
33+
}
34+
35+
// DP-1 (top directory position): a writer-less FIFO planted exactly where the
36+
// top-level Subsystems/ directory is expected must not block the parse. Layout
37+
// detection opens Subsystems/ first, so this is the earliest and most severe hang.
38+
// After the fix it is refused, the whole-tree drop is NAMED (never silent), the walk
39+
// stays BOUNDED, and no error leaks the server-side path.
40+
func TestParse_WriterlessFIFOAtSubsystemsDir_BoundedAndNamed(t *testing.T) {
41+
dir := t.TempDir()
42+
plantFIFO(t, filepath.Join(dir, "Subsystems"))
43+
44+
subs, warnings := parseBounded(t, dir)
45+
if len(subs) != 0 {
46+
t.Errorf("a FIFO at the Subsystems position yields no subsystems; got %v", flattenNames(subs))
47+
}
48+
if !warningsContain(warnings, "каталог подсистем") {
49+
t.Errorf("the refused Subsystems catalog must be NAMED in a warning; warnings=%v", warnings)
50+
}
51+
}
52+
53+
// DP-2 (nested recursion directory position): a valid Hierarchical parent whose
54+
// child-recursion directory Subsystems/<Name>/Subsystems is a writer-less FIFO must
55+
// not block the walk. walkHierarchical recurses into that directory unconditionally
56+
// after parsing the parent, so an unguarded open there hangs the walk exactly like
57+
// the top position. After the fix the parent still parses, the refused recursion
58+
// directory is NAMED (by its parent), and the walk stays BOUNDED.
59+
func TestWalkHierarchical_WriterlessFIFOAtRecursionDir_BoundedAndNamed(t *testing.T) {
60+
dir := t.TempDir()
61+
secWrite(t, filepath.Join(dir, "Subsystems", "Parent.xml"), secSubBody("Parent"))
62+
plantFIFO(t, filepath.Join(dir, "Subsystems", "Parent", "Subsystems"))
63+
64+
subs, warnings := parseBounded(t, dir)
65+
names := flattenNames(subs)
66+
if !containsStr(names, "Parent") {
67+
t.Errorf("the valid parent must still parse; names=%v", names)
68+
}
69+
if !warningsContain(warnings, "Parent") {
70+
t.Errorf("the refused recursion directory must be NAMED (by its parent); warnings=%v", warnings)
71+
}
72+
}
73+
74+
// DP-3 (Ext intermediate directory position): a writer-less FIFO named Ext at
75+
// Subsystems/<N>/Ext. This position is reached only by an lstat THROUGH the FIFO
76+
// (Subsystems/<N>/Ext/Subsystem.xml), which returns ENOTDIR without ever opening the
77+
// FIFO, so it never blocked; this test documents that audit finding and guards it
78+
// from regressing. A valid Ext sibling must still parse and the FIFO child is NAMED.
79+
func TestWalkExt_WriterlessFIFOAtExtDir_BoundedAndNamed(t *testing.T) {
80+
dir := t.TempDir()
81+
secWrite(t, filepath.Join(dir, "Subsystems", "Good", "Ext", "Subsystem.xml"), secSubBody("Good"))
82+
plantFIFO(t, filepath.Join(dir, "Subsystems", "Blk", "Ext"))
83+
84+
subs, warnings := parseBounded(t, dir)
85+
names := flattenNames(subs)
86+
if !containsStr(names, "Good") {
87+
t.Errorf("the valid Ext sibling must still parse; names=%v", names)
88+
}
89+
if containsStr(names, "Blk") {
90+
t.Errorf("the FIFO-Ext child must not parse; names=%v", names)
91+
}
92+
if !warningsContain(warnings, "Blk") {
93+
t.Errorf("the FIFO-Ext child must be NAMED; warnings=%v", warnings)
94+
}
95+
}
96+
97+
// DP-4 (applied-kind folder position, universe enumerator): a writer-less FIFO named
98+
// like an applied-kind folder (Documents). EnumerateAppliedObjects only descends
99+
// directory entries (DirEntry.IsDir gate), so a FIFO entry is skipped before any
100+
// open; this documents that audit finding, and the hardened directory open is the
101+
// defense-in-depth backstop. A valid applied object must still enumerate, bounded.
102+
func TestEnumerateAppliedObjects_WriterlessFIFOFolder_Bounded(t *testing.T) {
103+
dir := t.TempDir()
104+
secWrite(t, filepath.Join(dir, "Catalogs", "Валюты.xml"), objBody("Валюты"))
105+
plantFIFO(t, filepath.Join(dir, "Documents"))
106+
107+
got := enumBounded(t, dir)
108+
if !containsStr(got, "Справочник.Валюты") {
109+
t.Errorf("the valid applied object must still enumerate; got %v", got)
110+
}
111+
}

dump/subsystem_reader.go

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,37 @@ var (
4848
// dropped rather than wrapped. Customer-facing RU: no тире.
4949
var errReadSubsystemsRoot = errors.New("не удалось прочитать каталог подсистем дампа")
5050

51+
// errNotDirectory marks a dump path that occupies a directory position but is not a
52+
// directory: a FIFO, socket, device, plain file, or a symlink standing in for the
53+
// directory. Opening such a position unconditionally is the DoS this guard closes: a
54+
// writer-less FIFO at a directory position blocks the open forever and, unlike a
55+
// bounded read, a blocked open() cannot be interrupted by ctx. Every directory read
56+
// refuses a non-directory BEFORE that blocking open, mirroring the subsystem-file
57+
// guard, and NAMES the refusal so the drop is never silent.
58+
var errNotDirectory = errors.New("dump path is not a directory")
59+
60+
// errDumpDirNotDirectory is the path-free RU refusal returned by ParseAllSubsystemsCtx
61+
// when dumpDir ITSELF is a non-directory node (a FIFO, socket, device, plain file, or a
62+
// symlink resolving to one). It is errNotDirectory's guard one level up, at the dump
63+
// root: os.OpenRoot(dumpDir) on a writer-less FIFO blocks on an open() that ctx cannot
64+
// interrupt, so the node type is checked with os.Stat (which does not open it) BEFORE
65+
// the open. Customer-facing RU: no тире, never an absolute path.
66+
var errDumpDirNotDirectory = errors.New("каталог дампа имеет неверный тип")
67+
68+
// dumpDirIsNonDir reports whether dumpDir exists but is NOT a directory: a FIFO,
69+
// socket, device, plain file, or a symlink resolving to one of those. os.Stat follows a
70+
// symlink and, crucially, does NOT open the node, so it returns immediately on a
71+
// writer-less FIFO where os.OpenRoot would block on the ctx-uninterruptible open (the
72+
// DoS this guards). A missing path or a permission error (err != nil) and a genuine
73+
// directory (including a symlink to a directory) all report false, so every
74+
// os.OpenRoot(dumpDir) entry point falls through to its existing missing / unreadable /
75+
// symlink-to-directory contract unchanged; only a non-directory dumpDir is refused
76+
// before the blocking open.
77+
func dumpDirIsNonDir(dumpDir string) bool {
78+
fi, err := os.Stat(dumpDir)
79+
return err == nil && !fi.IsDir()
80+
}
81+
5182
// Subsystem is one node of the dump's subsystem forest: its canonical full path,
5283
// display synonym, direct member composition (Content, canonical RU full names)
5384
// and any nested child subsystems.
@@ -266,6 +297,9 @@ const (
266297
// so the walk returns an empty tree; a present but unreadable Subsystems/ yields
267298
// the path-free errReadSubsystemsRoot.
268299
func detectSubsystemLayout(dumpDir string) (subsystemLayout, error) {
300+
if dumpDirIsNonDir(dumpDir) {
301+
return layoutExt, nil // non-directory dumpDir: default layout, empty tree (never open it)
302+
}
269303
root, err := os.OpenRoot(dumpDir)
270304
if err != nil {
271305
return layoutExt, nil
@@ -285,9 +319,11 @@ func detectLayoutInRoot(root *os.Root) (subsystemLayout, error) {
285319
if errors.Is(err, os.ErrPermission) {
286320
return layoutExt, errReadSubsystemsRoot // present but unreadable: path-free
287321
}
288-
// Containment refusal (an escaping Subsystems/ symlink, os.Root "path
289-
// escapes") or any other read error: never probe outside; default layout so
290-
// the walk returns an empty tree.
322+
// Containment refusal (an escaping Subsystems/ symlink, os.Root "path escapes"),
323+
// a non-directory Subsystems position (errNotDirectory: a FIFO/socket/device/
324+
// plain file, refused BEFORE the blocking open by openDirInRoot so detection
325+
// never hangs), or any other read error: never probe outside; default layout so
326+
// the walk returns an empty tree (the walk re-encounters and NAMES it).
291327
return layoutExt, nil
292328
}
293329
for _, e := range entries {
@@ -327,13 +363,18 @@ func ParseAllSubsystems(dumpDir string) ([]Subsystem, error) {
327363
// (warnings) alongside the parsed tree. Hierarchical uses a disk-walk recursion;
328364
// Ext preserves the nested-children-from-XML behaviour. Every filesystem access is
329365
// confined to the dump via an os.Root (a crafted symlink that escapes the dump at
330-
// ANY path component is refused by the OS primitive, never followed), recursion is
331-
// depth-capped, and each dropped subsystem is NAMED in warnings (never silently
332-
// dropped).
366+
// ANY path component is refused by the OS primitive, never followed), every directory
367+
// AND file position is type-checked before it is opened (a writer-less FIFO planted at
368+
// any position is refused before the blocking open rather than hanging the walk),
369+
// recursion is depth-capped, and each dropped subsystem or directory is NAMED in
370+
// warnings (never silently dropped).
333371
func ParseAllSubsystemsCtx(ctx context.Context, dumpDir string) ([]Subsystem, []string, error) {
334372
if err := ctx.Err(); err != nil {
335373
return nil, nil, err
336374
}
375+
if dumpDirIsNonDir(dumpDir) {
376+
return nil, nil, errDumpDirNotDirectory // dumpDir itself is a FIFO/socket/device/file: refuse before the blocking open
377+
}
337378
root, err := os.OpenRoot(dumpDir)
338379
if err != nil {
339380
if errors.Is(err, os.ErrNotExist) {
@@ -381,20 +422,30 @@ func (w *subsystemWalker) warn(name, reason string) {
381422
w.warnings = append(w.warnings, fmt.Sprintf("подсистема %s: %s", n, reason))
382423
}
383424

425+
// warnSubsystemsRoot NAMES a refused top-level Subsystems catalog: a non-directory at
426+
// the Subsystems position (a FIFO, socket, device, plain file, or a symlink standing in
427+
// for it) drops the entire tree, which must never be silent. Path-free, no тире.
428+
func (w *subsystemWalker) warnSubsystemsRoot() {
429+
w.warnings = append(w.warnings, "каталог подсистем дампа имеет неверный тип и пропущен")
430+
}
431+
384432
// readDir reads a dump-relative directory confined to the walker's os.Root.
385433
func (w *subsystemWalker) readDir(rel string) ([]os.DirEntry, error) {
386434
return readDirInRoot(w.root, rel)
387435
}
388436

389437
// readDirInRoot reads a directory confined to root and returns its entries sorted
390438
// by name (matching os.ReadDir), so the hierarchical walk's on-disk child ordering
391-
// is deterministic. os.Root confines EVERY path component beneath the root using
392-
// the OS primitive (openat2 RESOLVE_BENEATH on Linux, equivalents elsewhere), so an
393-
// escaping symlink at ANY depth is refused with a "path escapes" error rather than
439+
// is deterministic. The directory is opened through openDirInRoot, which refuses any
440+
// non-directory at that position (a FIFO/socket/device/plain file, or a symlink
441+
// standing in for the directory) BEFORE the blocking open, so a planted writer-less
442+
// FIFO at a directory position can never hang the walk. os.Root confines EVERY path
443+
// component beneath the root using the OS primitive (openat2 RESOLVE_BENEATH on Linux,
444+
// equivalents elsewhere), so an escaping symlink at ANY depth is refused rather than
394445
// followed out of the dump. os.File.ReadDir (unlike os.ReadDir) returns entries in
395446
// directory order, so they are sorted here.
396447
func readDirInRoot(root *os.Root, rel string) ([]os.DirEntry, error) {
397-
f, err := root.Open(rel)
448+
f, err := openDirInRoot(root, rel)
398449
if err != nil {
399450
return nil, err
400451
}
@@ -407,6 +458,38 @@ func readDirInRoot(root *os.Root, rel string) ([]os.DirEntry, error) {
407458
return entries, nil
408459
}
409460

461+
// openDirInRoot opens rel as a directory confined to root, refusing any non-directory
462+
// at that position. It mirrors openSubsystemFile's guard, for directories: it lstats
463+
// and requires Mode().IsDir() BEFORE the open, so a writer-less FIFO, socket, device,
464+
// or a symlink standing in for the directory can never reach the blocking open() that
465+
// ctx cannot interrupt. It then opens with O_NONBLOCK on unix, so a directory swapped
466+
// for a FIFO in the check->use window still returns immediately instead of blocking,
467+
// and fstats the descriptor to require it be the very directory the lstat saw (IsDir
468+
// plus os.SameFile), which closes that TOCTOU window. Containment across every path
469+
// component is enforced by os.Root, so an escaping symlink at ANY depth is refused
470+
// rather than followed. A genuinely absent path returns os.ErrNotExist (callers treat
471+
// it as a normal empty position); a non-directory returns errNotDirectory (callers
472+
// NAME it); permission and containment ("path escapes") errors propagate unchanged.
473+
func openDirInRoot(root *os.Root, rel string) (*os.File, error) {
474+
li, err := root.Lstat(rel)
475+
if err != nil {
476+
return nil, err
477+
}
478+
if !li.Mode().IsDir() {
479+
return nil, errNotDirectory
480+
}
481+
f, err := root.OpenFile(rel, os.O_RDONLY|nonblockOpenFlag, 0)
482+
if err != nil {
483+
return nil, err
484+
}
485+
fi, err := f.Stat()
486+
if err != nil || !fi.IsDir() || !os.SameFile(li, fi) {
487+
_ = f.Close()
488+
return nil, errNotDirectory
489+
}
490+
return f, nil
491+
}
492+
410493
// openSubsystemFile opens a dump-relative subsystem file for reading, confined to
411494
// the walker's os.Root, and returns it only when it is a plain regular file.
412495
// Containment across every path component is enforced by os.Root: an escaping
@@ -475,6 +558,10 @@ func (w *subsystemWalker) walkExt(relRoot string) ([]Subsystem, error) {
475558
if errors.Is(err, os.ErrPermission) {
476559
return nil, errReadSubsystemsRoot // path-free
477560
}
561+
if errors.Is(err, errNotDirectory) {
562+
w.warnSubsystemsRoot() // a non-directory Subsystems position: NAME the drop
563+
return nil, nil
564+
}
478565
return nil, nil // containment refusal or other: empty tree, never leak
479566
}
480567
out := make([]Subsystem, 0, len(entries))
@@ -532,8 +619,19 @@ func (w *subsystemWalker) walkHierarchical(relRoot, parentPath string, depth int
532619
if errors.Is(err, os.ErrPermission) {
533620
return nil, errReadSubsystemsRoot // path-free, top level only
534621
}
622+
if errors.Is(err, errNotDirectory) {
623+
w.warnSubsystemsRoot() // a non-directory Subsystems position: NAME the drop
624+
return nil, nil
625+
}
535626
return nil, nil // containment refusal or other at top: empty tree
536627
}
628+
if errors.Is(err, errNotDirectory) {
629+
// A non-directory at the recursion position (a FIFO/socket/device, or a
630+
// symlink standing in for the child Subsystems/ directory): refused BEFORE
631+
// the blocking open by openDirInRoot; skip its subtree, NAMED by its parent.
632+
w.warn(parentPath, "вложенный каталог подсистем имеет неверный тип и пропущен")
633+
return nil, nil
634+
}
537635
// A nested recursion directory that is unreadable or escapes the dump (an
538636
// intermediate directory symlink refused by os.Root): skip its subtree, NAMED.
539637
w.warn(parentPath, "каталог подсистемы недоступен и пропущен")

0 commit comments

Comments
 (0)