Skip to content

Commit 705ba3b

Browse files
committed
imagebuildah: try to rein in use of transport names in image specs
Try to limit which image transports we accept in stages, and scope the ones that use path names to the context directory. At some point anything that isn't an image ID or pullable spec should start being rejected. Signed-off-by: Nalin Dahyabhai <[email protected]>
1 parent 7c58fc1 commit 705ba3b

File tree

4 files changed

+820
-1
lines changed

4 files changed

+820
-1
lines changed

imagebuildah/stage_executor.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
buildahdocker "github.com/containers/buildah/docker"
2121
"github.com/containers/buildah/internal"
2222
"github.com/containers/buildah/internal/metadata"
23+
"github.com/containers/buildah/internal/sanitize"
2324
"github.com/containers/buildah/internal/tmpdir"
2425
internalUtil "github.com/containers/buildah/internal/util"
2526
"github.com/containers/buildah/pkg/parse"
@@ -937,6 +938,29 @@ func (s *stageExecutor) UnrecognizedInstruction(step *imagebuilder.Step) error {
937938
return errors.New(err)
938939
}
939940

941+
// sanitizeFrom limits which image names (with or without transport prefixes)
942+
// we'll accept. For those it accepts which refer to filesystem objects, where
943+
// relative path names are evaluated relative to "contextDir", it will create a
944+
// copy of the original image, under "tmpdir", which contains no symbolic
945+
// links, and return either the original image reference or a reference to a
946+
// sanitized copy which should be used instead.
947+
func (s *stageExecutor) sanitizeFrom(from, tmpdir string) (newFrom string, err error) {
948+
transportName, restOfImageName, maybeHasTransportName := strings.Cut(from, ":")
949+
if !maybeHasTransportName || transports.Get(transportName) == nil {
950+
if _, err = reference.ParseNormalizedNamed(from); err == nil {
951+
// this is a normal-looking image-in-a-registry-or-named-in-storage name
952+
return from, nil
953+
}
954+
if img, err := s.executor.store.Image(from); img != nil && err == nil {
955+
// this is an image ID
956+
return from, nil
957+
}
958+
return "", fmt.Errorf("parsing image name %q: %w", from, err)
959+
}
960+
// TODO: drop this part and just return an error... someday
961+
return sanitize.ImageName(transportName, restOfImageName, s.executor.contextDir, tmpdir)
962+
}
963+
940964
// prepare creates a working container based on the specified image, or if one
941965
// isn't specified, the first argument passed to the first FROM instruction we
942966
// can find in the stage's parsed tree.
@@ -953,6 +977,10 @@ func (s *stageExecutor) prepare(ctx context.Context, from string, initializeIBCo
953977
}
954978
from = base
955979
}
980+
sanitizedFrom, err := s.sanitizeFrom(from, tmpdir.GetTempDir())
981+
if err != nil {
982+
return nil, fmt.Errorf("invalid base image specification %q: %w", from, err)
983+
}
956984
displayFrom := from
957985
if ib.Platform != "" {
958986
displayFrom = "--platform=" + ib.Platform + " " + displayFrom
@@ -992,7 +1020,7 @@ func (s *stageExecutor) prepare(ctx context.Context, from string, initializeIBCo
9921020

9931021
builderOptions := buildah.BuilderOptions{
9941022
Args: ib.Args,
995-
FromImage: from,
1023+
FromImage: sanitizedFrom,
9961024
GroupAdd: s.executor.groupAdd,
9971025
PullPolicy: pullPolicy,
9981026
ContainerSuffix: s.executor.containerSuffix,

internal/sanitize/sanitize.go

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
package sanitize
2+
3+
import (
4+
"archive/tar"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"os"
9+
"path"
10+
"path/filepath"
11+
"strings"
12+
13+
"github.com/sirupsen/logrus"
14+
directoryTransport "go.podman.io/image/v5/directory"
15+
dockerTransport "go.podman.io/image/v5/docker"
16+
dockerArchiveTransport "go.podman.io/image/v5/docker/archive"
17+
ociArchiveTransport "go.podman.io/image/v5/oci/archive"
18+
ociLayoutTransport "go.podman.io/image/v5/oci/layout"
19+
openshiftTransport "go.podman.io/image/v5/openshift"
20+
"go.podman.io/image/v5/pkg/compression"
21+
"go.podman.io/storage/pkg/archive"
22+
"go.podman.io/storage/pkg/chrootarchive"
23+
)
24+
25+
// create a temporary file to use as a destination archive
26+
func newArchiveDestination(tmpdir string) (tw *tar.Writer, f *os.File, err error) {
27+
if f, err = os.CreateTemp(tmpdir, "buildah-archive-"); err != nil {
28+
return nil, nil, fmt.Errorf("creating temporary copy of base image: %w", err)
29+
}
30+
tw = tar.NewWriter(f)
31+
return tw, f, nil
32+
}
33+
34+
// create a temporary directory to use as a new OCI layout or "image in a directory" image
35+
func newDirectoryDestination(tmpdir string) (string, error) {
36+
newDirectory, err := os.MkdirTemp(tmpdir, "buildah-layout-")
37+
if err != nil {
38+
return "", fmt.Errorf("creating temporary copy of base image: %w", err)
39+
}
40+
return newDirectory, nil
41+
}
42+
43+
// create an archive containing a single item from the build context
44+
func newSingleItemArchive(contextDir, archiveSource string) (io.ReadCloser, error) {
45+
for {
46+
// try to make sure the archiver doesn't get thrown by relative prefixes
47+
if strings.HasPrefix(archiveSource, "/") && archiveSource != "/" {
48+
archiveSource = strings.TrimPrefix(archiveSource, "/")
49+
continue
50+
} else if strings.HasPrefix(archiveSource, "./") && archiveSource != "./" {
51+
archiveSource = strings.TrimPrefix(archiveSource, "./")
52+
continue
53+
}
54+
break
55+
}
56+
// grab only that one file, ignore anything and everything else
57+
tarOptions := &archive.TarOptions{
58+
IncludeFiles: []string{path.Clean(archiveSource)},
59+
ExcludePatterns: []string{"**"},
60+
}
61+
return chrootarchive.Tar(contextDir, tarOptions, contextDir)
62+
}
63+
64+
// Write this header/content combination to a tar writer, making sure that it
65+
// doesn't include any symbolic links that point to something which hasn't
66+
// already been seen in this archive. Overwrites the contents of `*hdr`.
67+
func writeToArchive(tw *tar.Writer, hdr *tar.Header, content io.Reader, seenEntries map[string]struct{}, convertSymlinksToHardlinks bool) error {
68+
// write to the archive writer
69+
hdr.Name = path.Clean("/" + hdr.Name)
70+
if hdr.Name != "/" {
71+
hdr.Name = strings.TrimPrefix(hdr.Name, "/")
72+
}
73+
seenEntries[hdr.Name] = struct{}{}
74+
switch hdr.Typeflag {
75+
case tar.TypeDir, tar.TypeReg, tar.TypeLink:
76+
// all good
77+
case tar.TypeSymlink:
78+
// resolve the target of the symlink
79+
linkname := hdr.Linkname
80+
if !path.IsAbs(linkname) {
81+
linkname = path.Join("/"+path.Dir(hdr.Name), linkname)
82+
}
83+
linkname = path.Clean(linkname)
84+
if linkname != "/" {
85+
linkname = strings.TrimPrefix(linkname, "/")
86+
}
87+
if _, validTarget := seenEntries[linkname]; !validTarget {
88+
return fmt.Errorf("invalid symbolic link from %q to %q (%q) in archive", hdr.Name, hdr.Linkname, linkname)
89+
}
90+
rel, err := filepath.Rel(filepath.FromSlash(path.Dir("/"+hdr.Name)), filepath.FromSlash("/"+linkname))
91+
if err != nil {
92+
return fmt.Errorf("computing relative path from %q to %q in archive", hdr.Name, linkname)
93+
}
94+
rel = filepath.ToSlash(rel)
95+
if convertSymlinksToHardlinks {
96+
// rewrite as a hard link for oci-archive, which gets
97+
// extracted into a temporary directory to be read, but
98+
// not for docker-archive, which is read directly from
99+
// the unextracted archive file, in a way which doesn't
100+
// understand hard links
101+
hdr.Typeflag = tar.TypeLink
102+
hdr.Linkname = linkname
103+
} else {
104+
// ensure it's a relative symlink inside of the tree
105+
// for docker-archive
106+
hdr.Linkname = rel
107+
}
108+
default:
109+
return fmt.Errorf("rewriting archive of base image: disallowed entry type %c", hdr.Typeflag)
110+
}
111+
if err := tw.WriteHeader(hdr); err != nil {
112+
return fmt.Errorf("rewriting archive of base image: %w", err)
113+
}
114+
if hdr.Typeflag == tar.TypeReg {
115+
n, err := io.Copy(tw, content)
116+
if err != nil {
117+
return fmt.Errorf("copying content for %q in base image: %w", hdr.Name, err)
118+
}
119+
if n != hdr.Size {
120+
return fmt.Errorf("short write writing %q in base image: %d != %d", hdr.Name, n, hdr.Size)
121+
}
122+
}
123+
return nil
124+
}
125+
126+
// write this header and possible content to a directory tree
127+
func writeToDirectory(root string, hdr *tar.Header, content io.Reader) error {
128+
var err error
129+
// write this item directly to a directory tree. the reader won't care
130+
// much about permissions or datestamps, so don't bother setting them
131+
hdr.Name = path.Clean("/" + hdr.Name)
132+
newName := filepath.Join(root, filepath.FromSlash(hdr.Name))
133+
switch hdr.Typeflag {
134+
case tar.TypeDir:
135+
err = os.Mkdir(newName, 0o700)
136+
case tar.TypeReg:
137+
err = func() error {
138+
var f *os.File
139+
f, err := os.OpenFile(newName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
140+
if err != nil {
141+
return fmt.Errorf("copying content for %q in base image: %w", hdr.Name, err)
142+
}
143+
n, err := io.Copy(f, content)
144+
if err != nil {
145+
f.Close()
146+
return fmt.Errorf("copying content for %q in base image: %w", hdr.Name, err)
147+
}
148+
if n != hdr.Size {
149+
f.Close()
150+
return fmt.Errorf("short write writing %q in base image: %d != %d", hdr.Name, n, hdr.Size)
151+
}
152+
return f.Close()
153+
}()
154+
case tar.TypeLink:
155+
linkName := path.Clean("/" + hdr.Linkname)
156+
oldName := filepath.Join(root, filepath.FromSlash(linkName))
157+
err = os.Link(oldName, newName)
158+
case tar.TypeSymlink: // convert it to a hard link or absolute symlink
159+
var oldName string
160+
if !path.IsAbs(path.Clean("/" + hdr.Linkname)) {
161+
linkName := path.Join("/"+path.Dir(hdr.Name), hdr.Linkname)
162+
oldName = filepath.Join(root, filepath.FromSlash(linkName))
163+
} else {
164+
oldName = filepath.Join(root, filepath.FromSlash(path.Clean("/"+hdr.Linkname)))
165+
}
166+
err = os.Link(oldName, newName)
167+
if err != nil && errors.Is(err, os.ErrPermission) { // EPERM could mean it's a directory
168+
if oldInfo, err2 := os.Stat(oldName); err2 == nil && oldInfo.IsDir() {
169+
err = os.Symlink(oldName, newName)
170+
}
171+
}
172+
default:
173+
return fmt.Errorf("extracting archive of base image: disallowed entry type %c", hdr.Typeflag)
174+
}
175+
if err != nil {
176+
return fmt.Errorf("creating %q: %w", newName, err)
177+
}
178+
return nil
179+
}
180+
181+
// ImageName limits which image transports we'll accept. For those it accepts
182+
// which refer to filesystem objects, where relative path names are evaluated
183+
// relative to "contextDir", it will create a copy of the original image, under
184+
// "tmpdir", which contains no symbolic links. It it returns a parseable
185+
// reference to the image which should be used.
186+
func ImageName(transportName, restOfImageName, contextDir, tmpdir string) (newFrom string, err error) {
187+
seenEntries := make(map[string]struct{})
188+
// we're going to try to create a temporary directory or file, but if
189+
// we fail, make sure that they get removed immediately
190+
newImageDestination := ""
191+
succeeded := false
192+
defer func() {
193+
if !succeeded && newImageDestination != "" {
194+
if err := os.RemoveAll(newImageDestination); err != nil {
195+
logrus.Warnf("removing temporary copy of base image in %q: %v", newImageDestination, err)
196+
}
197+
}
198+
}()
199+
200+
// create an archive of the base image, whatever kind it is, chrooting into
201+
// the build context directory while doing so, to be sure that it can't
202+
// be tricked into including anything from outside of the context directory
203+
isEmbeddedArchive := false
204+
var f *os.File
205+
var tw *tar.Writer
206+
var archiveSource string
207+
var imageArchive io.ReadCloser
208+
switch transportName {
209+
case dockerTransport.Transport.Name(), "docker-daemon", openshiftTransport.Transport.Name(): // ok, these are all remote
210+
return transportName + ":" + restOfImageName, nil
211+
case dockerArchiveTransport.Transport.Name(), ociArchiveTransport.Transport.Name(): // these are, basically, tarballs
212+
// these take the form path[:stuff]
213+
transportRef := restOfImageName
214+
archiveSource, refLeftover, ok := strings.Cut(transportRef, ":")
215+
if ok {
216+
refLeftover = ":" + refLeftover
217+
}
218+
// create a temporary file to use as our new archive
219+
tw, f, err = newArchiveDestination(tmpdir)
220+
if err != nil {
221+
return "", fmt.Errorf("creating temporary copy of base image: %w", err)
222+
}
223+
newImageDestination = f.Name()
224+
defer func() {
225+
if tw != nil {
226+
if err := tw.Close(); err != nil {
227+
logrus.Warnf("wrapping up writing copy of base image to archive %q: %v", newImageDestination, err)
228+
}
229+
}
230+
if f != nil {
231+
if err := f.Close(); err != nil {
232+
logrus.Warnf("closing copy of base image in archive file %q: %v", newImageDestination, err)
233+
}
234+
}
235+
}()
236+
// archive only the archive file for copying to the new archive file
237+
imageArchive, err = newSingleItemArchive(contextDir, archiveSource)
238+
isEmbeddedArchive = true
239+
// generate the new reference using the temporary file's name
240+
newFrom = transportName + ":" + newImageDestination + refLeftover
241+
case ociLayoutTransport.Transport.Name(): // this is a directory tree
242+
// this takes the form path[:stuff]
243+
transportRef := restOfImageName
244+
archiveSource, refLeftover, ok := strings.Cut(transportRef, ":")
245+
if ok {
246+
refLeftover = ":" + refLeftover
247+
}
248+
// create a new directory to use as our new layout directory
249+
if newImageDestination, err = newDirectoryDestination(tmpdir); err != nil {
250+
return "", fmt.Errorf("creating temporary copy of base image: %w", err)
251+
}
252+
// archive the entire layout directory for copying to the new layout directory
253+
tarOptions := &archive.TarOptions{}
254+
imageArchive, err = chrootarchive.Tar(filepath.Join(contextDir, archiveSource), tarOptions, contextDir)
255+
// generate the new reference using the directory
256+
newFrom = transportName + ":" + newImageDestination + refLeftover
257+
case directoryTransport.Transport.Name(): // this is also a directory tree
258+
// this takes the form of just a path
259+
transportRef := restOfImageName
260+
// create a new directory to use as our new image directory
261+
if newImageDestination, err = newDirectoryDestination(tmpdir); err != nil {
262+
return "", fmt.Errorf("creating temporary copy of base image: %w", err)
263+
}
264+
// archive the entire directory for copying to the new directory
265+
archiveSource = transportRef
266+
tarOptions := &archive.TarOptions{}
267+
imageArchive, err = chrootarchive.Tar(filepath.Join(contextDir, archiveSource), tarOptions, contextDir)
268+
// generate the new reference using the directory
269+
newFrom = transportName + ":" + newImageDestination
270+
default:
271+
return "", fmt.Errorf("unexpected container image transport %q", transportName)
272+
}
273+
if err != nil {
274+
return "", fmt.Errorf("error archiving source at %q under %q", archiveSource, contextDir)
275+
}
276+
277+
// start reading the archived content
278+
defer func() {
279+
if err := imageArchive.Close(); err != nil {
280+
logrus.Warn(err)
281+
}
282+
}()
283+
tr := tar.NewReader(imageArchive)
284+
hdr, err := tr.Next()
285+
for err == nil {
286+
// if the archive we're parsing is expected to have an archive as its only item,
287+
// assume it's the first (and hopefully, only) item, and switch to stepping through
288+
// it as the archive
289+
if isEmbeddedArchive {
290+
if hdr.Typeflag != tar.TypeReg {
291+
return "", fmt.Errorf("internal error passing archive contents: embedded archive type was %c instead of %c", hdr.Typeflag, tar.TypeReg)
292+
}
293+
decompressed, _, decompressErr := compression.AutoDecompress(tr)
294+
if decompressErr != nil {
295+
return "", fmt.Errorf("error decompressing-if-necessary archive %q: %w", archiveSource, decompressErr)
296+
}
297+
defer func() {
298+
if err := decompressed.Close(); err != nil {
299+
logrus.Warn(err)
300+
}
301+
}()
302+
tr = tar.NewReader(decompressed)
303+
hdr, err = tr.Next()
304+
isEmbeddedArchive = false
305+
continue
306+
}
307+
// write this item from the source archive to either the new archive or the new
308+
// directory, which ever one we're doing
309+
var writeError error
310+
if tw != nil {
311+
writeError = writeToArchive(tw, hdr, io.LimitReader(tr, hdr.Size), seenEntries, transportName == ociArchiveTransport.Transport.Name())
312+
} else {
313+
writeError = writeToDirectory(newImageDestination, hdr, io.LimitReader(tr, hdr.Size))
314+
}
315+
if writeError != nil {
316+
return "", fmt.Errorf("writing copy of image to %q: %w", newImageDestination, writeError)
317+
}
318+
hdr, err = tr.Next()
319+
}
320+
if err != nil && !errors.Is(err, io.EOF) {
321+
return "", fmt.Errorf("reading archive of base image: %w", err)
322+
}
323+
if isEmbeddedArchive {
324+
logrus.Warnf("expected to have archived a copy of %q, missed it", archiveSource)
325+
}
326+
if tw != nil {
327+
if err := tw.Close(); err != nil {
328+
return "", fmt.Errorf("wrapping up writing copy of base image to archive %q: %w", newImageDestination, err)
329+
}
330+
tw = nil
331+
}
332+
if f != nil {
333+
if err := f.Close(); err != nil {
334+
return "", fmt.Errorf("closing copy of base image in archive file %q: %w", newImageDestination, err)
335+
}
336+
f = nil
337+
}
338+
succeeded = true
339+
return newFrom, nil
340+
}

0 commit comments

Comments
 (0)