Skip to content

Commit efc7a88

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 0bc20d2 commit efc7a88

File tree

4 files changed

+821
-1
lines changed

4 files changed

+821
-1
lines changed

imagebuildah/stage_executor.go

+38-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/containers/buildah/define"
2020
buildahdocker "github.com/containers/buildah/docker"
2121
"github.com/containers/buildah/internal"
22+
"github.com/containers/buildah/internal/sanitize"
2223
"github.com/containers/buildah/internal/tmpdir"
2324
internalUtil "github.com/containers/buildah/internal/util"
2425
"github.com/containers/buildah/pkg/parse"
@@ -927,6 +928,29 @@ func (s *StageExecutor) UnrecognizedInstruction(step *imagebuilder.Step) error {
927928
return errors.New(err)
928929
}
929930

931+
// sanitizeFrom limits which image transports we'll accept. For those it
932+
// accepts which refer to filesystem objects, where relative path names are
933+
// evaluated relative to "contextDir", it will create a copy of the original
934+
// image, under "tmpdir", which contains no symbolic links, and return either
935+
// the original image reference or a reference to a sanitized copy which should
936+
// be used instead.
937+
func (s *StageExecutor) sanitizeFrom(from, tmpdir string) (newFrom string, err error) {
938+
transportName, restOfImageName, maybeHasTransportName := strings.Cut(from, ":")
939+
if !maybeHasTransportName || transports.Get(transportName) == nil {
940+
if _, err = reference.ParseNormalizedNamed(from); err == nil {
941+
// this is a normal-looking image-in-a-registry-or-named-in-storage name
942+
return from, nil
943+
}
944+
if img, err := s.executor.store.Image(from); img != nil && err == nil {
945+
// this is an image ID
946+
return from, nil
947+
}
948+
return "", fmt.Errorf("parsing image name %q: %w", from, err)
949+
}
950+
// TODO: drop this part and just return an error... someday
951+
return sanitize.ImageName(transportName, restOfImageName, s.executor.contextDir, tmpdir)
952+
}
953+
930954
// prepare creates a working container based on the specified image, or if one
931955
// isn't specified, the first argument passed to the first FROM instruction we
932956
// can find in the stage's parsed tree.
@@ -943,6 +967,19 @@ func (s *StageExecutor) prepare(ctx context.Context, from string, initializeIBCo
943967
}
944968
from = base
945969
}
970+
sanitizedDir, err := os.MkdirTemp(tmpdir.GetTempDir(), "buildah-context-")
971+
if err != nil {
972+
return nil, fmt.Errorf("creating temporary directory: %w", err)
973+
}
974+
defer func() {
975+
if err := os.RemoveAll(sanitizedDir); err != nil {
976+
logrus.Warn(err)
977+
}
978+
}()
979+
sanitizedFrom, err := s.sanitizeFrom(from, tmpdir.GetTempDir())
980+
if err != nil {
981+
return nil, fmt.Errorf("invalid base image specification %q: %w", from, err)
982+
}
946983
displayFrom := from
947984
if ib.Platform != "" {
948985
displayFrom = "--platform=" + ib.Platform + " " + displayFrom
@@ -981,7 +1018,7 @@ func (s *StageExecutor) prepare(ctx context.Context, from string, initializeIBCo
9811018

9821019
builderOptions := buildah.BuilderOptions{
9831020
Args: ib.Args,
984-
FromImage: from,
1021+
FromImage: sanitizedFrom,
9851022
GroupAdd: s.executor.groupAdd,
9861023
PullPolicy: pullPolicy,
9871024
ContainerSuffix: s.executor.containerSuffix,

internal/sanitize/sanitize.go

+329
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
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+
directoryTransport "github.com/containers/image/v5/directory"
14+
dockerTransport "github.com/containers/image/v5/docker"
15+
dockerArchiveTransport "github.com/containers/image/v5/docker/archive"
16+
ociArchiveTransport "github.com/containers/image/v5/oci/archive"
17+
ociLayoutTransport "github.com/containers/image/v5/oci/layout"
18+
openshiftTransport "github.com/containers/image/v5/openshift"
19+
"github.com/containers/image/v5/pkg/compression"
20+
"github.com/containers/storage/pkg/archive"
21+
"github.com/containers/storage/pkg/chrootarchive"
22+
"github.com/sirupsen/logrus"
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
159+
var oldName string
160+
if !path.IsAbs(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+
default:
168+
return fmt.Errorf("extracting archive of base image: disallowed entry type %c", hdr.Typeflag)
169+
}
170+
if err != nil {
171+
return fmt.Errorf("creating %q: %w", newName, err)
172+
}
173+
return nil
174+
}
175+
176+
// ImageName limits which image transports we'll accept. For those it accepts
177+
// which refer to filesystem objects, where relative path names are evaluated
178+
// relative to "contextDir", it will create a copy of the original image, under
179+
// "tmpdir", which contains no symbolic links. It it returns a parseable
180+
// reference to the image which should be used.
181+
func ImageName(transportName, restOfImageName, contextDir, tmpdir string) (newFrom string, err error) {
182+
seenEntries := make(map[string]struct{})
183+
// we're going to try to create a temporary directory or file, but if
184+
// we fail, make sure that they get removed immediately
185+
newImageDestination := ""
186+
succeeded := false
187+
defer func() {
188+
if !succeeded && newImageDestination != "" {
189+
if err := os.RemoveAll(newImageDestination); err != nil {
190+
logrus.Warnf("removing temporary copy of base image in %q: %v", newImageDestination, err)
191+
}
192+
}
193+
}()
194+
195+
// create an archive of the base image, whatever kind it is, chrooting into
196+
// the build context directory while doing so, to be sure that it can't
197+
// be tricked into including anything from outside of the context directory
198+
isEmbeddedArchive := false
199+
var f *os.File
200+
var tw *tar.Writer
201+
var archiveSource string
202+
var imageArchive io.ReadCloser
203+
switch transportName {
204+
case dockerTransport.Transport.Name(), "docker-daemon", openshiftTransport.Transport.Name(): // ok, these are all remote
205+
return transportName + ":" + restOfImageName, nil
206+
case dockerArchiveTransport.Transport.Name(), ociArchiveTransport.Transport.Name(): // these are, basically, tarballs
207+
// these take the form path[:stuff]
208+
transportRef := restOfImageName
209+
parts := strings.Split(transportRef, ":")
210+
archiveSource = parts[0]
211+
refLeftover := parts[1:]
212+
// create a temporary file to use as our new archive
213+
tw, f, err = newArchiveDestination(tmpdir)
214+
if err != nil {
215+
return "", fmt.Errorf("creating temporary copy of base image: %w", err)
216+
}
217+
newImageDestination = f.Name()
218+
defer func() {
219+
if tw != nil {
220+
if err := tw.Close(); err != nil {
221+
logrus.Warnf("wrapping up writing copy of base image to archive %q: %v", newImageDestination, err)
222+
}
223+
}
224+
if f != nil {
225+
if err := f.Close(); err != nil {
226+
logrus.Warnf("closing copy of base image in archive file %q: %v", newImageDestination, err)
227+
}
228+
}
229+
}()
230+
// archive only the archive file for copying to the new archive file
231+
imageArchive, err = newSingleItemArchive(contextDir, archiveSource)
232+
isEmbeddedArchive = true
233+
// generate the new reference using the temporary file's name
234+
newFrom = transportName + ":" + strings.Join(append([]string{newImageDestination}, refLeftover...), ":")
235+
case ociLayoutTransport.Transport.Name(): // this is a directory tree
236+
// this takes the form path[:stuff]
237+
transportRef := restOfImageName
238+
parts := strings.Split(transportRef, ":")
239+
archiveSource = parts[0]
240+
refLeftover := parts[1:]
241+
// create a new directory to use as our new layout directory
242+
if newImageDestination, err = newDirectoryDestination(tmpdir); err != nil {
243+
return "", fmt.Errorf("creating temporary copy of base image: %w", err)
244+
}
245+
// archive the entire layout directory for copying to the new layout directory
246+
tarOptions := &archive.TarOptions{}
247+
imageArchive, err = chrootarchive.Tar(filepath.Join(contextDir, archiveSource), tarOptions, contextDir)
248+
// generate the new reference using the directory
249+
newFrom = transportName + ":" + strings.Join(append([]string{newImageDestination}, refLeftover...), ":")
250+
case directoryTransport.Transport.Name(): // this is also a directory tree
251+
// this takes the form of just a path
252+
transportRef := restOfImageName
253+
// create a new directory to use as our new image directory
254+
if newImageDestination, err = newDirectoryDestination(tmpdir); err != nil {
255+
return "", fmt.Errorf("creating temporary copy of base image: %w", err)
256+
}
257+
// archive the entire directory for copying to the new directory
258+
archiveSource = transportRef
259+
tarOptions := &archive.TarOptions{}
260+
imageArchive, err = chrootarchive.Tar(filepath.Join(contextDir, archiveSource), tarOptions, contextDir)
261+
// generate the new reference using the directory
262+
newFrom = transportName + ":" + newImageDestination
263+
default:
264+
return "", fmt.Errorf("unexpected container image transport %q", transportName)
265+
}
266+
if err != nil {
267+
return "", fmt.Errorf("error archiving source at %q under %q", archiveSource, contextDir)
268+
}
269+
270+
// start reading the archived content
271+
defer func() {
272+
if err := imageArchive.Close(); err != nil {
273+
logrus.Warn(err)
274+
}
275+
}()
276+
tr := tar.NewReader(imageArchive)
277+
hdr, err := tr.Next()
278+
for hdr != nil {
279+
// if the archive we're parsing is expected to have an archive as its only item,
280+
// assume it's the first (and hopefully, only) item, and switch to stepping through
281+
// it as the archive
282+
if isEmbeddedArchive {
283+
decompressed, _, decompressErr := compression.AutoDecompress(tr)
284+
if decompressErr != nil {
285+
return "", fmt.Errorf("error decompressing-if-necessary archive %q: %w", archiveSource, decompressErr)
286+
}
287+
defer func() {
288+
if err := decompressed.Close(); err != nil {
289+
logrus.Warn(err)
290+
}
291+
}()
292+
tr = tar.NewReader(decompressed)
293+
hdr, err = tr.Next()
294+
isEmbeddedArchive = false
295+
continue
296+
}
297+
// write this item from the source archive to either the new archive or the new
298+
// directory, which ever one we're doing
299+
if tw != nil {
300+
err = writeToArchive(tw, hdr, io.LimitReader(tr, hdr.Size), seenEntries, transportName == ociArchiveTransport.Transport.Name())
301+
} else {
302+
err = writeToDirectory(newImageDestination, hdr, io.LimitReader(tr, hdr.Size))
303+
}
304+
if err != nil {
305+
return "", fmt.Errorf("writing copy of image to %q: %w", newImageDestination, err)
306+
}
307+
hdr, err = tr.Next()
308+
}
309+
if isEmbeddedArchive {
310+
logrus.Warnf("expected to have archived a copy of %q, missed it", archiveSource)
311+
}
312+
if err != nil && !errors.Is(err, io.EOF) {
313+
return "", fmt.Errorf("reading archive of base image: %w", err)
314+
}
315+
if tw != nil {
316+
if err := tw.Close(); err != nil {
317+
return "", fmt.Errorf("wrapping up writing copy of base image to archive %q: %w", newImageDestination, err)
318+
}
319+
tw = nil
320+
}
321+
if f != nil {
322+
if err := f.Close(); err != nil {
323+
return "", fmt.Errorf("closing copy of base image in archive file %q: %w", newImageDestination, err)
324+
}
325+
f = nil
326+
}
327+
succeeded = true
328+
return newFrom, nil
329+
}

0 commit comments

Comments
 (0)