@axis-network/butter-col 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -2
- package/bin/butter-col.js +4 -0
- package/dist/bootstrap.html +67 -0
- package/dist/client.js +2 -0
- package/dist/client.js.map +7 -0
- package/dist/controller.mjs +2 -0
- package/dist/controller.mjs.map +7 -0
- package/dist/formatter.mjs +2 -0
- package/dist/formatter.mjs.map +7 -0
- package/dist/sw.js +30 -0
- package/dist/sw.js.map +7 -0
- package/dist/transports/butter.js +2 -0
- package/dist/transports/butter.js.map +7 -0
- package/dist/types/controller/index.d.ts +65 -0
- package/dist/types/formatter/codecs.d.ts +17 -0
- package/dist/types/formatter/css.d.ts +10 -0
- package/dist/types/formatter/html.d.ts +61 -0
- package/dist/types/formatter/index.d.ts +77 -0
- package/dist/types/formatter/js.d.ts +27 -0
- package/dist/types/formatter/url.d.ts +40 -0
- package/lib/cli.js +257 -0
- package/lib/index.d.ts +68 -0
- package/lib/index.js +166 -0
- package/package.json +77 -4
- package/server/conf/butter-proxy.conf.template +29 -0
- package/server/conf/mime.types +19 -0
- package/server/conf/nginx.conf.template +152 -0
- package/server/config.lua +34 -0
- package/server/lua/butter/config.lua +160 -0
- package/server/lua/butter/cookiejar.lua +197 -0
- package/server/lua/butter/dns.lua +67 -0
- package/server/lua/butter/guard.lua +78 -0
- package/server/lua/butter/headers.lua +83 -0
- package/server/lua/butter/init.lua +18 -0
- package/server/lua/butter/ip.lua +165 -0
- package/server/lua/butter/limit.lua +18 -0
- package/server/lua/butter/session.lua +42 -0
- package/server/lua/butter/store.lua +77 -0
- package/server/lua/butter/transport.lua +248 -0
- package/server/lua/butter/url.lua +78 -0
- package/site/app.css +373 -0
- package/site/app.js +335 -0
- package/site/butter-col.png +0 -0
- package/site/index.html +139 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
-- DNS resolution with a shared cache. The proxy connects to the address it
|
|
2
|
+
-- resolved and checked here, so DNS rebinding cannot slip past the guard.
|
|
3
|
+
|
|
4
|
+
local resolver = require "resty.dns.resolver"
|
|
5
|
+
local ip = require "butter.ip"
|
|
6
|
+
|
|
7
|
+
local M = {}
|
|
8
|
+
|
|
9
|
+
local function decode(cached)
|
|
10
|
+
local out = {}
|
|
11
|
+
for text in cached:gmatch("[^,]+") do
|
|
12
|
+
local bytes, version = ip.parse(text)
|
|
13
|
+
if bytes then out[#out + 1] = { bytes = bytes, version = version } end
|
|
14
|
+
end
|
|
15
|
+
return out
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
local function query(r, host, qtype)
|
|
19
|
+
local answers, err = r:query(host, { qtype = qtype })
|
|
20
|
+
if not answers then return nil, err end
|
|
21
|
+
if answers.errcode then return nil, answers.errstr or ("DNS error " .. answers.errcode) end
|
|
22
|
+
local addrs, ttl = {}, nil
|
|
23
|
+
for _, answer in ipairs(answers) do
|
|
24
|
+
if answer.type == qtype and answer.address then
|
|
25
|
+
addrs[#addrs + 1] = answer.address
|
|
26
|
+
ttl = ttl and math.min(ttl, answer.ttl or ttl) or answer.ttl
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
return addrs, nil, ttl
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
--- Resolve `host` to a list of { bytes, version }.
|
|
33
|
+
-- @return table|nil, string|nil
|
|
34
|
+
function M.resolve(host, cfg)
|
|
35
|
+
local cache = ngx.shared.butter_dns
|
|
36
|
+
local cached = cache:get(host)
|
|
37
|
+
if cached then
|
|
38
|
+
if cached:sub(1, 1) == "!" then return nil, cached:sub(2) end
|
|
39
|
+
return decode(cached)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
local r, err = resolver:new({ nameservers = cfg.nameservers, retrans = cfg.retrans, timeout = cfg.timeout })
|
|
43
|
+
if not r then return nil, "cannot create resolver: " .. tostring(err) end
|
|
44
|
+
|
|
45
|
+
local addrs, qerr, ttl = query(r, host, r.TYPE_A)
|
|
46
|
+
if (not addrs or #addrs == 0) and cfg.ipv6 then
|
|
47
|
+
local addrs6, qerr6, ttl6 = query(r, host, r.TYPE_AAAA)
|
|
48
|
+
if addrs6 and #addrs6 > 0 then
|
|
49
|
+
addrs, ttl = addrs6, ttl6
|
|
50
|
+
else
|
|
51
|
+
qerr = qerr or qerr6
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if not addrs or #addrs == 0 then
|
|
56
|
+
local message = qerr or "no addresses found"
|
|
57
|
+
cache:set(host, "!" .. message, cfg.negative_ttl)
|
|
58
|
+
return nil, message
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
ttl = math.max(cfg.min_ttl, math.min(cfg.max_ttl, ttl or cfg.min_ttl))
|
|
62
|
+
local text = table.concat(addrs, ",")
|
|
63
|
+
cache:set(host, text, ttl)
|
|
64
|
+
return decode(text)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
return M
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
-- Decides whether a target may be proxied and which address to connect to.
|
|
2
|
+
|
|
3
|
+
local config = require "butter.config"
|
|
4
|
+
local dns = require "butter.dns"
|
|
5
|
+
local ip = require "butter.ip"
|
|
6
|
+
|
|
7
|
+
local M = {}
|
|
8
|
+
|
|
9
|
+
-- Ports browsers refuse to fetch (https://fetch.spec.whatwg.org/#bad-port).
|
|
10
|
+
local BAD_PORTS = {}
|
|
11
|
+
for _, port in ipairs({
|
|
12
|
+
1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102,
|
|
13
|
+
103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465,
|
|
14
|
+
512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993,
|
|
15
|
+
995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668,
|
|
16
|
+
6669, 6679, 6697, 10080,
|
|
17
|
+
}) do
|
|
18
|
+
BAD_PORTS[port] = true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
--- Does `host` equal `pattern` or sit under it?
|
|
22
|
+
local function host_matches(host, pattern)
|
|
23
|
+
pattern = pattern:lower():gsub("^%*?%.", "")
|
|
24
|
+
return host == pattern or (#host > #pattern and host:sub(-#pattern - 1) == "." .. pattern)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
local function matches_any(host, list)
|
|
28
|
+
for _, pattern in ipairs(list or {}) do
|
|
29
|
+
if host_matches(host, pattern) then return true end
|
|
30
|
+
end
|
|
31
|
+
return false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
--- Check a parsed target (butter.url) and pick the upstream address.
|
|
35
|
+
-- @return table|nil, string|nil, string|nil { addr = "ip:port", ip = "ip" } or nil, code, message
|
|
36
|
+
function M.check(target)
|
|
37
|
+
local cfg = config.get()
|
|
38
|
+
local g = cfg.guard
|
|
39
|
+
|
|
40
|
+
if BAD_PORTS[target.port] and not g.allow_bad_ports then
|
|
41
|
+
return nil, "BLOCKED_PORT", "Port " .. target.port .. " is not allowed."
|
|
42
|
+
end
|
|
43
|
+
if matches_any(target.host, g.blocked_hosts) then
|
|
44
|
+
return nil, "BLOCKED_HOST", target.host .. " is blocked on this server."
|
|
45
|
+
end
|
|
46
|
+
if g.allowed_hosts and not matches_any(target.host, g.allowed_hosts) then
|
|
47
|
+
return nil, "BLOCKED_HOST", target.host .. " is not on this server's allow list."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
local addrs
|
|
51
|
+
local literal, version = ip.parse(target.host)
|
|
52
|
+
if literal then
|
|
53
|
+
addrs = { { bytes = literal, version = version } }
|
|
54
|
+
elseif target.ipv6 then
|
|
55
|
+
return nil, "INVALID_URL", "Invalid IPv6 address."
|
|
56
|
+
else
|
|
57
|
+
local err
|
|
58
|
+
addrs, err = dns.resolve(target.host, cfg.dns)
|
|
59
|
+
if not addrs then
|
|
60
|
+
return nil, "DNS_FAILURE", "Could not resolve " .. target.host .. " (" .. tostring(err) .. ")."
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if not g.allow_private then
|
|
65
|
+
for _, a in ipairs(addrs) do
|
|
66
|
+
if ip.is_reserved(a.bytes, a.version) then
|
|
67
|
+
return nil, "BLOCKED_ADDRESS", target.host .. " points to a private or reserved address."
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
local chosen = addrs[1]
|
|
73
|
+
local text = ip.to_string(chosen.bytes, chosen.version)
|
|
74
|
+
local host = chosen.version == 6 and ("[" .. text .. "]") or text
|
|
75
|
+
return { addr = host .. ":" .. target.port, ip = text }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
return M
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
-- Header rules for the transport. Pure Lua, no ngx dependency.
|
|
2
|
+
|
|
3
|
+
local M = {}
|
|
4
|
+
|
|
5
|
+
local function set(list)
|
|
6
|
+
local s = {}
|
|
7
|
+
for _, v in ipairs(list) do s[v] = true end
|
|
8
|
+
return s
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
-- Headers from the browser's own request to /butter/fetch that are forwarded
|
|
12
|
+
-- upstream as-is (everything else is replaced by X-Butter-Headers).
|
|
13
|
+
M.KEEP_OUTER = set({ "user-agent", "accept-language", "accept-encoding", "content-length", "transfer-encoding" })
|
|
14
|
+
|
|
15
|
+
-- Never accepted from X-Butter-Headers.
|
|
16
|
+
M.REQUEST_FORBIDDEN = set({
|
|
17
|
+
"host", "connection", "keep-alive", "proxy-connection", "proxy-authorization", "te", "trailer",
|
|
18
|
+
"transfer-encoding", "upgrade", "content-length", "cookie", "cookie2", "expect",
|
|
19
|
+
"x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "x-real-ip", "forwarded", "via",
|
|
20
|
+
"user-agent", "accept-encoding",
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
-- Upstream response headers not reported in X-Butter-Headers.
|
|
24
|
+
M.RESPONSE_SKIP = set({ "connection", "keep-alive", "transfer-encoding", "proxy-connection", "trailer", "upgrade" })
|
|
25
|
+
|
|
26
|
+
-- Upstream response headers kept on the outer response so the browser
|
|
27
|
+
-- decodes the body before the service worker sees it.
|
|
28
|
+
M.OUTER_KEEP = set({ "content-encoding", "content-length" })
|
|
29
|
+
|
|
30
|
+
M.MAX_HEADERS = 100
|
|
31
|
+
M.MAX_VALUE = 16384
|
|
32
|
+
|
|
33
|
+
function M.valid_name(name)
|
|
34
|
+
return type(name) == "string" and #name <= 256 and name:match("^[!#$%%&'*+%-%.%^_`|~%w]+$") ~= nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
function M.valid_value(value)
|
|
38
|
+
return type(value) == "string" and #value <= M.MAX_VALUE
|
|
39
|
+
and not value:find("[\r\n]") and not value:find("\0", 1, true)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
--- Validate the decoded X-Butter-Headers object.
|
|
43
|
+
-- @return table|nil, string|nil list of { name, value }
|
|
44
|
+
function M.sanitize_request(decoded)
|
|
45
|
+
if type(decoded) ~= "table" then return nil, "headers must be a JSON object" end
|
|
46
|
+
local out, count = {}, 0
|
|
47
|
+
for name, value in pairs(decoded) do
|
|
48
|
+
if not M.valid_name(name) then return nil, "invalid header name" end
|
|
49
|
+
if not M.valid_value(value) then return nil, "invalid value for header " .. name end
|
|
50
|
+
local lower = name:lower()
|
|
51
|
+
if not M.REQUEST_FORBIDDEN[lower] and lower:sub(1, 9) ~= "x-butter-" then
|
|
52
|
+
count = count + 1
|
|
53
|
+
if count > M.MAX_HEADERS then return nil, "too many headers" end
|
|
54
|
+
out[#out + 1] = { name, value }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
table.sort(out, function(a, b) return a[1] < b[1] end)
|
|
58
|
+
return out
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
M.REASONS = {
|
|
62
|
+
[100] = "Continue", [101] = "Switching Protocols",
|
|
63
|
+
[200] = "OK", [201] = "Created", [202] = "Accepted", [203] = "Non-Authoritative Information",
|
|
64
|
+
[204] = "No Content", [205] = "Reset Content", [206] = "Partial Content",
|
|
65
|
+
[300] = "Multiple Choices", [301] = "Moved Permanently", [302] = "Found", [303] = "See Other",
|
|
66
|
+
[304] = "Not Modified", [307] = "Temporary Redirect", [308] = "Permanent Redirect",
|
|
67
|
+
[400] = "Bad Request", [401] = "Unauthorized", [402] = "Payment Required", [403] = "Forbidden",
|
|
68
|
+
[404] = "Not Found", [405] = "Method Not Allowed", [406] = "Not Acceptable", [407] = "Proxy Authentication Required",
|
|
69
|
+
[408] = "Request Timeout", [409] = "Conflict", [410] = "Gone", [411] = "Length Required",
|
|
70
|
+
[412] = "Precondition Failed", [413] = "Content Too Large", [414] = "URI Too Long",
|
|
71
|
+
[415] = "Unsupported Media Type", [416] = "Range Not Satisfiable", [417] = "Expectation Failed",
|
|
72
|
+
[418] = "I'm a teapot", [421] = "Misdirected Request", [422] = "Unprocessable Content", [425] = "Too Early",
|
|
73
|
+
[426] = "Upgrade Required", [428] = "Precondition Required", [429] = "Too Many Requests",
|
|
74
|
+
[431] = "Request Header Fields Too Large", [451] = "Unavailable For Legal Reasons",
|
|
75
|
+
[500] = "Internal Server Error", [501] = "Not Implemented", [502] = "Bad Gateway",
|
|
76
|
+
[503] = "Service Unavailable", [504] = "Gateway Timeout", [505] = "HTTP Version Not Supported",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function M.reason(status)
|
|
80
|
+
return M.REASONS[status] or ""
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
return M
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- butter-col server entry point (loaded by init_by_lua in nginx.conf).
|
|
2
|
+
|
|
3
|
+
local M = {}
|
|
4
|
+
|
|
5
|
+
--- @param config_path string|nil user configuration; defaults to <prefix>/config.lua
|
|
6
|
+
function M.init(config_path)
|
|
7
|
+
local config = require "butter.config"
|
|
8
|
+
local cfg = config.load(config_path or (ngx.config.prefix() .. "config.lua"))
|
|
9
|
+
-- Load everything now so workers share the compiled code (and so syntax
|
|
10
|
+
-- errors stop nginx at startup instead of at the first request).
|
|
11
|
+
require "butter.transport"
|
|
12
|
+
local names = {}
|
|
13
|
+
for i, entry in ipairs(cfg.transports) do names[i] = entry.name end
|
|
14
|
+
ngx.log(ngx.NOTICE, "butter-col ", config.VERSION, " ready (prefix ", cfg.prefix, ", codec ", cfg.codec,
|
|
15
|
+
", transports ", table.concat(names, ","), ", default ", cfg.default_transport, ")")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
return M
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
-- IPv4 / IPv6 parsing and reserved-range checks for SSRF protection.
|
|
2
|
+
-- Pure Lua (5.1 / LuaJIT / 5.3) without bit operators: addresses are byte arrays.
|
|
3
|
+
|
|
4
|
+
local M = {}
|
|
5
|
+
|
|
6
|
+
--- Parse dotted-quad IPv4. Rejects octal-looking parts like "010".
|
|
7
|
+
-- @return table|nil 4 bytes
|
|
8
|
+
function M.parse_v4(s)
|
|
9
|
+
local parts = { s:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") }
|
|
10
|
+
if #parts ~= 4 then return nil end
|
|
11
|
+
local bytes = {}
|
|
12
|
+
for i = 1, 4 do
|
|
13
|
+
local p = parts[i]
|
|
14
|
+
if #p > 3 or (#p > 1 and p:sub(1, 1) == "0") then return nil end
|
|
15
|
+
local n = tonumber(p)
|
|
16
|
+
if n > 255 then return nil end
|
|
17
|
+
bytes[i] = n
|
|
18
|
+
end
|
|
19
|
+
return bytes
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
local function parse_groups(s)
|
|
23
|
+
local groups = {}
|
|
24
|
+
if s == "" then return groups end
|
|
25
|
+
for group in (s .. ":"):gmatch("([^:]*):") do
|
|
26
|
+
if not group:match("^%x%x?%x?%x?$") then return nil end
|
|
27
|
+
groups[#groups + 1] = tonumber(group, 16)
|
|
28
|
+
end
|
|
29
|
+
return groups
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
--- Parse IPv6 (with optional embedded IPv4 tail). Zone ids are rejected.
|
|
33
|
+
-- @return table|nil 16 bytes
|
|
34
|
+
function M.parse_v6(s)
|
|
35
|
+
if s:find("%", 1, true) then return nil end
|
|
36
|
+
local v4_tail
|
|
37
|
+
local last_colon = s:match("^.*():")
|
|
38
|
+
if last_colon and s:find(".", last_colon, true) then
|
|
39
|
+
v4_tail = M.parse_v4(s:sub(last_colon + 1))
|
|
40
|
+
if not v4_tail then return nil end
|
|
41
|
+
s = s:sub(1, last_colon)
|
|
42
|
+
if s:sub(-2) ~= "::" then s = s:sub(1, -2) end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
local head, tail = s:match("^(.-)::(.*)$")
|
|
46
|
+
local groups
|
|
47
|
+
local extra = v4_tail and 2 or 0
|
|
48
|
+
if head then
|
|
49
|
+
if tail:find("::", 1, true) then return nil end
|
|
50
|
+
local h, t = parse_groups(head), parse_groups(tail)
|
|
51
|
+
if not h or not t then return nil end
|
|
52
|
+
local missing = 8 - extra - #h - #t
|
|
53
|
+
if missing < 1 then return nil end
|
|
54
|
+
groups = h
|
|
55
|
+
for _ = 1, missing do groups[#groups + 1] = 0 end
|
|
56
|
+
for _, g in ipairs(t) do groups[#groups + 1] = g end
|
|
57
|
+
else
|
|
58
|
+
groups = parse_groups(s)
|
|
59
|
+
if not groups or #groups ~= 8 - extra then return nil end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
local bytes = {}
|
|
63
|
+
for _, g in ipairs(groups) do
|
|
64
|
+
bytes[#bytes + 1] = math.floor(g / 256)
|
|
65
|
+
bytes[#bytes + 1] = g % 256
|
|
66
|
+
end
|
|
67
|
+
if v4_tail then
|
|
68
|
+
for i = 1, 4 do bytes[#bytes + 1] = v4_tail[i] end
|
|
69
|
+
end
|
|
70
|
+
return bytes
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
--- @return table|nil, number|nil bytes and version (4 or 6)
|
|
74
|
+
function M.parse(s)
|
|
75
|
+
if type(s) ~= "string" then return nil end
|
|
76
|
+
local v4 = M.parse_v4(s)
|
|
77
|
+
if v4 then return v4, 4 end
|
|
78
|
+
if s:find(":", 1, true) then
|
|
79
|
+
local v6 = M.parse_v6(s)
|
|
80
|
+
if v6 then return v6, 6 end
|
|
81
|
+
end
|
|
82
|
+
return nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
--- Does `bytes` fall inside network/bits?
|
|
86
|
+
function M.in_cidr(bytes, network, bits)
|
|
87
|
+
for i = 1, #network do
|
|
88
|
+
if bits <= 0 then return true end
|
|
89
|
+
if bits >= 8 then
|
|
90
|
+
if bytes[i] ~= network[i] then return false end
|
|
91
|
+
bits = bits - 8
|
|
92
|
+
else
|
|
93
|
+
local div = 2 ^ (8 - bits)
|
|
94
|
+
return math.floor(bytes[i] / div) == math.floor(network[i] / div)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
return true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
--- Compile CIDR strings into { bytes, bits, version } entries.
|
|
101
|
+
function M.compile(list)
|
|
102
|
+
local out = {}
|
|
103
|
+
for _, cidr in ipairs(list) do
|
|
104
|
+
local addr, bits = cidr:match("^(.-)/(%d+)$")
|
|
105
|
+
local bytes, version = M.parse(addr or cidr)
|
|
106
|
+
if not bytes then error("invalid CIDR: " .. tostring(cidr)) end
|
|
107
|
+
out[#out + 1] = { bytes = bytes, bits = tonumber(bits) or (#bytes * 8), version = version }
|
|
108
|
+
end
|
|
109
|
+
return out
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
M.RESERVED = M.compile({
|
|
113
|
+
-- IPv4
|
|
114
|
+
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
|
|
115
|
+
"172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24", "192.88.99.0/24", "192.168.0.0/16",
|
|
116
|
+
"198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4",
|
|
117
|
+
-- IPv6
|
|
118
|
+
"::/128", "::1/128", "100::/64", "2001::/23", "2001:db8::/32", "fc00::/7",
|
|
119
|
+
"fe80::/10", "fec0::/10", "ff00::/8",
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
local function embedded_v4(bytes)
|
|
123
|
+
-- ::ffff:a.b.c.d (mapped), ::a.b.c.d (compatible), 64:ff9b::a.b.c.d (NAT64)
|
|
124
|
+
local zero10 = true
|
|
125
|
+
for i = 1, 10 do
|
|
126
|
+
if bytes[i] ~= 0 then zero10 = false break end
|
|
127
|
+
end
|
|
128
|
+
if zero10 and ((bytes[11] == 255 and bytes[12] == 255) or (bytes[11] == 0 and bytes[12] == 0)) then
|
|
129
|
+
return { bytes[13], bytes[14], bytes[15], bytes[16] }
|
|
130
|
+
end
|
|
131
|
+
if bytes[1] == 0 and bytes[2] == 0x64 and bytes[3] == 0xff and bytes[4] == 0x9b then
|
|
132
|
+
return { bytes[13], bytes[14], bytes[15], bytes[16] }
|
|
133
|
+
end
|
|
134
|
+
-- 2002:AABB:CCDD::/48 (6to4)
|
|
135
|
+
if bytes[1] == 0x20 and bytes[2] == 0x02 then
|
|
136
|
+
return { bytes[3], bytes[4], bytes[5], bytes[6] }
|
|
137
|
+
end
|
|
138
|
+
return nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
--- Is the address private, loopback, link-local, multicast or otherwise reserved?
|
|
142
|
+
function M.is_reserved(bytes, version)
|
|
143
|
+
for _, net in ipairs(M.RESERVED) do
|
|
144
|
+
if net.version == version and M.in_cidr(bytes, net.bytes, net.bits) then return true end
|
|
145
|
+
end
|
|
146
|
+
if version == 6 then
|
|
147
|
+
local v4 = embedded_v4(bytes)
|
|
148
|
+
if v4 then return M.is_reserved(v4, 4) end
|
|
149
|
+
end
|
|
150
|
+
return false
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
--- Canonical text form (IPv6 uncompressed), suitable for nginx proxy_pass.
|
|
154
|
+
function M.to_string(bytes, version)
|
|
155
|
+
if version == 4 then
|
|
156
|
+
return string.format("%d.%d.%d.%d", bytes[1], bytes[2], bytes[3], bytes[4])
|
|
157
|
+
end
|
|
158
|
+
local groups = {}
|
|
159
|
+
for i = 1, 16, 2 do
|
|
160
|
+
groups[#groups + 1] = string.format("%x", bytes[i] * 256 + bytes[i + 1])
|
|
161
|
+
end
|
|
162
|
+
return table.concat(groups, ":")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
return M
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- Fixed-window rate limiter per client address.
|
|
2
|
+
|
|
3
|
+
local M = {}
|
|
4
|
+
|
|
5
|
+
--- @return boolean true if the request may proceed
|
|
6
|
+
function M.check(cfg)
|
|
7
|
+
if not cfg or not cfg.enabled then return true end
|
|
8
|
+
local window = cfg.window
|
|
9
|
+
local key = ngx.var.binary_remote_addr .. ":" .. math.floor(ngx.now() / window)
|
|
10
|
+
local count, err = ngx.shared.butter_limit:incr(key, 1, 0, window)
|
|
11
|
+
if not count then
|
|
12
|
+
ngx.log(ngx.WARN, "butter: rate limiter unavailable: ", err)
|
|
13
|
+
return true
|
|
14
|
+
end
|
|
15
|
+
return count <= cfg.requests
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
return M
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
-- Browser sessions: a random id in an HttpOnly cookie on the proxy origin
|
|
2
|
+
-- keys the server-side cookie jar.
|
|
3
|
+
|
|
4
|
+
local config = require "butter.config"
|
|
5
|
+
|
|
6
|
+
local M = {}
|
|
7
|
+
|
|
8
|
+
M.COOKIE = "butter_sid"
|
|
9
|
+
|
|
10
|
+
--- @return string, boolean session id, and whether it was just created
|
|
11
|
+
function M.get()
|
|
12
|
+
local sid = ngx.var["cookie_" .. M.COOKIE]
|
|
13
|
+
if sid and #sid == 32 and sid:match("^%x+$") then return sid, false end
|
|
14
|
+
local random = require "resty.random"
|
|
15
|
+
local str = require "resty.string"
|
|
16
|
+
local bytes = random.bytes(16, true) or random.bytes(16)
|
|
17
|
+
return str.to_hex(bytes), true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
--- Set-Cookie value for the session cookie.
|
|
21
|
+
function M.cookie(sid)
|
|
22
|
+
local parts = {
|
|
23
|
+
M.COOKIE .. "=" .. sid,
|
|
24
|
+
"Path=/",
|
|
25
|
+
"HttpOnly",
|
|
26
|
+
"SameSite=Strict",
|
|
27
|
+
"Max-Age=" .. string.format("%d", config.get().cookies.session_ttl),
|
|
28
|
+
}
|
|
29
|
+
if ngx.var.scheme == "https" or ngx.var.http_x_forwarded_proto == "https" then
|
|
30
|
+
parts[#parts + 1] = "Secure"
|
|
31
|
+
end
|
|
32
|
+
return table.concat(parts, "; ")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
--- Issue the session cookie on the current response if the request had none.
|
|
36
|
+
function M.ensure()
|
|
37
|
+
local sid, created = M.get()
|
|
38
|
+
if created then ngx.header["Set-Cookie"] = M.cookie(sid) end
|
|
39
|
+
return sid, created
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
return M
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
-- Cookie jar storage: one JSON list per session in a shared dict.
|
|
2
|
+
|
|
3
|
+
local cjson = require "cjson.safe"
|
|
4
|
+
local jar = require "butter.cookiejar"
|
|
5
|
+
local config = require "butter.config"
|
|
6
|
+
|
|
7
|
+
local M = {}
|
|
8
|
+
|
|
9
|
+
local function dict()
|
|
10
|
+
return ngx.shared.butter_cookies
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
local function load(sid)
|
|
14
|
+
local raw = dict():get("jar:" .. sid)
|
|
15
|
+
if not raw then return {} end
|
|
16
|
+
local list = cjson.decode(raw)
|
|
17
|
+
return type(list) == "table" and list or {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
local function save(sid, list)
|
|
21
|
+
local key = "jar:" .. sid
|
|
22
|
+
if #list == 0 then
|
|
23
|
+
dict():delete(key)
|
|
24
|
+
return
|
|
25
|
+
end
|
|
26
|
+
local ok, err, forcible = dict():set(key, cjson.encode(list), config.get().cookies.session_ttl)
|
|
27
|
+
if not ok then
|
|
28
|
+
ngx.log(ngx.ERR, "butter: cannot store cookies: ", err)
|
|
29
|
+
elseif forcible then
|
|
30
|
+
ngx.log(ngx.WARN, "butter: butter_cookies is full; evicting old sessions (consider a larger lua_shared_dict)")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
-- Header filters cannot yield, so resty.lock is out. A short spin on an
|
|
35
|
+
-- add()-based lock covers the rare case of two workers updating the same
|
|
36
|
+
-- session at the same instant; on timeout we proceed anyway.
|
|
37
|
+
local function with_lock(sid, fn)
|
|
38
|
+
local key = "lock:" .. sid
|
|
39
|
+
local locked = false
|
|
40
|
+
for _ = 1, 500 do
|
|
41
|
+
if dict():add(key, true, 0.05) then
|
|
42
|
+
locked = true
|
|
43
|
+
break
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
local ok, err = pcall(fn)
|
|
47
|
+
if locked then dict():delete(key) end
|
|
48
|
+
if not ok then ngx.log(ngx.ERR, "butter: cookie store error: ", err) end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
--- Store Set-Cookie values received from (or written by scripts for) `target`.
|
|
52
|
+
function M.set_cookies(sid, target, values, opts)
|
|
53
|
+
local now = ngx.time()
|
|
54
|
+
local max = config.get().cookies.max_per_session
|
|
55
|
+
with_lock(sid, function()
|
|
56
|
+
local list = load(sid)
|
|
57
|
+
for _, value in ipairs(values) do
|
|
58
|
+
local cookie = jar.parse_set_cookie(value, target, now)
|
|
59
|
+
if cookie then
|
|
60
|
+
list = jar.upsert(list, cookie, now, { from_script = opts and opts.from_script, max = max })
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
save(sid, list)
|
|
64
|
+
end)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
--- Cookie request header for `target`.
|
|
68
|
+
function M.cookie_header(sid, target)
|
|
69
|
+
return jar.header(load(sid), target, ngx.time())
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
--- What document.cookie should return for a page at `target`.
|
|
73
|
+
function M.document_cookie(sid, target)
|
|
74
|
+
return jar.header(load(sid), target, ngx.time(), { for_script = true })
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
return M
|