blob: 5415ca7d3d5e3d0347c2a6d47a49833cf4a47121 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
-- cgit auth-filter that gates repositories behind a login form and a signed
-- session cookie.
--
-- This is the FILE-BACKED variant. The user accounts, the groups, and the
-- per-repository access lists are read from files on disk, whose paths are set
-- in the CONFIGURATION block below. Edit those files without touching this
-- script. This suits larger or externally managed user sets.
--
-- The companion auth-inline.lua behaves identically but keeps its accounts and
-- access lists inline in the script itself, which suits a small fixed set of
-- users.
--
-- Enable it in cgitrc with
--     auth-filter=lua:/path/to/auth-file.lua
--
-- SUPPORTED LUA
--
-- Lua 5.1, 5.2, 5.3, 5.4 and LuaJIT. Lua 5.5 is not supported, because luaossl
-- has no 5.5 build. Match the runtime to the Lua that cgit is built against.
--
-- HTTPS IS RECOMMENDED
--
-- Serve cgit over HTTPS. Terminate TLS at the web server in front of cgit. The
-- session cookie is marked Secure by default, so a browser only sends it back
-- over HTTPS. If you genuinely run cgit over plain HTTP with no TLS anywhere,
-- set cookie_insecure below, otherwise the cookie is never returned and login
-- appears to loop.
--
-- DEPENDENCIES
--
--     luaossl    OpenSSL binding, provides openssl.rand and openssl.hmac
--                <https://github.com/wahern/luaossl>
--     luaposix   POSIX binding, provides posix.sys.stat and posix.unistd
--                <https://github.com/luaposix/luaposix>
--
-- The reliable cross-platform install is LuaRocks, matched to your Lua
-- version. luaossl also needs the OpenSSL development headers present.
--
--     # Debian and Ubuntu
--     sudo apt install luarocks libssl-dev
--     sudo luarocks --lua-version 5.1 install luaossl
--     sudo luarocks --lua-version 5.1 install luaposix
--
--     # Fedora
--     sudo dnf install luarocks openssl-devel
--     sudo luarocks --lua-version 5.1 install luaossl luaposix
--
--     # Alpine
--     sudo apk add luarocks openssl-dev
--     sudo luarocks-5.1 install luaossl luaposix
--
--     # macOS with Homebrew
--     brew install luarocks openssl
--     luarocks install luaossl OPENSSL_DIR="$(brew --prefix openssl)"
--     luarocks install luaposix
--
-- Some distributions also package these, for example lua-luaossl and lua-posix
-- on Debian. If you use a distribution package, make sure it is built for the
-- same Lua version as cgit.
--
-- SECURITY NOTES
--
-- The cookie carries only a username with no server-side session store, so
-- deleting an account does not revoke a cookie already issued until it
-- expires, and instances that share a secret file accept each other's cookies.
-- The login form carries no CSRF token. Both are acceptable for gating read
-- access to a git browser. Weigh them before guarding anything more sensitive.

local sysstat = require("posix.sys.stat")
local unistd = require("posix.unistd")
local rand = require("openssl.rand")
local hmac = require("openssl.hmac")

--
-- ========================= CONFIGURATION =========================
-- Edit the values in this block. Nothing below it needs changing for
-- ordinary use.
--

-- Accounts, one per line, as username:hash. Generate a hash with
--     mkpasswd -m sha-512 -R 300000
-- This file should not be world-readable.
local users_filename = "/etc/cgit-auth/users"

-- Group membership, one per line, as groupname:user1,user2,user3,...
local groups_filename = "/etc/cgit-auth/groups"

-- Per-repository access, one per line, as reponame:group1,group2,...
-- A repository listed here is protected. One not listed is public.
local repos_filename = "/etc/cgit-auth/repos"

-- Where the cookie-signing secret is stored. It is created on first use. It
-- must be persistent and writable by cgit. Prefer a path OUTSIDE the cache
-- root, because pruning the cache would delete a secret kept inside it and
-- invalidate every live session. This file should not be world-readable.
local secret_filename = "/var/cache/cgit/auth-secret"

-- How long a login stays valid, in seconds. Default one week.
local session_seconds = 7 * 24 * 60 * 60

-- Name of the session cookie.
local cookie_name = "cgitauth"

-- Path the cookie is scoped to. "/" covers the whole host. Set it to the cgit
-- root to scope the cookie more tightly.
local cookie_path = "/"

-- Leave false so the cookie is marked Secure and only travels over HTTPS. Set
-- it true ONLY if cgit is served over plain HTTP with no TLS anywhere, see the
-- HTTPS note in the header.
local cookie_insecure = false

--
-- =================================================================
--

-- A throwaway hash of the documented shape, used only to spend the same work
-- on a missing account as on a present one, so a failed login does not reveal
-- by timing whether the username exists.
local dummy_hash = "$6$rounds=300000$0000000000000000$"

-- Module state shared across the open, write and close calls of one request.
local action, http, cgit, post

--
--
-- Account and access-list storage. This is the ONLY part that differs from
-- auth-inline.lua. Swap these two functions to change where accounts live.
--
--

local function trim(s)
	return (string.gsub(s, "^%s*(.-)%s*$", "%1"))
end

-- Return the stored password hash for a user, or nil. Reads the users file
-- fresh each call. A missing or unreadable file, and any unparsable line, are
-- skipped rather than fatal.
function account_hash(user)
	if user == nil then
		return nil
	end
	local wanted = user:lower()
	local f = io.open(users_filename, "r")
	if f == nil then
		return nil
	end
	for line in f:lines() do
		local u, h = string.match(line, "(.-):(.+)")
		if u ~= nil and trim(u):lower() == wanted then
			f:close()
			return h
		end
	end
	f:close()
	return nil
end

-- Return the set of users allowed to access a repository, keyed by lowercased
-- username, or nil if the repository is not protected. A protected repository
-- whose groups resolve to no users returns an EMPTY table, which denies
-- everyone rather than falling through to public.
function repo_userset(repo)
	if repo == nil then
		return nil
	end
	local groups = nil
	local f = io.open(repos_filename, "r")
	if f ~= nil then
		for line in f:lines() do
			local r, g = string.match(line, "(.-):(.+)")
			if r ~= nil and trim(r) == repo then
				groups = {}
				for group in string.gmatch(g, "([^,]+)") do
					groups[trim(group):lower()] = true
				end
				break
			end
		end
		f:close()
	end
	if groups == nil then
		return nil
	end
	local users = {}
	local gf = io.open(groups_filename, "r")
	if gf ~= nil then
		for line in gf:lines() do
			local g, u = string.match(line, "(.-):(.+)")
			if g ~= nil and groups[trim(g):lower()] then
				for user in string.gmatch(u, "([^,]+)") do
					users[trim(user):lower()] = true
				end
			end
		end
		gf:close()
	end
	return users
end

--
--
-- Utility functions based on keplerproject/wsapi.
--
--

function url_decode(str)
	if not str then
		return ""
	end
	str = string.gsub(str, "+", " ")
	str = string.gsub(str, "%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end)
	str = string.gsub(str, "\r\n", "\n")
	return str
end

function url_encode(str)
	if not str then
		return ""
	end
	str = string.gsub(str, "\n", "\r\n")
	str = string.gsub(str, "([^%w ])", function(c) return string.format("%%%02X", string.byte(c)) end)
	str = string.gsub(str, " ", "+")
	return str
end

-- Parse an application/x-www-form-urlencoded body. A value may itself contain
-- '=', for example a base64 password, so the value runs to the next '&'.
function parse_qs(qs)
	local tab = {}
	for key, val in string.gmatch(qs or "", "([^&=]+)=([^&]*)") do
		tab[url_decode(key)] = url_decode(val)
	end
	return tab
end

-- Return the value of the named cookie, or nil. The stored token was already
-- url-encoded by secure_value, so it is returned verbatim, which keeps the
-- write path (set_cookie) and the read path symmetric. Decoding it here would
-- break the signature check for any value carrying a percent escape.
function get_cookie(cookies, name)
	cookies = string.gsub(";" .. (cookies or "") .. ";", "%s*;%s*", ";")
	return string.match(cookies, ";" .. name .. "=(.-);")
end

function tohex(b)
	local x = ""
	for i = 1, #b do
		x = x .. string.format("%.2x", string.byte(b, i))
	end
	return x
end

--
--
-- Cookie construction and validation helpers.
--
--

local secret = nil

-- Load the cookie-signing secret, creating it on first use. Failures raise,
-- which cgit turns into a request error, so a broken secret denies rather than
-- signs with nothing.
function get_secret()
	if secret ~= nil then
		return secret
	end
	local secret_file = io.open(secret_filename, "r")
	if secret_file == nil then
		local old_umask = sysstat.umask(63)
		local temporary_filename = secret_filename .. ".tmp." .. tohex(rand.bytes(16))
		local temporary_file = io.open(temporary_filename, "w")
		if temporary_file == nil then
			sysstat.umask(old_umask)
			error("cgit auth: cannot create secret file " .. secret_filename)
		end
		local wrote = temporary_file:write(tohex(rand.bytes(32)))
		local closed = temporary_file:close()
		if not wrote or not closed then
			os.remove(temporary_filename)
			sysstat.umask(old_umask)
			error("cgit auth: failed writing secret file " .. secret_filename)
		end
		unistd.link(temporary_filename, secret_filename) -- Intentionally fails if another worker won the race.
		unistd.unlink(temporary_filename)
		sysstat.umask(old_umask)
		secret_file = io.open(secret_filename, "r")
	end
	if secret_file == nil then
		error("cgit auth: cannot read secret file " .. secret_filename)
	end
	secret = secret_file:read("*l")
	secret_file:close()
	if secret == nil or secret:len() ~= 64 then
		secret = nil
		error("cgit auth: secret file " .. secret_filename .. " is malformed, expected 64 hex characters")
	end
	return secret
end

-- A redirect target is unsafe if a browser would read it as another origin.
-- The only such form cgit can be tricked into signing is a scheme-relative
-- "//host" or "/\host". Everything else stays on this host.
function is_safe_redirect(url)
	if type(url) ~= "string" then
		return false
	end
	local head = url:sub(1, 2)
	if head == "//" or head == "/\\" then
		return false
	end
	return true
end

-- Return the value carried by a signed cookie, or nil if it does not verify.
function validate_value(expected_field, cookie)
	local i = 0
	local value = ""
	local field = ""
	local expiration = 0
	local salt = ""
	local chmac = ""

	if cookie == nil or cookie:len() < 3 or cookie:sub(1, 1) == "|" then
		return nil
	end

	for component in string.gmatch(cookie, "[^|]+") do
		if i == 0 then
			field = component
		elseif i == 1 then
			value = component
		elseif i == 2 then
			-- The expiration must be a plain integer. Rejecting other forms
			-- keeps the signed bytes canonical, since tonumber and tostring of
			-- "1e9" or "100.0" differ across Lua versions.
			if not string.match(component, "^%d+$") then
				return nil
			end
			expiration = tonumber(component)
		elseif i == 3 then
			salt = component
		elseif i == 4 then
			chmac = component
		else
			break
		end
		i = i + 1
	end

	if chmac == nil or chmac:len() == 0 then
		return nil
	end

	-- Compare the HMAC without short-circuiting on the first mismatch.
	if not constant_equals(chmac, tohex(hmac.new(get_secret(), "sha256"):final(field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt))) then
		return nil
	end

	-- An expiration of 0 never expires and is used for the redirect token.
	if expiration ~= 0 and expiration <= os.time() then
		return nil
	end

	if url_decode(field) ~= expected_field then
		return nil
	end

	local decoded = url_decode(value)
	-- Reject values carrying control characters so a signed value cannot
	-- smuggle CR or LF into a response header.
	if decoded:find("%c") then
		return nil
	end
	return decoded
end

function secure_value(field, value, expiration)
	if value == nil or value:len() <= 0 then
		return ""
	end

	local salt = tohex(rand.bytes(16))
	value = url_encode(value)
	field = url_encode(field)
	local authstr = field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt
	authstr = authstr .. "|" .. tohex(hmac.new(get_secret(), "sha256"):final(authstr))
	return authstr
end

-- Strip control characters that could split an HTTP response header.
function strip_ctl(s)
	return (string.gsub(s or "", "%c", ""))
end

-- Compare two strings in time that does not depend on how many leading bytes
-- match, so a mismatch position is not revealed by timing.
function constant_equals(a, b)
	if type(a) ~= "string" or type(b) ~= "string" or #a ~= #b then
		return false
	end
	local diff = 0
	for i = 1, #a do
		local d = a:byte(i) - b:byte(i)
		diff = diff + d * d
	end
	return diff == 0
end

function set_cookie(cookie, value)
	local attrs = "; HttpOnly; SameSite=Lax; Path=" .. cookie_path
	if not cookie_insecure then
		attrs = attrs .. "; Secure"
	end
	if value == "" then
		attrs = attrs .. "; Max-Age=0"
	elseif session_seconds > 0 then
		attrs = attrs .. "; Max-Age=" .. tostring(session_seconds)
	end
	html("Set-Cookie: " .. cookie .. "=" .. strip_ctl(value) .. attrs .. "\n")
end

function redirect_to(url)
	html("Status: 302 Redirect\n")
	html("Cache-Control: no-cache, no-store\n")
	html("Location: " .. strip_ctl(url) .. "\n")
end

function not_found()
	html("Status: 404 Not Found\n")
	html("Cache-Control: no-cache, no-store\n\n")
end

--
--
-- Authentication actions. Identical to auth-inline.lua from here down.
--
--

-- Sets HTTP cookie headers based on post and sets up redirection.
function authenticate_post()
	local redirect = validate_value("redirect", post["redirect"])

	if redirect == nil or not is_safe_redirect(redirect) then
		not_found()
		return 0
	end

	redirect_to(redirect)

	local username = post["username"]
	local password = post["password"]
	local ok = false
	if username ~= nil and password ~= nil then
		local hash = account_hash(username)
		if hash == nil then
			-- Spend the work anyway, see dummy_hash.
			unistd.crypt(password, dummy_hash)
		elseif constant_equals(hash, unistd.crypt(password, hash)) then
			ok = true
		end
	end

	if ok then
		set_cookie(cookie_name, secure_value("username", username, os.time() + session_seconds))
	else
		set_cookie(cookie_name, "")
	end

	html("\n")
	return 0
end

-- Returns 1 if the cookie is valid and 0 if it is not.
function authenticate_cookie()
	local accepted_users = repo_userset(cgit["repo"])
	if accepted_users == nil then
		-- The repository is not protected.
		return 1
	end

	local username = validate_value("username", get_cookie(http["cookie"], cookie_name))
	if username == nil or not accepted_users[username:lower()] then
		return 0
	end
	return 1
end

-- Prints the html for the login form.
function body()
	local target = cgit["url"]
	if not is_safe_redirect(target) then
		target = cgit["login"]
	end

	html("<h2>Authentication Required</h2>")
	html("<form method='post' action='")
	html_attr(cgit["login"])
	html("'>")
	html("<input type='hidden' name='redirect' value='")
	html_attr(secure_value("redirect", target, 0))
	html("' />")
	html("<table>")
	html("<tr><td><label for='username'>Username:</label></td><td><input id='username' name='username' autofocus /></td></tr>")
	html("<tr><td><label for='password'>Password:</label></td><td><input id='password' name='password' type='password' /></td></tr>")
	html("<tr><td colspan='2'><input value='Login' type='submit' /></td></tr>")
	html("</table></form>")

	return 0
end

--
--
-- Wrapper around the filter API, exposing the http, cgit and post tables to
-- the functions above.
--
--

local actions = {}
actions["authenticate-post"] = authenticate_post
actions["authenticate-cookie"] = authenticate_cookie
actions["body"] = body

function filter_open(...)
	action = actions[select(1, ...)]

	post = {}

	http = {}
	http["cookie"] = select(2, ...)
	http["method"] = select(3, ...)
	http["query"] = select(4, ...)
	http["referer"] = select(5, ...)
	http["path"] = select(6, ...)
	http["host"] = select(7, ...)
	http["https"] = select(8, ...)

	cgit = {}
	cgit["repo"] = select(9, ...)
	cgit["page"] = select(10, ...)
	cgit["url"] = select(11, ...)
	cgit["login"] = select(12, ...)
end

function filter_close()
	if action == nil then
		-- Unknown action, deny rather than raise.
		return 0
	end
	return action()
end

function filter_write(str)
	post = parse_qs(str)
end