@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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -2
  3. package/bin/butter-col.js +4 -0
  4. package/dist/bootstrap.html +67 -0
  5. package/dist/client.js +2 -0
  6. package/dist/client.js.map +7 -0
  7. package/dist/controller.mjs +2 -0
  8. package/dist/controller.mjs.map +7 -0
  9. package/dist/formatter.mjs +2 -0
  10. package/dist/formatter.mjs.map +7 -0
  11. package/dist/sw.js +30 -0
  12. package/dist/sw.js.map +7 -0
  13. package/dist/transports/butter.js +2 -0
  14. package/dist/transports/butter.js.map +7 -0
  15. package/dist/types/controller/index.d.ts +65 -0
  16. package/dist/types/formatter/codecs.d.ts +17 -0
  17. package/dist/types/formatter/css.d.ts +10 -0
  18. package/dist/types/formatter/html.d.ts +61 -0
  19. package/dist/types/formatter/index.d.ts +77 -0
  20. package/dist/types/formatter/js.d.ts +27 -0
  21. package/dist/types/formatter/url.d.ts +40 -0
  22. package/lib/cli.js +257 -0
  23. package/lib/index.d.ts +68 -0
  24. package/lib/index.js +166 -0
  25. package/package.json +77 -4
  26. package/server/conf/butter-proxy.conf.template +29 -0
  27. package/server/conf/mime.types +19 -0
  28. package/server/conf/nginx.conf.template +152 -0
  29. package/server/config.lua +34 -0
  30. package/server/lua/butter/config.lua +160 -0
  31. package/server/lua/butter/cookiejar.lua +197 -0
  32. package/server/lua/butter/dns.lua +67 -0
  33. package/server/lua/butter/guard.lua +78 -0
  34. package/server/lua/butter/headers.lua +83 -0
  35. package/server/lua/butter/init.lua +18 -0
  36. package/server/lua/butter/ip.lua +165 -0
  37. package/server/lua/butter/limit.lua +18 -0
  38. package/server/lua/butter/session.lua +42 -0
  39. package/server/lua/butter/store.lua +77 -0
  40. package/server/lua/butter/transport.lua +248 -0
  41. package/server/lua/butter/url.lua +78 -0
  42. package/site/app.css +373 -0
  43. package/site/app.js +335 -0
  44. package/site/butter-col.png +0 -0
  45. 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