gout

A static git page generator
git clone https://git.bracken.jp/gout.git
Log | Files | Refs | README | LICENSE

commit e2641771bea38c1ff5bf9429eecd693932d179fe
parent 5bc71cffc1e2f51a325ee31f2110adbdbc8b00e6
Author: Chris Bracken <chris@bracken.jp>
Date:   Wed, 23 Jul 2025 16:05:21 -0700

Validate paths don't escape the repo

One could imagine a contributor to a git repo submitting a patch with a
maliciously-crafted path designed to escape the repo being read -- e.g.
`some/path/../../../../../../etc/passwd` -- and cause a target file to
be emitted to the static output document.

We now validate repo paths prior to reading from them, and bail out on
absolute paths, and paths containing `..`.

Diffstat:
Mutils.c | 27+++++++++++++++++++++++++++
Mutils.h | 4++++
Mutils_tests.c | 18++++++++++++++++++
Mwriter/gopher/fileblob.c | 3+++
Mwriter/html/fileblob.c | 3+++
5 files changed, 55 insertions(+), 0 deletions(-)

diff --git a/utils.c b/utils.c @@ -98,3 +98,30 @@ void checkfileerror(FILE* fp, const char* name, char mode) { errx(1, "write error: %s", name); } } + +bool is_safe_repo_path(const char* path) { + if (path[0] == '/') { + return false; + } + + const char* p = path; + while (*p) { + /* Locate the next path component, or end of path. */ + const char* start = p; + while (*p && *p != '/') { + p++; + } + + /* Check for "..". */ + size_t len = p - start; + if (len == 2 && start[0] == '.' && start[1] == '.') { + return false; + } + + /* Skip over the path delimiter. */ + if (*p) { + p++; + } + } + return true; +} diff --git a/utils.h b/utils.h @@ -1,6 +1,7 @@ #ifndef GITOUT_UTILS_H_ #define GITOUT_UTILS_H_ +#include <stdbool.h> #include <stdio.h> /* Concatenates path parts to "p1/p2". Exits on failure or truncation. */ @@ -27,4 +28,7 @@ FILE* efopen(const char* filename, const char* flags); /* Exits with error if the specified FILE* has an error. */ void checkfileerror(FILE* fp, const char* name, char mode); +/* Validates that a path is safe to use. Returns true if safe. */ +bool is_safe_repo_path(const char* path); + #endif // GITOUT_UTILS_H_ diff --git a/utils_tests.c b/utils_tests.c @@ -25,3 +25,21 @@ UTEST(path_concat, CanConcatenatePathsSecondEmpty) { EXPECT_STREQ("p1", out); EXPECT_STREQ("p1", returned); } + +UTEST(is_safe_repo_path, AllowsValidRelativePaths) { + EXPECT_TRUE(is_safe_repo_path("foo")); + EXPECT_TRUE(is_safe_repo_path("foo/bar")); + EXPECT_TRUE(is_safe_repo_path("foo/bar.txt")); + EXPECT_TRUE(is_safe_repo_path(".foo")); + EXPECT_TRUE(is_safe_repo_path("foo.bar/baz")); +} + +UTEST(is_safe_repo_path, RejectsInvalidPaths) { + EXPECT_FALSE(is_safe_repo_path("/foo")); + EXPECT_FALSE(is_safe_repo_path("/foo/bar")); + EXPECT_FALSE(is_safe_repo_path("..")); + EXPECT_FALSE(is_safe_repo_path("../foo")); + EXPECT_FALSE(is_safe_repo_path("foo/..")); + EXPECT_FALSE(is_safe_repo_path("foo/../bar")); + EXPECT_FALSE(is_safe_repo_path("foo/./../bar")); +} diff --git a/writer/gopher/fileblob.c b/writer/gopher/fileblob.c @@ -19,6 +19,9 @@ struct GopherFileBlob { }; GopherFileBlob* gopher_fileblob_create(const GitRepo* repo, const char* path) { + if (!is_safe_repo_path(path)) { + errx(1, "unsafe path: %s", path); + } GopherFileBlob* blob = ecalloc(1, sizeof(GopherFileBlob)); blob->repo = repo; diff --git a/writer/html/fileblob.c b/writer/html/fileblob.c @@ -19,6 +19,9 @@ struct HtmlFileBlob { }; HtmlFileBlob* html_fileblob_create(const GitRepo* repo, const char* path) { + if (!is_safe_repo_path(path)) { + errx(1, "unsafe path: %s", path); + } HtmlFileBlob* blob = ecalloc(1, sizeof(HtmlFileBlob)); blob->repo = repo;