blob: 3d426e97f754f7d7d1eacea8249c80e40c53f137 (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
-- A cgit auth filter that puts chosen repositories behind a login form and a
-- signed session cookie. cgit consults it on every request once cgitrc names
-- it with auth-filter=lua:/path/to/auth-file.lua, and it answers the
-- authenticate-cookie, authenticate-post and body actions the filter API
-- defines. This variant keeps the accounts, the groups and the per-repository
-- access lists in files on disk, which suits a user set that is large or
-- maintained by something else. The companion auth-inline.lua behaves
-- identically but carries the same lists inside the script.

-- The script runs on Lua 5.1, 5.2, 5.3, 5.4 and LuaJIT, and the runtime has to
-- be the same Lua that cgit was built against. Lua 5.5 will not do, because
-- luaossl has no 5.5 build.
--
-- The session cookie is marked Secure by default, so a browser only sends
-- it back over HTTPS. On an instance served over plain HTTP login loops
-- until cookie_insecure below is set.
--
-- Two libraries are needed. luaossl provides openssl.rand and openssl.hmac and
-- lives at <https://github.com/wahern/luaossl>, and luaposix provides
-- posix.sys.stat and posix.unistd and lives at
-- <https://github.com/luaposix/luaposix>. The reliable cross-platform way to
-- install them is LuaRocks matched to your Lua version, and 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
--
-- There is no server-side session store and no CSRF token, so a deleted
-- account's cookie stays valid until it expires and instances sharing a
-- secret file accept each other's cookies. That is acceptable for gating
-- read access to a git browser, so weigh it before guarding anything more.

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

-- The values that follow are the configuration and are meant to be edited.

-- Accounts live 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 lives one per line as groupname:user1,user2,user3 and so
-- on.
local groups_filename = "/etc/cgit-auth/groups"

-- Per-repository access lives one per line as reponame:group1,group2 and so
-- on. A repository named here is protected and one that is not named is
-- public. The repository name has to match exactly, while group and user names
-- match regardless of case.
local repos_filename = "/etc/cgit-auth/repos"

-- Where the cookie-signing secret is stored, created on first use. It must be
-- persistent and writable by cgit, and it is worth keeping 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/lib/cgit/auth-secret"

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

local cookie_name = "cgitauth"

-- "/" covers the whole host. Set this to the cgit root to scope the cookie
-- more tightly.
local cookie_path = "/"

-- Set this true only if cgit is served over plain HTTP with no TLS anywhere.
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 user name exists.
local dummy_hash = "$6$rounds=300000$0000000000000000$"

-- One request reaches this script as an open, a write and a close, so what the
-- open decodes is kept here for the calls that follow.
local action, http, cgit, post

-- The two lookups below, account_hash and repo_userset, are the only part
-- of this script that differs from auth-inline.lua.

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

local function add_names(list, set)
	for name in string.gmatch(list, "([^,]+)") do
		set[trim(name):lower()] = true
	end
end

-- A missing, unreadable or unparsable users file turns every login down
-- rather than failing the request outright.
--
-- The hash is trimmed as well as the name because reading by line strips
-- the newline but not a carriage return, so a CRLF users file would hand
-- crypt a hash with a trailing \r and fail every login.
function account_hash(user)
	if user == nil then
		return nil
	end
	local wanted = user:lower()
	local users_file = io.open(users_filename, "r")
	if users_file == nil then
		return nil
	end
	for line in users_file:lines() do
		local name, hash = string.match(line, "(.-):(.+)")
		if name ~= nil and trim(name):lower() == wanted then
			users_file:close()
			return trim(hash)
		end
	end
	users_file:close()
	return nil
end

-- The users allowed into a repository come back keyed by lowercased name, and
-- a repository the repos file does not name comes back as nil. A protected
-- repository whose groups resolve to nobody comes back as an empty table
-- instead, so it denies everyone rather than falling through to public.
function repo_userset(repo)
	if repo == nil then
		return nil
	end
	local groups = nil
	local repos_file = io.open(repos_filename, "r")
	if repos_file ~= nil then
		for line in repos_file:lines() do
			local name, list = string.match(line, "(.-):(.+)")
			if name ~= nil and trim(name) == repo then
				groups = {}
				add_names(list, groups)
				break
			end
		end
		repos_file:close()
	end
	if groups == nil then
		return nil
	end
	local users = {}
	local groups_file = io.open(groups_filename, "r")
	if groups_file ~= nil then
		for line in groups_file:lines() do
			local name, list = string.match(line, "(.-):(.+)")
			if name ~= nil and groups[trim(name):lower()] then
				add_names(list, users)
			end
		end
		groups_file:close()
	end
	return users
end

-- The URL helpers below are adapted from keplerproject/wsapi.

function url_decode(str)
	if not str then
		return ""
	end
	str = string.gsub(str, "+", " ")
	str = string.gsub(str, "%%(%x%x)", function(hex)
		return string.char(tonumber(hex, 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(char)
		return string.format("%%%02X", string.byte(char))
	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_query(query)
	local params = {}
	for key, value in string.gmatch(query or "", "([^&=]+)=([^&]*)") do
		params[url_decode(key)] = url_decode(value)
	end
	return params
end

local function pattern_escape(s)
	return (string.gsub(s, "([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1"))
end

-- The token comes back still URL encoded by secure_value. Decoding it here
-- would break the signature check for any value carrying a percent escape.
-- The name is escaped because it lands in a Lua pattern.
function get_cookie(cookies, name)
	cookies = string.gsub(";" .. (cookies or "") .. ";", "%s*;%s*", ";")
	return string.match(cookies, ";" .. pattern_escape(name) .. "=(.-);")
end

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

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
		-- The secret is written under a tightened mask so it is not
		-- created readable by anyone but the user cgit runs as, and
		-- the old mask goes back on every way out.
		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
		-- The link is meant to fail when another worker won the race,
		-- which leaves that worker's secret in place rather than
		-- replacing it and invalidating the sessions it just signed.
		unistd.link(temporary_filename, secret_filename)
		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 signature = ""

	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, since
			-- tonumber and tostring of "1e9" or "100.0" differ
			-- across Lua versions and the signed bytes have to
			-- come back byte for byte.
			if not string.match(component, "^%d+$") then
				return nil
			end
			expiration = tonumber(component)
		elseif i == 3 then
			salt = component
		elseif i == 4 then
			signature = component
		else
			break
		end
		i = i + 1
	end

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

	local payload = field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt
	local expected_signature = tohex(hmac.new(get_secret(), "sha256"):final(payload))
	if not constant_equals(signature, expected_signature) 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

-- The layout built here is what validate_value takes apart again, so the two
-- have to move together.
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 payload = field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt
	local signature = tohex(hmac.new(get_secret(), "sha256"):final(payload))
	return payload .. "|" .. signature
end

-- A control character in a header value would let that value split the
-- response, so it is dropped rather than escaped.
function strip_controls(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 delta = a:byte(i) - b:byte(i)
		diff = diff + delta * delta
	end
	return diff == 0
end

-- An empty value is how a login is cleared, since Max-Age=0 tells the browser
-- to drop the cookie it already holds.
function set_cookie(cookie, value)
	local attributes = "; HttpOnly; SameSite=Lax; Path=" .. cookie_path
	if not cookie_insecure then
		attributes = attributes .. "; Secure"
	end
	if value == "" then
		attributes = attributes .. "; Max-Age=0"
	elseif session_seconds > 0 then
		attributes = attributes .. "; Max-Age=" .. tostring(session_seconds)
	end
	html("Set-Cookie: " .. cookie .. "=" .. strip_controls(value) .. attributes .. "\n")
end

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

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

-- The three actions cgit can ask for follow, and they are the same in both
-- variants, so a change to one belongs in the other.

-- The redirect goes out before the password is checked, so a wrong password
-- and a right one answer with the same status and location and differ only in
-- the cookie.
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

-- cgit reads the answer, where 1 lets the request through and 0 sends it to
-- the login form.
function authenticate_cookie()
	local accepted_users = repo_userset(cgit["repo"])
	if accepted_users == nil then
		-- A repository nothing lists is public.
		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

-- cgit calls this to fill the page body once the cookie has been turned down.
-- The form carries a signed redirect token, so the browser lands back on the
-- page that was asked for.
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>")
	html("<input id='username' name='username' type='text'")
	html(" autocomplete='username' autofocus></td></tr>")
	html("<tr><td><label for='password'>Password:</label></td><td>")
	html("<input id='password' name='password' type='password'")
	html(" autocomplete='current-password'></td></tr>")
	html("<tr><td colspan='2'>")
	html("<input value='Login' type='submit'></td></tr>")
	html("</table></form>")

	return 0
end

-- filter_open unpacks the action and request fields cgit passes in fixed
-- order, filter_write collects a post body, and filter_close runs the action.

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
		-- An unknown action denies rather than raising.
		return 0
	end
	return action()
end

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