Skip to content

Commit 99f145b

Browse files
committed
Fix path traversal in local file fetch
Verify that the resolved path stays within the base directory before reading it. A directive like [embedmd]:# (../../../etc/passwd) would previously read arbitrary files accessible to the running user. Apply the same bounds check to the test fake so tests exercise the real restriction. Closes #80
1 parent 870991f commit 99f145b

2 files changed

Lines changed: 47 additions & 4 deletions

File tree

embedmd/content.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,14 @@ type fetcher struct{}
3636

3737
func (fetcher) Fetch(dir, path string) ([]byte, error) {
3838
if !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") {
39-
path = filepath.Join(dir, filepath.FromSlash(path))
40-
return os.ReadFile(path)
39+
resolved := filepath.Join(dir, filepath.FromSlash(path))
40+
if dir != "" {
41+
base := filepath.Clean(dir) + string(os.PathSeparator)
42+
if !strings.HasPrefix(filepath.Clean(resolved)+string(os.PathSeparator), base) {
43+
return nil, fmt.Errorf("path %q escapes base directory", path)
44+
}
45+
}
46+
return os.ReadFile(resolved)
4147
}
4248

4349
res, err := http.Get(path)

embedmd/embedmd_test.go

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,37 @@ func TestProcess(t *testing.T) {
242242
"Yay!\n",
243243
err: "2: could not read https://fakeurl.com\\main.go: parse \"https://fakeurl.com\\\\main.go\": invalid character \"\\\\\" in host name",
244244
},
245+
{
246+
name: "path traversal rejected when base dir is set",
247+
dir: "sample",
248+
in: "# Header\n" +
249+
"[embedmd]:# (../secret.go)\n" +
250+
"Yay!\n",
251+
files: map[string][]byte{"secret.go": []byte(content)},
252+
err: `2: could not read ../secret.go: path "../secret.go" escapes base directory`,
253+
},
254+
{
255+
name: "deeply nested path traversal rejected",
256+
dir: "a/b/c",
257+
in: "# Header\n" +
258+
"[embedmd]:# (../../../secret.go)\n" +
259+
"Yay!\n",
260+
err: `2: could not read ../../../secret.go: path "../../../secret.go" escapes base directory`,
261+
},
262+
{
263+
name: "normal relative path within base dir still works",
264+
dir: "sample",
265+
in: "# This is some markdown\n" +
266+
"[embedmd]:# (code.go)\n" +
267+
"Yay!\n",
268+
files: map[string][]byte{"sample/code.go": []byte(content)},
269+
out: "# This is some markdown\n" +
270+
"[embedmd]:# (code.go)\n" +
271+
"```go\n" +
272+
string(content) +
273+
"```\n" +
274+
"Yay!\n",
275+
},
245276
{
246277
name: "ignore commands in code blocks",
247278
in: "# This is some markdown\n" +
@@ -285,8 +316,14 @@ type mixedContentProvider struct {
285316

286317
func (c mixedContentProvider) Fetch(dir, path string) ([]byte, error) {
287318
if !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") {
288-
path = filepath.Join(dir, filepath.FromSlash(path))
289-
if f, ok := c.files[path]; ok {
319+
resolved := filepath.Join(dir, filepath.FromSlash(path))
320+
if dir != "" {
321+
base := filepath.Clean(dir) + string(os.PathSeparator)
322+
if !strings.HasPrefix(filepath.Clean(resolved)+string(os.PathSeparator), base) {
323+
return nil, fmt.Errorf("path %q escapes base directory", path)
324+
}
325+
}
326+
if f, ok := c.files[resolved]; ok {
290327
return f, nil
291328
}
292329
return nil, os.ErrNotExist

0 commit comments

Comments
 (0)