diff options
context:
space:
mode:
authorBryce Kwon <bryce@brycekwon.com>
committerBryce Kwon <bryce@brycekwon.com>
commit
parent
tree
download
Add a test suite for the shipped extensions
Diffstat (limited to 'tests/extensions/test-auth.lua')
-rw-r--r--tests/extensions/test-auth.lua302
1 file changed, 302 insertions, 0 deletions
diff --git a/tests/extensions/test-auth.lua b/tests/extensions/test-auth.lua
new file mode 100644
index 0000000..359f52e
--- /dev/null
+++ b/tests/extensions/test-auth.lua
@@ -0,0 +1,302 @@
+-- Unit checks for the two auth filters, run under a standalone Lua by
+-- t0505-auth.sh with the script path as the first argument and the variant,
+-- inline or file, as the second. The scripts take luaossl and luaposix at
+-- load, so the harness meets both with deterministic stand-ins, which keeps
+-- every check about this script's own logic, the cookie layout, the expiry
+-- and field rules, the redirect vetting and the action flows, rather than
+-- about OpenSSL. The signing secret is pinned by replacing get_secret, and
+-- the account and access lookups are populated through each variant's own
+-- channel, the documented account_hash and repo_userset swap points for the
+-- inline script and fixture files reached through a redirected io.open for
+-- the file one. Both variants carry the same data so the flow checks read
+-- identically for the two.
+
+local test_directory = arg[0]:match("^(.*)/") or "."
+local h = dofile(test_directory .. "/harness.lua")
+
+local script = arg[1]
+local variant = arg[2]
+
+h.stub_auth_modules()
+
+-- The password behind every test account, stored as what the harness crypt
+-- stand-in returns for it so a login with it verifies and any other fails.
+local password = "open sesame"
+local stored_hash = "$6$rounds=300000$testsalt$" .. password
+
+if variant == "file" then
+ local users_file = io.open("auth-users", "w")
+ users_file:write("Alice:" .. stored_hash .. "\r\n")
+ users_file:write("not a parsable line\n")
+ users_file:close()
+ local groups_file = io.open("auth-groups", "w")
+ groups_file:write("devs:Alice, bob\n")
+ groups_file:write("others:carol\n")
+ groups_file:close()
+ local repos_file = io.open("auth-repos", "w")
+ repos_file:write("secret-repo:devs\n")
+ repos_file:write("empty-repo:ghosts\n")
+ repos_file:close()
+ h.redirect_file("/etc/cgit-auth/users", "auth-users")
+ h.redirect_file("/etc/cgit-auth/groups", "auth-groups")
+ h.redirect_file("/etc/cgit-auth/repos", "auth-repos")
+end
+
+h.load(script)
+
+function get_secret()
+ return string.rep("0123456789abcdef", 4)
+end
+
+if variant == "inline" then
+ local accounts = { alice = stored_hash }
+ local access = {
+ ["secret-repo"] = { alice = true, bob = true },
+ ["empty-repo"] = {},
+ }
+ function account_hash(user)
+ if user == nil then
+ return nil
+ end
+ return accounts[user:lower()]
+ end
+ function repo_userset(repo)
+ if repo == nil then
+ return nil
+ end
+ return access[repo]
+ end
+end
+
+-- The lookups themselves, which are the part the two variants do not share.
+
+local hash = account_hash("ALICE")
+h.equals("an account is found whatever its case", hash, stored_hash)
+h.equals("an unknown account is not", account_hash("nobody"), nil)
+h.equals("a nil user is not", account_hash(nil), nil)
+
+local userset = repo_userset("secret-repo")
+h.check("a protected repository lists its users",
+ userset ~= nil and userset.alice and userset.bob)
+h.equals("an unlisted repository is public", repo_userset("unlisted"), nil)
+h.equals("the repository name matches case exactly",
+ repo_userset("Secret-Repo"), nil)
+local empty = repo_userset("empty-repo")
+h.check("a protected repository with no members denies as an empty set",
+ empty ~= nil and next(empty) == nil)
+
+if variant == "file" then
+ h.redirect_file("/etc/cgit-auth/users", "auth-users-missing")
+ h.equals("a missing users file turns every login down",
+ account_hash("alice"), nil)
+ h.redirect_file("/etc/cgit-auth/users", "auth-users")
+end
+
+-- The url and cookie helpers.
+
+h.equals("decode handles escapes and plus", url_decode("%41+b"), "A b")
+h.equals("encode escapes what is not a word", url_encode("a b|c"), "a+b%7Cc")
+local tricky = "x&=?|"
+h.equals("decode inverts encode", url_decode(url_encode(tricky)), tricky)
+
+local params = parse_query("u=a&p=b=c&x=%41")
+h.equals("a query splits on ampersands", params.u, "a")
+h.equals("a value keeps its own equals signs", params.p, "b=c")
+h.equals("a value is decoded", params.x, "A")
+
+h.equals("a cookie is found among others",
+ get_cookie("foo=1; cgitauth=abc; bar=2", "cgitauth"), "abc")
+h.equals("a lone cookie is found", get_cookie("cgitauth=abc", "cgitauth"),
+ "abc")
+h.equals("no header yields no cookie", get_cookie(nil, "cgitauth"), nil)
+h.equals("a longer name does not match",
+ get_cookie("xcgitauth=z", "cgitauth"), nil)
+h.equals("a magic character in the name is taken literally",
+ get_cookie("a-b=z", "a-b"), "z")
+
+h.check("equal strings compare equal", constant_equals("abc", "abc"))
+h.check("differing strings do not", not constant_equals("abc", "abd"))
+h.check("differing lengths do not", not constant_equals("a", "ab"))
+h.check("non strings do not", not constant_equals(nil, "a"))
+
+h.check("a local path is a safe redirect", is_safe_redirect("/x"))
+h.check("a scheme relative target is not", not is_safe_redirect("//evil"))
+h.check("a backslash variant is not", not is_safe_redirect("/\\evil"))
+h.check("a missing target is not", not is_safe_redirect(nil))
+
+h.equals("control characters are stripped from header values",
+ strip_controls("a\r\nb"), "ab")
+
+-- Signing and verification.
+
+local now = os.time()
+
+local cookie = secure_value("username", "alice", now + 3600)
+h.equals("a signed value verifies and comes back",
+ validate_value("username", cookie), "alice")
+
+cookie = secure_value("username", "a|b", now + 3600)
+h.equals("a value holding the separator survives the round trip",
+ validate_value("username", cookie), "a|b")
+
+cookie = secure_value("username", "a\nb", now + 3600)
+h.equals("a signed control character is still rejected on the way out",
+ validate_value("username", cookie), nil)
+
+cookie = secure_value("redirect", "/repo/?a=b", 0)
+h.equals("an expiration of zero never expires",
+ validate_value("redirect", cookie), "/repo/?a=b")
+
+cookie = secure_value("username", "alice", now - 10)
+h.equals("an expired value is rejected",
+ validate_value("username", cookie), nil)
+
+cookie = secure_value("username", "alice", now + 3600)
+local flipped = cookie:sub(1, -2) ..
+ (cookie:sub(-1) == "0" and "1" or "0")
+h.equals("a tampered signature is rejected",
+ validate_value("username", flipped), nil)
+
+h.equals("a value signed for one field does not serve another",
+ validate_value("redirect", cookie), nil)
+
+h.equals("no cookie does not verify", validate_value("username", nil), nil)
+h.equals("a tiny cookie does not verify", validate_value("username", "ab"),
+ nil)
+h.equals("a leading separator does not verify",
+ validate_value("username", "|x|1|s|sig"), nil)
+h.equals("an unsigned cookie does not verify",
+ validate_value("username", "username|alice|123|salt"), nil)
+h.equals("an exponent spelling of the expiry does not verify",
+ validate_value("username", "username|alice|1e9|salt|beef"), nil)
+h.equals("an empty value signs to nothing", secure_value("username", "", 1),
+ "")
+
+-- The headers the filter writes itself.
+
+h.reset()
+set_cookie("cgitauth", "value")
+local out = h.output()
+h.contains("a session cookie carries the hardening attributes", out,
+ "Set-Cookie: cgitauth=value; HttpOnly; SameSite=Lax; Path=/; Secure; " ..
+ "Max-Age=604800\n")
+
+h.reset()
+set_cookie("cgitauth", "")
+h.contains("an empty value clears the cookie instead", h.output(),
+ "Set-Cookie: cgitauth=; HttpOnly; SameSite=Lax; Path=/; Secure; " ..
+ "Max-Age=0\n")
+
+h.reset()
+redirect_to("/x\r\nX-Evil: 1")
+out = h.output()
+h.contains("a newline cannot split the location header", out,
+ "Location: /xX-Evil: 1\n")
+h.excludes("no carriage return slips through", out, "\r")
+
+-- The three actions, driven the way cgit drives them, an open carrying the
+-- request, a write only for the posted form and the answer read off the
+-- close.
+
+local function run_action(action, opts)
+ opts = opts or {}
+ h.reset()
+ filter_open(action, opts.cookie or "", opts.method or "GET", "", "",
+ "/", "example.org", "on", opts.repo or "", "summary",
+ opts.url or "/repo/", "/?p=login")
+ if opts.body then
+ filter_write(opts.body)
+ end
+ local ret = filter_close()
+ return h.output(), ret
+end
+
+local ret
+
+out, ret = run_action("authenticate-cookie", { repo = "unlisted" })
+h.equals("a public repository needs no cookie", ret, 1)
+
+out, ret = run_action("authenticate-cookie", { repo = "secret-repo" })
+h.equals("a protected repository turns a bare request away", ret, 0)
+
+local session = secure_value("username", "Alice", now + 3600)
+out, ret = run_action("authenticate-cookie",
+ { repo = "secret-repo", cookie = "cgitauth=" .. session })
+h.equals("a signed session for a member is let through", ret, 1)
+
+out, ret = run_action("authenticate-cookie",
+ { repo = "empty-repo", cookie = "cgitauth=" .. session })
+h.equals("a memberless repository denies even a valid session", ret, 0)
+
+local outsider = secure_value("username", "carol", now + 3600)
+out, ret = run_action("authenticate-cookie",
+ { repo = "secret-repo", cookie = "cgitauth=" .. outsider })
+h.equals("a signed session for an outsider is turned away", ret, 0)
+
+local forged = session:sub(1, -2) ..
+ (session:sub(-1) == "0" and "1" or "0")
+out, ret = run_action("authenticate-cookie",
+ { repo = "secret-repo", cookie = "cgitauth=" .. forged })
+h.equals("a forged session is turned away", ret, 0)
+
+out, ret = run_action("no-such-action", {})
+h.equals("an unknown action denies rather than raising", ret, 0)
+
+out, ret = run_action("body", { url = "/repo/log/?q=x" })
+h.contains("the login form posts to the login url", out,
+ "<form method='post' action='/?p=login'>")
+local token = out:match("name='redirect' value='([^']*)'")
+h.equals("the form carries a signed way back",
+ token and validate_value("redirect", token), "/repo/log/?q=x")
+
+out, ret = run_action("body", { url = "//evil.example/x" })
+token = out:match("name='redirect' value='([^']*)'")
+h.equals("an unsafe destination is swapped for the login page",
+ token and validate_value("redirect", token), "/?p=login")
+
+local way_back = secure_value("redirect", "/repo/", 0)
+
+out, ret = run_action("authenticate-post", {
+ method = "POST",
+ body = "username=Alice&password=" .. url_encode(password) ..
+ "&redirect=" .. url_encode(way_back),
+})
+h.contains("a good login redirects back", out, "Status: 302")
+h.contains("to where the form said", out, "Location: /repo/\n")
+local granted = out:match("Set%-Cookie: cgitauth=([^;]*);")
+h.equals("and grants a session that verifies",
+ granted and validate_value("username", granted), "Alice")
+
+out, ret = run_action("authenticate-post", {
+ method = "POST",
+ body = "username=Alice&password=wrong&redirect=" ..
+ url_encode(way_back),
+})
+h.contains("a bad password redirects the same way", out, "Status: 302")
+h.contains("but clears the session cookie", out,
+ "Set-Cookie: cgitauth=; ")
+
+out, ret = run_action("authenticate-post", {
+ method = "POST",
+ body = "username=nobody&password=x&redirect=" .. url_encode(way_back),
+})
+h.contains("an unknown user is told nothing different", out,
+ "Set-Cookie: cgitauth=; ")
+
+out, ret = run_action("authenticate-post", {
+ method = "POST",
+ body = "username=Alice&password=" .. url_encode(password),
+})
+h.contains("a post without the signed token is not served", out,
+ "Status: 404")
+
+local hijack = secure_value("redirect", "//evil.example/", 0)
+out, ret = run_action("authenticate-post", {
+ method = "POST",
+ body = "username=Alice&password=" .. url_encode(password) ..
+ "&redirect=" .. url_encode(hijack),
+})
+h.contains("even a signed unsafe destination is not followed", out,
+ "Status: 404")
+
+h.finish()