Skip to content

Commit ff1d116

Browse files
committed
Bound user-database file reads in openUserFile
openUserFile now stats the opened file, refuses anything that is not a regular file, and wraps the returned fs.File so reads are capped at maxUserFileBytes (10 MiB). All callers of openUserFile read either etc/passwd or etc/group; both are regular files on real systems, well under the cap. The cap and the regular-file check together bound parser memory use when reading user-database files of unexpected shape or size. Adds tests for the cap and for the non-regular file rejection. The cap test covers three boundary points: a small pad (trailing entry parsed), a pad placing the entry's last byte exactly on the cap (still parsed), and a pad past the cap (read returns an "exceeds" error). Assisted-by: Antigravity Signed-off-by: Chris Henzie <chrishenzie@gmail.com> (cherry picked from commit 7b05ec4) Signed-off-by: Chris Henzie <chrishenzie@gmail.com>
1 parent 2315484 commit ff1d116

2 files changed

Lines changed: 200 additions & 2 deletions

File tree

pkg/oci/spec_opts.go

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"encoding/json"
2525
"errors"
2626
"fmt"
27+
"io"
2728
"io/fs"
2829
"maps"
2930
"math"
@@ -1823,10 +1824,13 @@ type readLinker interface {
18231824
// openUserFile attempts to open a file within the root fs.
18241825
// It handles cases where the file is an absolute symlink (e.g., NixOS /etc/passwd -> /nix/store/...),
18251826
// which triggers "path escapes from parent" errors in Go 1.24+ due to stricter os.DirFS validation.
1827+
//
1828+
// The returned file rejects non-regular sources and returns an error if more
1829+
// than maxUserFileBytes are read from it.
18261830
func openUserFile(root fs.FS, name string) (fs.File, error) {
18271831
f, err := root.Open(name)
18281832
if err == nil {
1829-
return f, nil
1833+
return wrapUserFile(f, name)
18301834
}
18311835

18321836
// Check if the FS implements our local ReadLink interface.
@@ -1843,7 +1847,11 @@ func openUserFile(root fs.FS, name string) (fs.File, error) {
18431847
if rerr == nil {
18441848
// filepath.Rel might return OS-specific separators (backslashes on Windows).
18451849
// fs.Open strictly expects forward slashes, so we convert it.
1846-
return root.Open(filepath.ToSlash(rel))
1850+
f, oerr := root.Open(filepath.ToSlash(rel))
1851+
if oerr != nil {
1852+
return nil, oerr
1853+
}
1854+
return wrapUserFile(f, name)
18471855
}
18481856
}
18491857
}
@@ -1852,3 +1860,47 @@ func openUserFile(root fs.FS, name string) (fs.File, error) {
18521860
// Return the original error if we couldn't resolve it
18531861
return nil, err
18541862
}
1863+
1864+
// maxUserFileBytes caps how much data is read from any user-database file
1865+
// opened via openUserFile. Real systems keep these files well under 1 MiB;
1866+
// 10 MiB is generous headroom while keeping peak memory during
1867+
// user.ParsePasswd/ParseGroup bounded to single-digit MiB.
1868+
const maxUserFileBytes = 10 << 20
1869+
1870+
// wrapUserFile rejects non-regular sources and returns an fs.File that
1871+
// errors out if more than maxUserFileBytes are read from it.
1872+
func wrapUserFile(f fs.File, name string) (fs.File, error) {
1873+
info, err := f.Stat()
1874+
if err != nil {
1875+
f.Close()
1876+
return nil, fmt.Errorf("stat %s: %w", name, err)
1877+
}
1878+
if !info.Mode().IsRegular() {
1879+
f.Close()
1880+
return nil, fmt.Errorf("%s is not a regular file", name)
1881+
}
1882+
return &limitedFile{
1883+
File: f,
1884+
// Allow one byte past the cap so an overflow surfaces as an
1885+
// error rather than a silent EOF that the parser would treat as
1886+
// a clean end-of-file (and miss any entries past the cap).
1887+
r: &io.LimitedReader{R: f, N: maxUserFileBytes + 1},
1888+
name: name,
1889+
}, nil
1890+
}
1891+
1892+
// limitedFile is an fs.File whose Read returns an error once more than
1893+
// maxUserFileBytes have been read.
1894+
type limitedFile struct {
1895+
fs.File
1896+
r *io.LimitedReader
1897+
name string
1898+
}
1899+
1900+
func (l *limitedFile) Read(p []byte) (int, error) {
1901+
n, err := l.r.Read(p)
1902+
if l.r.N == 0 {
1903+
return n, fmt.Errorf("%q exceeds %d bytes", l.name, maxUserFileBytes)
1904+
}
1905+
return n, err
1906+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package oci
18+
19+
import (
20+
"bytes"
21+
"errors"
22+
"io/fs"
23+
"testing"
24+
"testing/fstest"
25+
"time"
26+
27+
"github.com/moby/sys/user"
28+
"github.com/stretchr/testify/assert"
29+
)
30+
31+
// TestOpenUserFileCapsReads asserts the boundary behavior of the read cap:
32+
// well below, ending exactly at, and past maxUserFileBytes.
33+
func TestOpenUserFileCapsReads(t *testing.T) {
34+
t.Parallel()
35+
36+
beyond := []byte("\nbeyond:x:42:\n")
37+
38+
for _, tc := range []struct {
39+
name string
40+
padBytes int
41+
wantGids []uint32
42+
wantErr bool
43+
}{
44+
{
45+
name: "pad below cap, beyond is parsed",
46+
padBytes: 100,
47+
wantGids: []uint32{42},
48+
},
49+
{
50+
name: "beyond ends exactly at cap, is parsed",
51+
padBytes: maxUserFileBytes - len(beyond),
52+
wantGids: []uint32{42},
53+
},
54+
{
55+
name: "pad past cap, read errors out",
56+
padBytes: maxUserFileBytes,
57+
wantErr: true,
58+
},
59+
} {
60+
t.Run(tc.name, func(t *testing.T) {
61+
t.Parallel()
62+
63+
data := append(bytes.Repeat([]byte{0}, tc.padBytes), beyond...)
64+
fsys := fstest.MapFS{
65+
"etc/group": &fstest.MapFile{Data: data, Mode: 0o644},
66+
}
67+
68+
gids, err := getSupplementalGroupsFromFS(fsys, func(g user.Group) bool {
69+
return g.Name == "beyond"
70+
})
71+
if tc.wantErr {
72+
assert.ErrorContains(t, err, "exceeds")
73+
return
74+
}
75+
assert.NoError(t, err)
76+
assert.Equal(t, tc.wantGids, gids)
77+
})
78+
}
79+
}
80+
81+
// TestOpenUserFileRejectsNonRegularFiles verifies that non-regular files
82+
// are refused before any byte is read from them.
83+
func TestOpenUserFileRejectsNonRegularFiles(t *testing.T) {
84+
t.Parallel()
85+
86+
for _, tc := range []struct {
87+
name string
88+
mode fs.FileMode
89+
}{
90+
{name: "char device", mode: fs.ModeDevice | fs.ModeCharDevice | 0o666},
91+
{name: "socket", mode: fs.ModeSocket | 0o666},
92+
} {
93+
t.Run(tc.name, func(t *testing.T) {
94+
t.Parallel()
95+
96+
f := &nonRegularFile{mode: tc.mode}
97+
rootFS := singleFileFS{name: "etc/group", file: f}
98+
99+
_, err := getSupplementalGroupsFromFS(rootFS, nil)
100+
assert.Error(t, err)
101+
assert.False(t, f.readCalled, "Read should not be called on non-regular file")
102+
})
103+
}
104+
}
105+
106+
// nonRegularFile implements fs.File and reports a configurable non-regular
107+
// mode via Stat.
108+
type nonRegularFile struct {
109+
mode fs.FileMode
110+
readCalled bool
111+
}
112+
113+
func (f *nonRegularFile) Read([]byte) (int, error) {
114+
f.readCalled = true
115+
return 0, errors.New("read should not be called on non-regular file")
116+
}
117+
118+
func (f *nonRegularFile) Stat() (fs.FileInfo, error) {
119+
return nonRegularFileInfo{mode: f.mode}, nil
120+
}
121+
func (f *nonRegularFile) Close() error { return nil }
122+
123+
type nonRegularFileInfo struct {
124+
mode fs.FileMode
125+
}
126+
127+
func (nonRegularFileInfo) Name() string { return "group" }
128+
func (nonRegularFileInfo) Size() int64 { return 0 }
129+
func (i nonRegularFileInfo) Mode() fs.FileMode { return i.mode }
130+
func (nonRegularFileInfo) ModTime() time.Time { return time.Time{} }
131+
func (nonRegularFileInfo) IsDir() bool { return false }
132+
func (nonRegularFileInfo) Sys() any { return nil }
133+
134+
// singleFileFS routes a single name to a single fs.File and returns
135+
// fs.ErrNotExist for everything else.
136+
type singleFileFS struct {
137+
name string
138+
file fs.File
139+
}
140+
141+
func (s singleFileFS) Open(name string) (fs.File, error) {
142+
if name == s.name {
143+
return s.file, nil
144+
}
145+
return nil, fs.ErrNotExist
146+
}

0 commit comments

Comments
 (0)