diff --git a/docs/utilities.md b/docs/utilities.md index 0a200fdf..69181641 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -476,6 +476,13 @@ encoding = require "lapis.util.encoding" ]] } +Inside OpenResty, `encode_base64`, `decode_base64` and `hmac_sha1` use the +implementations built into `ngx`. Any other HMAC is calculated with +[luaossl](https://github.com/wahern/luaossl), or with +[lua-resty-openssl](https://github.com/fffonion/lua-resty-openssl) when luaossl +can't be loaded. Without either library the module still loads, but +calculating that HMAC raises an error. + ### `encode_base64(str)` Base64 encodes a string. @@ -614,7 +621,9 @@ to have it encoded into the token. You can then use the `callback` parameter of `validate_token` to verify data's value. The random string is stored in a cookie named as your session name with -`_token` appended to the end. +`_token` appended to the end. Its random bytes come from luaossl. Inside +OpenResty, the `resty.random` module that ships with OpenResty is used when +luaossl can't be loaded. ### `csrf.validate_token(req, callback=nil)` diff --git a/lapis/csrf.lua b/lapis/csrf.lua index 74f504fd..c22f2bad 100644 --- a/lapis/csrf.lua +++ b/lapis/csrf.lua @@ -3,14 +3,34 @@ do local _obj_0 = require("lapis.util.encoding") encode_base64, encode_with_secret, decode_with_secret = _obj_0.encode_base64, _obj_0.encode_with_secret, _obj_0.decode_with_secret end -local openssl_rand = require("openssl.rand") local config = require("lapis.config").get() local cookie_name = tostring(config.session_name) .. "_token" +local random_bytes +do + local has_luaossl, openssl_rand = pcall(require, "openssl.rand") + if has_luaossl then + random_bytes = openssl_rand.bytes + elseif ngx and ngx.config and pcall(function() + return require("resty.random") + end) then + local resty_random = require("resty.random") + random_bytes = function(n) + local bytes = resty_random.bytes(n, true) + assert(bytes, "lapis.csrf: resty.random failed to generate random bytes") + return bytes + end + else + local luaossl_err = openssl_rand + random_bytes = function() + return error("lapis.csrf: generating a token requires luaossl (or resty.random in OpenResty), but luaossl failed to load: " .. tostring(luaossl_err)) + end + end +end local generate_token generate_token = function(req, data) local key = req.cookies[cookie_name] if not (key) then - key = encode_base64(openssl_rand.bytes(32)) + key = encode_base64(random_bytes(32)) req.cookies[cookie_name] = key end local token = { diff --git a/lapis/csrf.moon b/lapis/csrf.moon index 64323c82..102918f4 100644 --- a/lapis/csrf.moon +++ b/lapis/csrf.moon @@ -1,16 +1,35 @@ -- csrf protection import encode_base64, encode_with_secret, decode_with_secret from require "lapis.util.encoding" -openssl_rand = require "openssl.rand" config = require"lapis.config".get! cookie_name = "#{config.session_name}_token" +-- luaossl is optional so it doesn't have to be installed for apps running in +-- OpenResty, which bundles resty.random. resty.random calls into the OpenSSL +-- that nginx is linked with, so it's only used inside of a real nginx process. +-- (The fake ngx of a simulated request has no ngx.config) +random_bytes = do + has_luaossl, openssl_rand = pcall require, "openssl.rand" + + if has_luaossl + openssl_rand.bytes + elseif ngx and ngx.config and pcall -> require "resty.random" + resty_random = require "resty.random" + (n) -> + bytes = resty_random.bytes n, true + assert bytes, "lapis.csrf: resty.random failed to generate random bytes" + bytes + else + luaossl_err = openssl_rand + -> + error "lapis.csrf: generating a token requires luaossl (or resty.random in OpenResty), but luaossl failed to load: #{luaossl_err}" + generate_token = (req, data) -> key = req.cookies[cookie_name] unless key - key = encode_base64 openssl_rand.bytes(32) + key = encode_base64 random_bytes 32 req.cookies[cookie_name] = key token = { diff --git a/lapis/util/encoding.lua b/lapis/util/encoding.lua index e34bd206..a09e978a 100644 --- a/lapis/util/encoding.lua +++ b/lapis/util/encoding.lua @@ -1,6 +1,28 @@ local encode_base64, decode_base64, hmac_sha1 local config = require("lapis.config").get() -local openssl_hmac = require("openssl.hmac") +local new_hmac +do + local has_luaossl, openssl_hmac = pcall(require, "openssl.hmac") + if has_luaossl then + new_hmac = openssl_hmac.new + elseif pcall(function() + return require("resty.openssl.hmac") + end) then + new_hmac = require("resty.openssl.hmac").new + else + local luaossl_err = openssl_hmac + new_hmac = function(secret, digest_type) + return error("lapis.util.encoding: hmac_" .. tostring(digest_type) .. " requires luaossl or lua-resty-openssl, but luaossl failed to load: " .. tostring(luaossl_err)) + end + end +end +local hmac_for +hmac_for = function(digest_type) + return function(secret, str) + local hmac = assert(new_hmac(secret, digest_type)) + return assert(hmac:final(str)) + end +end if ngx then do local _obj_0 = ngx @@ -16,16 +38,9 @@ else decode_base64 = function(...) return (unb64(...)) end - hmac_sha1 = function(secret, str) - local hmac = openssl_hmac.new(secret, "sha1") - return hmac:final(str) - end -end -local hmac_sha256 -hmac_sha256 = function(secret, str) - local hmac = openssl_hmac.new(secret, "sha256") - return hmac:final(str) + hmac_sha1 = hmac_for("sha1") end +local hmac_sha256 = hmac_for("sha256") local default_hmac local _exp_0 = config.hmac_digest if "sha256" == _exp_0 then diff --git a/lapis/util/encoding.moon b/lapis/util/encoding.moon index 4b195027..6782d167 100644 --- a/lapis/util/encoding.moon +++ b/lapis/util/encoding.moon @@ -2,7 +2,27 @@ local encode_base64, decode_base64, hmac_sha1 config = require"lapis.config".get! -openssl_hmac = require "openssl.hmac" + +-- luaossl is optional so it doesn't have to be installed for apps running in +-- OpenResty, where lua-resty-openssl can be used instead. Both libraries +-- provide new(key, digest_type) to create an hmac. With neither installed, +-- calculating an hmac raises an error that includes why luaossl failed to load +new_hmac = do + has_luaossl, openssl_hmac = pcall require, "openssl.hmac" + + if has_luaossl + openssl_hmac.new + elseif pcall -> require "resty.openssl.hmac" + require("resty.openssl.hmac").new + else + luaossl_err = openssl_hmac + (secret, digest_type) -> + error "lapis.util.encoding: hmac_#{digest_type} requires luaossl or lua-resty-openssl, but luaossl failed to load: #{luaossl_err}" + +hmac_for = (digest_type) -> + (secret, str) -> + hmac = assert new_hmac secret, digest_type + assert hmac\final str if ngx {:encode_base64, :decode_base64, :hmac_sha1} = ngx @@ -12,17 +32,13 @@ else encode_base64 = (...) -> (b64 ...) decode_base64 = (...) -> (unb64 ...) - hmac_sha1 = (secret, str) -> - hmac = openssl_hmac.new secret, "sha1" - hmac\final str + hmac_sha1 = hmac_for "sha1" ---Generate HMAC-SHA256 hash ---@param secret string Secret key for HMAC ---@param str string String to hash ---@return string hash Binary HMAC-SHA256 digest -hmac_sha256 = (secret, str) -> - hmac = openssl_hmac.new secret, "sha256" - hmac\final str +hmac_sha256 = hmac_for "sha256" default_hmac = switch config.hmac_digest when "sha256" diff --git a/spec/csrf_spec.moon b/spec/csrf_spec.moon index 2a0e6996..e970ecac 100644 --- a/spec/csrf_spec.moon +++ b/spec/csrf_spec.moon @@ -1,5 +1,7 @@ csrf = require "lapis.csrf" -import encode_with_secret, decode_with_secret from require "lapis.util.encoding" +import encode_base64, encode_with_secret, decode_with_secret from require "lapis.util.encoding" +ngx_stack = require "lapis.spec.stack" +import require_with_stubs from require "spec.helpers" describe "lapis.csrf", -> config = require"lapis.config".get! @@ -192,3 +194,90 @@ describe "lapis.csrf", -> csrf_token: token } }, (d) -> d.number == 5 + + describe "random bytes backend", -> + -- stands in for luaossl's openssl.rand and OpenResty's resty.random + fake_rand = (char, log) -> { + bytes: (n, strong) -> + table.insert log, { :n, :strong } if log + string.rep char, n + } + + generate_key = (c) -> + r = { cookies: {} } + assert c.generate_token r + r.cookies.lapis_session_token + + it "uses luaossl when it's installed", -> + ngx_stack.push { config: {} } + finally -> ngx_stack.pop! + + c = require_with_stubs "lapis.csrf", { + "openssl.rand": fake_rand "a" + "resty.random": fake_rand "b" + } + + assert.same encode_base64(string.rep "a", 32), generate_key c + + it "falls back to resty.random with nginx", -> + ngx_stack.push { config: {} } + finally -> ngx_stack.pop! + + calls = {} + c = require_with_stubs "lapis.csrf", { + "openssl.rand": false + "resty.random": fake_rand "b", calls + } + + assert.same encode_base64(string.rep "b", 32), generate_key c + -- asks for cryptographically strong bytes + assert.same {{ n: 32, strong: true }}, calls + + it "fails when resty.random can't generate bytes", -> + ngx_stack.push { config: {} } + finally -> ngx_stack.pop! + + c = require_with_stubs "lapis.csrf", { + "openssl.rand": false + "resty.random": { bytes: -> nil } + } + + ok, err = pcall generate_key, c + assert.same false, ok + assert.truthy err\find "resty.random failed to generate random bytes", 1, true + + describe "without nginx", -> + assert_no_backend = -> + c = require_with_stubs "lapis.csrf", { + "openssl.rand": false + "resty.random": fake_rand "b" + } + + ok, err = pcall generate_key, c + assert.same false, ok + assert.truthy err\find "generating a token requires luaossl (or resty.random in OpenResty)", 1, true + -- includes the reason luaossl couldn't be loaded + assert.truthy err\find "luaossl failed to load: module 'openssl.rand' not found (stubbed by spec)", 1, true + + it "fails to generate a token without luaossl", -> + assert_no_backend! + + it "doesn't use resty.random in a simulated request", -> + -- the fake ngx of a simulated request has no ngx.config + ngx_stack.push { _lapis_simulate: true } + finally -> ngx_stack.pop! + assert_no_backend! + + it "validates tokens without a backend", -> + c = require_with_stubs "lapis.csrf", { + "openssl.rand": false + "resty.random": false + } + + r = { cookies: {} } + token = csrf.generate_token r + + assert c.validate_token { + cookies: r.cookies + params: { csrf_token: token } + } diff --git a/spec/encoding_spec.moon b/spec/encoding_spec.moon index f473a546..730f1118 100644 --- a/spec/encoding_spec.moon +++ b/spec/encoding_spec.moon @@ -1,4 +1,16 @@ encoding = require "lapis.util.encoding" +ngx_stack = require "lapis.spec.stack" +import require_with_stubs from require "spec.helpers" + +to_hex = (str) -> (str\gsub ".", (c) -> string.format "%02x", c\byte!) + +-- stands in for luaossl's openssl.hmac and lua-resty-openssl's +-- resty.openssl.hmac, which share the same interface +fake_hmac = (name) -> { + new: (secret, digest_type) -> { + final: (str) => "#{name}:#{digest_type}:#{secret}:#{str}" + } +} describe "lapis.util.encoding", -> config = require"lapis.config".get! @@ -29,4 +41,84 @@ describe "lapis.util.encoding", -> assert.same {encoding.decode_with_secret "hello.world"}, {nil, "invalid signature"} + -- RFC 2202 and RFC 4231 test case 2 + it "calculates hmac_sha1", -> + assert.same "effcdf6ae5eb2fa2d27416d5f184df9c259a7c79", + to_hex encoding.hmac_sha1 "Jefe", "what do ya want for nothing?" + + it "calculates hmac_sha256", -> + assert.same "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843", + to_hex encoding.hmac_sha256 "Jefe", "what do ya want for nothing?" + + describe "hmac backend", -> + it "uses luaossl when it's installed", -> + e = require_with_stubs "lapis.util.encoding", { + "openssl.hmac": fake_hmac "luaossl" + "resty.openssl.hmac": fake_hmac "resty" + } + + assert.same "luaossl:sha1:key:msg", e.hmac_sha1 "key", "msg" + assert.same "luaossl:sha256:key:msg", e.hmac_sha256 "key", "msg" + + it "falls back to lua-resty-openssl", -> + e = require_with_stubs "lapis.util.encoding", { + "openssl.hmac": false + "resty.openssl.hmac": fake_hmac "resty" + } + + assert.same "resty:sha1:key:msg", e.hmac_sha1 "key", "msg" + assert.same "resty:sha256:key:msg", e.hmac_sha256 "key", "msg" + + it "loads without a backend and fails when an hmac is calculated", -> + e = require_with_stubs "lapis.util.encoding", { + "openssl.hmac": false + "resty.openssl.hmac": false + } + + for fn_name in *{"hmac_sha1", "hmac_sha256"} + ok, err = pcall e[fn_name], "key", "msg" + assert.same false, ok + assert.truthy err\find "#{fn_name} requires luaossl or lua-resty-openssl", 1, true + -- includes the reason luaossl couldn't be loaded + assert.truthy err\find "luaossl failed to load: module 'openssl.hmac' not found (stubbed by spec)", 1, true + + assert.has_error -> e.encode_with_secret { color: "red" } + + describe "with nginx", -> + before_each -> + mime = require "mime" + ngx_stack.push { + config: {} + encode_base64: (str) -> (mime.b64 str) + decode_base64: (str) -> (mime.unb64 str) + hmac_sha1: (secret, str) -> "ngx:sha1:#{secret}:#{str}" + } + + after_each -> + ngx_stack.pop! + + it "signs with ngx.hmac_sha1 without a backend", -> + e = require_with_stubs "lapis.util.encoding", { + "openssl.hmac": false + "resty.openssl.hmac": false + } + + assert.same "ngx:sha1:key:msg", e.hmac_sha1 "key", "msg" + assert.same { color: "red" }, e.decode_with_secret e.encode_with_secret { color: "red" } + assert.has_error -> e.hmac_sha256 "key", "msg" + + it "signs with lua-resty-openssl when hmac_digest is sha256", -> + prev_digest = config.hmac_digest + config.hmac_digest = "sha256" + finally -> config.hmac_digest = prev_digest + + e = require_with_stubs "lapis.util.encoding", { + "openssl.hmac": false + "resty.openssl.hmac": fake_hmac "resty" + } + + assert.same "ngx:sha1:key:msg", e.hmac_sha1 "key", "msg" + assert.same "resty:sha256:key:msg", e.hmac_sha256 "key", "msg" + msg, signature = e.encode_with_secret({ color: "red" })\match "^(.*)%.(.*)$" + assert.same "resty:sha256:the-secret:#{msg}", e.decode_base64 signature diff --git a/spec/helpers.moon b/spec/helpers.moon index a2df2651..89500054 100644 --- a/spec/helpers.moon +++ b/spec/helpers.moon @@ -140,4 +140,33 @@ sorted_pairs = (sort=table.sort) -> after_each -> _G.pairs = _pairs -{ :with_query_fn, :assert_queries, :stub_queries, :sorted_pairs } +-- Loads a fresh copy of a module while the modules in stubs are replaced: a +-- table is returned in their place, and false makes requiring them fail as if +-- they weren't installed. Everything is put back before returning, so only the +-- returned copy of the module sees the stubs +require_with_stubs = (name, stubs) -> + prev_loaded = {} + prev_preload = {} + + for mod_name, stub in pairs stubs + prev_loaded[mod_name] = package.loaded[mod_name] + prev_preload[mod_name] = package.preload[mod_name] + package.loaded[mod_name] = nil + package.preload[mod_name] = if stub + -> stub + else + -> error "module '#{mod_name}' not found (stubbed by spec)", 0 + + prev_module = package.loaded[name] + package.loaded[name] = nil + ok, mod = pcall require, name + package.loaded[name] = prev_module + + for mod_name in pairs stubs + package.loaded[mod_name] = prev_loaded[mod_name] + package.preload[mod_name] = prev_preload[mod_name] + + assert ok, mod + mod + +{ :with_query_fn, :assert_queries, :stub_queries, :sorted_pairs, :require_with_stubs }