-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhaproxy_req_fp.lua
More file actions
366 lines (303 loc) · 12.2 KB
/
Copy pathhaproxy_req_fp.lua
File metadata and controls
366 lines (303 loc) · 12.2 KB
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
--[[
SPDX-License-Identifier: Apache-2.0 WITH Commons-Clause
Copyright (c) 2026 Austin Kauffman
haproxy_req_fp - HAProxy request fingerprint module
Registers the Lua action "req_fp" (http-res phase) that computes a
17-field fingerprint for each HTTP request/response pair and stores it
in the transaction variable txn.req_fp.
Fingerprint format (17 underscore-separated fields):
{path_b62}_{method2}_{http_ver}_{path_depth}_
{param_keys}_{param_types}_{param_lens}_{req_ctype}_
{hdr_count}_{hdr_list}_{accept_lang}_{auth_type}_
{cookie}_{cookie_fields}_{referer}_
{status}_{body_bytes}
Field definitions:
path_b62 - URI path bytes as a big-endian integer, base62-encoded
method2 - first 2 chars of HTTP method, lowercased (ge, po, pu, de, ...)
http_ver - 2-digit protocol version: 09 10 11 20 30
path_depth - number of '/' chars in URI, zero-padded 2 digits, max 99
param_keys - first char of each param name sorted; "nil" if none (max 32)
param_types - type code per value, same order; "nil" if none
param_lens - decoded value lengths, dash-separated; "0" if none
req_ctype - first 4 alpha chars of request Content-Type subtype;
"0000" if absent (e.g. "json", "html", "xwww", "form")
hdr_count - total request header count, zero-padded 2 digits, max 99
hdr_list - sorted first-char initials of all header names; "nil" if none
accept_lang - first 4 lowercase alpha chars of primary Accept-Language tag;
"0000" if absent (hyphens and digits stripped)
auth_type - 'n' none, 'b' Basic, 't' Bearer/token, 'd' Digest, 'o' other
cookie - 'c' if Cookie header present, 'n' if absent
cookie_fields - sorted first-char initials of cookie field names; "nil" if none
referer - 'n' no Referer, 's' same-domain, 'x' cross-domain
status - HTTP response status code
body_bytes - response Content-Length; "0" if absent
Param source: URL query string only.
Body params (application/x-www-form-urlencoded) require option http-buffer-request
and a separate http-req action; see README for the two-phase pattern.
Value type codes: int(i) float(f) string(s) char(c) bool(b) time(t)
date(d) datetime+tz(z) empty(e) object(o) list(l)
Usage in haproxy.cfg:
global
lua-load /etc/haproxy/haproxy_req_fp.lua
frontend http-in
http-response lua.req_fp
log-format "%ci [%t] %[var(txn.req_fp)]"
--]]
-- ---- Constants -------------------------------------------------------------
local MAX_PARAMS = 32
local PATH_MAX = 2048
local B62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
-- ---- Base62 encoder --------------------------------------------------------
-- Treats the bytes of s as a big-endian integer and encodes it in base62.
-- Caps input at PATH_MAX bytes. Returns "0" for empty input.
local function base62_encode(s)
if #s > PATH_MAX then s = s:sub(1, PATH_MAX) end
if #s == 0 then return "0" end
-- n is an array of byte-width "digits" representing the big-endian integer.
-- Long division by 62 is performed until the value reaches zero.
local n = { s:byte(1, #s) }
local out = {}
while #n > 0 do
local rem = 0
local new_n = {}
for i = 1, #n do
local val = rem * 256 + n[i]
local q = math.floor(val / 62)
rem = val % 62
if q > 0 or #new_n > 0 then
new_n[#new_n + 1] = q
end
end
table.insert(out, 1, B62_CHARS:sub(rem + 1, rem + 1))
n = new_n
end
return table.concat(out)
end
-- ---- URL decoder -----------------------------------------------------------
local function url_decode(s)
s = s:gsub('+', ' ')
s = s:gsub('%%(%x%x)', function(h) return string.char(tonumber(h, 16)) end)
return s
end
-- ---- Value type detector ---------------------------------------------------
local function detect_type(v)
if #v == 0 then return 'e' end
if v:sub(1, 1) == '{' then return 'o' end
if v:sub(1, 1) == '[' then return 'l' end
local lv = v:lower()
if lv == 'true' or lv == 'false' then return 'b' end
-- datetime with timezone (must check before plain date)
if v:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d:%d%d') then
if v:match('[Zz]$') or v:match('[%+%-]%d%d:?%d%d$') then
return 'z'
end
end
if v:match('^%d%d%d%d%-%d%d%-%d%d$') then return 'd' end
if v:match('^%d%d:%d%d:%d%d') then return 't' end
if v:match('^%-?%d+$') then return 'i' end
if v:match('^%-?%d*%.%d+$') then return 'f' end
if #v == 1 then return 'c' end
return 's'
end
-- ---- Query/body parameter parser -------------------------------------------
-- Parses a query-string or form-urlencoded byte string into a sorted array
-- of {name, value} tables, capped at MAX_PARAMS entries.
local function parse_params(qs)
if not qs or #qs == 0 then return {} end
local params = {}
for kv in (qs .. '&'):gmatch('([^&]*)&') do
if #kv > 0 and #params < MAX_PARAMS then
local k, v = kv:match('^([^=]*)=?(.*)')
k = k and url_decode(k) or ''
v = v and url_decode(v) or ''
if #k > 0 then
params[#params + 1] = { name = k, value = v }
end
end
end
table.sort(params, function(a, b) return a.name < b.name end)
return params
end
-- ---- HTTP version mapper ---------------------------------------------------
local VER_MAP = {
['0.9'] = '09', ['1.0'] = '10', ['1.1'] = '11',
['2'] = '20', ['2.0'] = '20',
['3'] = '30', ['3.0'] = '30',
}
local function http_ver_code(ver)
return VER_MAP[ver] or '11'
end
-- ---- Path depth ------------------------------------------------------------
local function get_path_depth(path)
local count = 0
for _ in path:gmatch('/') do count = count + 1 end
return math.min(count, 99)
end
-- ---- Request Content-Type subtype ------------------------------------------
-- Returns first 4 lowercase alpha chars of the Content-Type subtype (after '/').
-- Falls back to full value if no '/'. Returns "0000" if header absent.
local function get_req_ctype(req_hdrs)
local ct = req_hdrs['content-type']
if not ct or not ct[1] then return '0000' end
local val = ct[1]
local subtype = val:match('/([^;]+)') or val
local out = {}
for i = 1, #subtype do
local c = subtype:sub(i, i)
if c == ';' then break end
if c:match('[a-zA-Z]') then
out[#out + 1] = c:lower()
if #out == 4 then break end
end
end
while #out < 4 do out[#out + 1] = '0' end
return table.concat(out)
end
-- ---- Accept-Language -------------------------------------------------------
-- Returns first 4 lowercase alpha chars of the primary language tag.
-- Stops at the first ',' or ';'. Returns "0000" if header absent.
local function get_accept_lang(req_hdrs)
local al = req_hdrs['accept-language']
if not al or not al[1] then return '0000' end
local out = {}
for i = 1, #al[1] do
local c = al[1]:sub(i, i)
if c == ',' or c == ';' then break end
if c:match('[a-zA-Z]') then
out[#out + 1] = c:lower()
if #out == 4 then break end
end
end
while #out < 4 do out[#out + 1] = '0' end
return table.concat(out)
end
-- ---- Authorization type ----------------------------------------------------
local function get_auth_type(req_hdrs)
local auth = req_hdrs['authorization']
if not auth or not auth[1] then return 'n' end
local v = auth[1]:lower()
if v:sub(1, 5) == 'basic' then return 'b' end
if v:sub(1, 6) == 'bearer' then return 't' end
if v:sub(1, 6) == 'digest' then return 'd' end
return 'o'
end
-- ---- Referer classifier ----------------------------------------------------
-- Strips port from a host string and lowercases it.
local function normalize_host(h)
return h:lower():gsub(':%d+$', '')
end
local function get_referer_flag(req_hdrs)
local ref = req_hdrs['referer']
if not ref or not ref[1] then return 'n' end
local host_hdr = req_hdrs['host']
if not host_hdr or not host_hdr[1] then return 'x' end
local srv_host = normalize_host(host_hdr[1])
local ref_host = ref[1]:lower():match('^https?://([^/?#]+)')
if not ref_host then return 'x' end
ref_host = normalize_host(ref_host)
return (ref_host == srv_host) and 's' or 'x'
end
-- ---- Header list builder ---------------------------------------------------
-- Returns a sorted string of first-char initials of all request header names.
local function get_header_list(req_hdrs)
local chars = {}
for name, _ in pairs(req_hdrs) do
if #name > 0 and #chars < MAX_PARAMS then
chars[#chars + 1] = name:sub(1, 1):lower()
end
end
if #chars == 0 then return 'nil' end
table.sort(chars)
return table.concat(chars)
end
-- ---- Cookie fields builder -------------------------------------------------
-- Returns a sorted string of first-char initials of cookie field names.
local function get_cookie_fields(req_hdrs)
local cookies = req_hdrs['cookie']
if not cookies then return 'nil' end
local chars = {}
for _, hdr in ipairs(cookies) do
for pair in (hdr .. ';'):gmatch('([^;]*);') do
local name = pair:match('^%s*([^=%s]+)')
if name and #name > 0 and #chars < MAX_PARAMS then
chars[#chars + 1] = name:sub(1, 1):lower()
end
end
end
if #chars == 0 then return 'nil' end
table.sort(chars)
return table.concat(chars)
end
-- ---- Header count ----------------------------------------------------------
-- Counts total request headers including duplicate names.
local function get_header_count(req_hdrs)
local count = 0
for _, vals in pairs(req_hdrs) do
count = count + #vals
end
return math.min(count, 99)
end
-- ---- Fingerprint builder ---------------------------------------------------
local function build_fingerprint(txn)
local req_hdrs = txn.http:req_get_headers()
local res_hdrs = txn.http:res_get_headers()
local path = txn.f:path() or '/'
local method = txn.f:method() or 'ge'
local query = txn.f:query() or ''
local ver = txn.f:req_ver() or '1.1'
local status = txn.f:status() or 0
local parts = {}
local function add(v) parts[#parts + 1] = tostring(v) end
-- 1. path_b62
add(base62_encode(path))
-- 2. method2
add(method:sub(1, 2):lower())
-- 3. http_ver
add(http_ver_code(ver))
-- 4. path_depth
add(string.format('%02d', get_path_depth(path)))
-- 5-7. param_keys / param_types / param_lens
local params = parse_params(query)
if #params == 0 then
add('nil'); add('nil'); add('0')
else
local keys, types, lens = {}, {}, {}
for _, p in ipairs(params) do
keys[#keys + 1] = p.name:sub(1, 1)
types[#types + 1] = detect_type(p.value)
lens[#lens + 1] = tostring(#p.value)
end
add(table.concat(keys))
add(table.concat(types))
add(table.concat(lens, '-'))
end
-- 8. req_ctype
add(get_req_ctype(req_hdrs))
-- 9. hdr_count
add(string.format('%02d', get_header_count(req_hdrs)))
-- 10. hdr_list
add(get_header_list(req_hdrs))
-- 11. accept_lang
add(get_accept_lang(req_hdrs))
-- 12. auth_type
add(get_auth_type(req_hdrs))
-- 13. cookie
local ck = req_hdrs['cookie']
add((ck and #ck > 0) and 'c' or 'n')
-- 14. cookie_fields
add(get_cookie_fields(req_hdrs))
-- 15. referer
add(get_referer_flag(req_hdrs))
-- 16. status
add(status)
-- 17. body_bytes (from response Content-Length header)
local cl = res_hdrs['content-length']
local bytes = (cl and cl[1]) and (tonumber(cl[1]) or 0) or 0
add(bytes > 0 and bytes or 0)
return table.concat(parts, '_')
end
-- ---- Action registration ---------------------------------------------------
core.register_action('req_fp', { 'http-res' }, function(txn)
local ok, result = pcall(build_fingerprint, txn)
txn:set_var('txn.req_fp', ok and result or 'err')
end)