diff options
Diffstat (limited to 'tests/t0301-security.sh')
| -rwxr-xr-x | tests/t0301-security.sh | 358 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
1 file changed, 358 insertions, 0 deletions
diff --git a/tests/t0301-security.sh b/tests/t0301-security.sh new file mode 100755 index 0000000..8fce5f4 --- /dev/null +++ b/tests/t0301-security.sh @@ -0,0 +1,358 @@ +#!/bin/sh + +# Collects the regression tests for the security fixes and for the behaviour +# this fork adds on top of upstream cgit. Each case builds the smallest +# repository and config that reproduce the original problem and then asks for +# the page that used to mishandle it. The comment above a case says what the +# page is being defended against, because a request that looks ordinary is +# usually the whole point of the attack. + +test_description='Check security fixes and fork-specific behavior' +. ./setup.sh + +# Most of what follows shares one repository and one config, so the fixture +# carries everything they need at once, a blob over the size limit, readmes +# holding markup that must not reach the page as markup, and a subdirectory +# to sort ahead of the files. +test_expect_success 'set up security fixtures' ' + mkrepo repos/sec 1 && + ( + cd repos/sec && + dd if=/dev/zero bs=1024 count=4 2>/dev/null | tr "\0" "X" >big.txt && + printf "# Title\n<script>alert(1)</script>\n" >README.md && + printf "<script>alert(2)</script>\n" >readme.txt && + printf "top\n" >afile && + mkdir zsub && + printf "inner\n" >zsub/inner && + git add -A && + git commit -m fixtures + ) && + { + echo "virtual-root=/" && + echo "cache-size=0" && + echo "max-blob-size=1" && + echo "enable-blame=1" && + echo "enable-tree-group-dirs=1" && + echo "repo.url=sec" && + echo "repo.path=$PWD/repos/sec/.git" + } >seccgitrc +' + +secq() { CGIT_CONFIG="$PWD/seccgitrc" QUERY_STRING="$1" cgit; } + +# A revision beginning with a dash reaches git as an option rather than as a +# tip, so a request for id=--output=<path> could create or truncate any file +# the server is able to write. +test_expect_success 'log id=--output does not write a file' ' + rm -f pwned && + cgit_query "url=foo/log&id=--output=$PWD/pwned" >tmp 2>&1 && + ! test -e pwned +' + +test_expect_success 'log id=--output is rejected as an invalid revision' ' + grep -i "invalid revision" tmp +' + +test_expect_success 'a normal log still renders' ' + cgit_query "url=foo/log" >tmp && + grep -i "commit 5" tmp +' + +test_expect_success 'a valid id= still renders the log' ' + sha=$(git -C repos/foo rev-parse HEAD) && + cgit_query "url=foo/log&id=$sha" >tmp && + grep -i "commit 5" tmp +' + +# max-blob-size is enforced before the object is read, so every view that +# would otherwise inline a file has to turn the same one away rather than +# inflate it first and think better of it afterwards. +test_expect_success 'tree view refuses an oversized blob' ' + secq "url=sec/tree/big.txt" | grep -iE "exceeds|too large" +' + +test_expect_success 'plain view refuses an oversized blob' ' + secq "url=sec/plain/big.txt" | grep -iE "exceeds|too large|413" +' + +test_expect_success 'blame view refuses an oversized blob' ' + secq "url=sec/blame/big.txt" | grep -iE "exceeds|too large" +' + +test_expect_success 'a small blob is still served' ' + secq "url=sec/plain/afile" | grep -F "top" +' + +# A readme is repository content, so with no about filter configured it has +# to reach the page escaped instead of as live markup, whatever its name +# suggests about the format. +test_expect_success 'markdown readme without a filter is escaped as plain text' ' + { + echo "virtual-root=/" && + echo "cache-size=0" && + echo "repo.url=md" && + echo "repo.path=$PWD/repos/sec/.git" && + echo "repo.readme=master:README.md" + } >secmdrc && + CGIT_CONFIG="$PWD/secmdrc" QUERY_STRING="url=md/about/" cgit >tmp && + grep "pre class=.plaintext." tmp && + grep "<script>" tmp && + ! grep "<script>alert(1)</script>" tmp +' + +test_expect_success 'non-markdown readme without a filter is escaped' ' + { + echo "virtual-root=/" && + echo "cache-size=0" && + echo "repo.url=txt" && + echo "repo.path=$PWD/repos/sec/.git" && + echo "repo.readme=master:readme.txt" + } >sectxtrc && + CGIT_CONFIG="$PWD/sectxtrc" QUERY_STRING="url=txt/about/" cgit >tmp && + grep "<script>" tmp && + ! grep "<script>alert(2)</script>" tmp +' + +test_expect_success 'non-markdown readme keeps its line structure' ' + grep "pre class=.plaintext." tmp +' + +# A Content-Security-Policy without unsafe-inline stops an inline onchange +# handler from ever running, so the option forms only mark their selects and +# cgit.js wires the submit up from outside the page. +test_expect_success 'diff option selects use the autosubmit marker' ' + sha=$(git -C repos/foo rev-parse HEAD) && + cgit_query "url=foo/commit&id=$sha" >tmp && + grep "data-autosubmit" tmp && + ! grep "onchange" tmp +' + +# Grouping directories ahead of files is behaviour this fork adds, so nothing +# upstream covers it. +test_expect_success 'tree groups directories before files' ' + secq "url=sec/tree/" >tmp && + dirline=$(grep -n "tree/zsub" tmp | head -1 | cut -d: -f1) && + fileline=$(grep -n "tree/afile" tmp | head -1 | cut -d: -f1) && + test -n "$dirline" && + test -n "$fileline" && + test "$dirline" -lt "$fileline" +' + +# A file name is repository content and may hold a quote, which would break +# out of the href attribute on the line number links of a side by side diff, +# so the path is percent-encoded on its way into them. +test_expect_success 'ssdiff percent-encodes a quoted file path' ' + mkrepo repos/xss 1 && + name=$(printf "x\047y.txt") && + ( + cd repos/xss && + printf "a\nb\n" >"$name" && + git add -A && + git commit -m add && + printf "a\nc\n" >"$name" && + git commit -am change + ) && + { + echo "virtual-root=/" && + echo "cache-size=0" && + echo "repo.url=xss" && + echo "repo.path=$PWD/repos/xss/.git" + } >xssrc && + sha=$(git -C repos/xss rev-parse HEAD) && + CGIT_CONFIG="$PWD/xssrc" QUERY_STRING="url=xss/diff/&id=$sha&ss=1" cgit >tmp && + grep "tree/x%27y.txt" tmp && + ! grep "href=.[^>]*x.y.txt.[^>]*>" tmp +' + +# git itself will write a commit with an empty message, so the log and the +# summary both have to have something to print where the subject goes. +test_expect_success 'a message-less commit renders without crashing' ' + mkrepo repos/nomsg 1 && + ( + cd repos/nomsg && + tree=$(git write-tree) && + printf "tree %s\nauthor a <a@b> 1735689600 +0000\ncommitter a <a@b> 1735689600 +0000\n" "$tree" >raw && + cid=$(git hash-object -t commit -w raw) && + git update-ref refs/heads/master "$cid" + ) && + { + echo "virtual-root=/" && + echo "cache-size=0" && + echo "repo.url=nomsg" && + echo "repo.path=$PWD/repos/nomsg/.git" + } >nomsgrc && + CGIT_CONFIG="$PWD/nomsgrc" QUERY_STRING="url=nomsg/log/&showmsg=1" cgit >tmp && + grep "no commit message" tmp && + CGIT_CONFIG="$PWD/nomsgrc" QUERY_STRING="url=nomsg/" cgit >tmp && + grep "no commit message" tmp +' + +# A branch need not point at a commit. struct refinfo keeps taginfo and +# commitinfo in a union and fills only the member matching the object type, +# so sorting branches through the commit member read past the end of the +# smaller taginfo, and read NULL for a tree, which crashed. git update-ref +# refuses to create such a ref, hence the loose files written by hand below, +# and a repository is only files on disk so cgit meets whatever is there. +test_expect_success 'set up a repo whose branches point at odd objects' ' + mkrepo repos/oddref 2 && + ( + cd repos/oddref && + git tag -a annotated -m note && + git rev-parse annotated >.git/refs/heads/points-at-tag && + git rev-parse HEAD^{tree} >.git/refs/heads/points-at-tree && + git for-each-ref refs/heads/ >refs.out && + grep -q "tree.refs/heads/points-at-tree" refs.out && + grep -q "tag.refs/heads/points-at-tag" refs.out + ) && + { + echo "virtual-root=/" && + echo "cache-size=0" && + echo "branch-sort=age" && + echo "repo.url=oddref" && + echo "repo.path=$PWD/repos/oddref/.git" + } >oddrefrc +' + +test_expect_success 'refs page sorts such branches without crashing' ' + CGIT_CONFIG="$PWD/oddrefrc" QUERY_STRING="url=oddref/refs/" cgit >tmp && + grep "points-at-tree" tmp && + grep "</html>" tmp +' + +test_expect_success 'the branch page sorts them without crashing' ' + CGIT_CONFIG="$PWD/oddrefrc" QUERY_STRING="url=oddref/refs/heads/" cgit >tmp && + grep "points-at-tree" tmp && + grep "</html>" tmp +' + +test_expect_success 'the summary page sorts them without crashing' ' + CGIT_CONFIG="$PWD/oddrefrc" QUERY_STRING="url=oddref/" cgit >tmp && + grep "points-at-tree" tmp && + grep "</html>" tmp +' + +test_expect_success 'name-sorted branches are unaffected' ' + { + echo "virtual-root=/" && + echo "cache-size=0" && + echo "branch-sort=name" && + echo "repo.url=oddref" && + echo "repo.path=$PWD/repos/oddref/.git" + } >oddrefnamerc && + CGIT_CONFIG="$PWD/oddrefnamerc" QUERY_STRING="url=oddref/refs/heads/" cgit >tmp && + grep "points-at-tree" tmp && + grep "</html>" tmp +' + +# module-link is a template and scan-path lets a repository set its own from +# a cgitrc in the tree, so handing that string to printf let the owner of a +# scanned repository crash the process, or read stack memory into the served +# page, merely by adding conversions past the two that are filled. The tests +# after the fixture also pin down the templates that must keep working. +test_expect_success 'set up a submodule fixture with a hostile module-link' ' + mkrepo repos/modlink 1 && + ( + cd repos/modlink && + sub=$(git rev-parse HEAD) && + git update-index --add --cacheinfo 160000,$sub,submod && + git commit -m gitlink + ) && + mkdir -p scan && + cp -R repos/modlink scan/modlink && + printf "module-link=/m/%%s/%%s/%%s/%%s/%%s/%%s/%%s/%%s/%%s/%%s\n" \ + >scan/modlink/.git/cgitrc && + { + echo "virtual-root=/" && + echo "cache-size=0" && + echo "scan-path=$PWD/scan" + } >modlinkrc +' + +test_expect_success 'a surplus conversion does not crash the tree view' ' + CGIT_CONFIG="$PWD/modlinkrc" QUERY_STRING="url=modlink/tree/" cgit >tmp && + grep "ls-mod" tmp +' + +test_expect_success 'a surplus conversion is shown literally, not filled' ' + grep "href=./m/submod/[0-9a-f]*/%s/%s/" tmp +' + +test_expect_success 'a well-formed module-link still takes name and sha1' ' + { + echo "virtual-root=/" && + echo "cache-size=0" && + echo "module-link=/mod/%s/commit/?id=%s" && + echo "repo.url=modlink" && + echo "repo.path=$PWD/repos/modlink/.git" + } >modlinkokrc && + sub=$(git -C repos/modlink rev-parse HEAD~1) && + CGIT_CONFIG="$PWD/modlinkokrc" QUERY_STRING="url=modlink/tree/" cgit >tmp && + grep "href=./mod/submod/commit/?id=$sub." tmp +' + +test_expect_success 'a per-path module-link takes only the sha1' ' + { + echo "virtual-root=/" && + echo "cache-size=0" && + echo "repo.url=modlink" && + echo "repo.path=$PWD/repos/modlink/.git" && + echo "repo.module-link.submod=https://example.com/s/?id=%s" + } >modlinkpathrc && + sub=$(git -C repos/modlink rev-parse HEAD~1) && + CGIT_CONFIG="$PWD/modlinkpathrc" QUERY_STRING="url=modlink/tree/" cgit >tmp && + grep "href=.https://example.com/s/?id=$sub." tmp +' + +test_expect_success 'a doubled percent in a module-link renders as one' ' + { + echo "virtual-root=/" && + echo "cache-size=0" && + echo "module-link=/m/%s/%%/%s" && + echo "repo.url=modlink" && + echo "repo.path=$PWD/repos/modlink/.git" + } >modlinkpctrc && + sub=$(git -C repos/modlink rev-parse HEAD~1) && + CGIT_CONFIG="$PWD/modlinkpctrc" QUERY_STRING="url=modlink/tree/" cgit >tmp && + grep "href=./m/submod/%/$sub." tmp +' + +# The .gitmodules file is commit-controlled content, so a derived link may +# carry any scheme and any byte an author can commit. Only http and https +# may reach an href, and everything lands attribute-escaped. +test_expect_success 'set up a hostile .gitmodules' ' + ( + cd repos/modlink && + sub=$(git rev-parse HEAD) && + git update-index --add --cacheinfo 160000,$sub,quoted && + cat >.gitmodules <<-EOF && + [submodule "submod"] + path = submod + url = javascript:alert(1) + [submodule "quoted"] + path = quoted + url = https://example.com/x'\''><script>alert(1)</script> + EOF + git add .gitmodules && + git commit -m hostile + ) && + { + echo "virtual-root=/" && + echo "cache-size=0" && + echo "enable-gitmodules-links=1" && + echo "repo.url=modlink" && + echo "repo.path=$PWD/repos/modlink/.git" + } >modlinkgmrc +' + +test_expect_success 'a javascript url never reaches an href' ' + CGIT_CONFIG="$PWD/modlinkgmrc" QUERY_STRING="url=modlink/tree/" cgit >tmp && + ! grep "href=.javascript:" tmp && + grep "class=.ls-mod. title=.javascript:alert(1).>submod</span>" tmp +' + +test_expect_success 'a quote in a web url cannot break out of the href' ' + ! grep "<script>alert" tmp && + grep "href=.https://example.com/x'><script>" tmp +' + +test_done |
