diff options
context:
space:
mode:
authorBryce Kwon <bryce@brycekwon.com>
committerBryce Kwon <bryce@brycekwon.com>
commit
parent
tree
download
Renumber the test scripts into themed ranges
t000x the ground the suite stands on: git version, html validity, the cache t01xx page content, one script per page t02xx features that cut across pages: filters, submodule links, dates, limits t03xx defence, the security regressions and the $HOME promise t04xx the helper tools under tools/
Diffstat (limited to 'tests/t0301-security.sh')
-rwxr-xr-xtests/t0301-security.sh358
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 "&lt;script&gt;" 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 "&lt;script&gt;" 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&#x27;&gt;&lt;script&gt;" tmp
+'
+
+test_done