Class: SolidObjects::Web::CsrfProtection

Inherits:
Object
  • Object
show all
Defined in:
lib/solid_objects/web/csrf_protection.rb,
sig/generated/lib/solid_objects/web/csrf_protection.rbs

Overview

A state changing request must carry the token of the session that asked for the form. The token a form receives is masked with a fresh one-time pad on every request, so the bytes on the wire differ each time and a compression side channel cannot recover the session token.

Constant Summary collapse

SAFE_METHODS =

Returns:

  • (Object)
%w[GET HEAD OPTIONS TRACE].freeze
TOKEN_BYTES =

Returns:

  • (::Integer)
32
MISSING_SESSION =

Returns:

  • (::String)
<<~MESSAGE
  SolidObjects::Web needs a Rack session for CSRF protection.

  Mount it inside the application routes so the Rails session middleware runs first:

    Rails.application.routes.draw do
      mount SolidObjects::Web => "/solid_objects"
    end

  In a bare Rack application, run a session middleware before it:

    use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), same_site: true
    run SolidObjects::Web
MESSAGE

Instance Method Summary collapse

Constructor Details

#initialize(app) ⇒ CsrfProtection

Returns a new instance of CsrfProtection.

RBS:

  • (untyped) -> void

Parameters:

  • (Object)


33
34
35
# File 'lib/solid_objects/web/csrf_protection.rb', line 33

def initialize(app)
  @app = app
end

Instance Method Details

#accept?(env) ⇒ Boolean

RBS:

  • (Hash[String, untyped]) -> bool

Parameters:

  • (Hash[String, untyped])

Returns:

  • (Boolean)


50
51
52
53
54
# File 'lib/solid_objects/web/csrf_protection.rb', line 50

def accept?(env)
  return true if SAFE_METHODS.include?(env["REQUEST_METHOD"])

  valid?(env, ::Rack::Request.new(env).params["authenticity_token"])
end

#call(env) ⇒ Array[untyped]

RBS:

  • (Hash[String, untyped]) -> Array[untyped]

Parameters:

  • (Hash[String, untyped])

Returns:

  • (Array[untyped])


38
39
40
41
42
43
44
45
# File 'lib/solid_objects/web/csrf_protection.rb', line 38

def call(env)
  return forbidden unless accept?(env)

  session = session!(env)
  session[:csrf] ||= SecureRandom.base64(TOKEN_BYTES)
  env[Web::CSRF_TOKEN_KEY] = mask(session[:csrf])
  @app.call(env)
end

#decode(token) ⇒ String?

RBS:

  • (String) -> String?

Parameters:

  • (String)

Returns:

  • (String, nil)


112
113
114
115
116
117
# File 'lib/solid_objects/web/csrf_protection.rb', line 112

def decode(token)
  decoded = token.tr("-_", "+/").unpack1("m0")
  decoded.is_a?(String) ? decoded : nil
rescue ArgumentError
  nil
end

#encode(token) ⇒ String

RBS:

  • (String) -> String

Parameters:

  • (String)

Returns:

  • (String)


107
108
109
# File 'lib/solid_objects/web/csrf_protection.rb', line 107

def encode(token)
  [ token ].pack("m0").tr("+/", "-_")
end

#exclusive_or(left, right) ⇒ String

RBS:

  • (String, String) -> String

Parameters:

  • (String)
  • (String)

Returns:

  • (String)


102
103
104
# File 'lib/solid_objects/web/csrf_protection.rb', line 102

def exclusive_or(left, right)
  left.bytes.zip(right.bytes).map { |first, second| first ^ second.to_i }.pack("c*")
end

#forbiddenArray[untyped]

RBS:

  • () -> Array[untyped]

Returns:

  • (Array[untyped])


125
126
127
# File 'lib/solid_objects/web/csrf_protection.rb', line 125

def forbidden
  [ 403, { "content-type" => "text/plain" }, [ "Forbidden" ] ]
end

#mask(token) ⇒ String

RBS:

  • (String) -> String

Parameters:

  • (String)

Returns:

  • (String)


89
90
91
92
93
# File 'lib/solid_objects/web/csrf_protection.rb', line 89

def mask(token)
  decoded = decode(token).to_s
  pad = SecureRandom.random_bytes(decoded.bytesize)
  encode(pad + exclusive_or(pad, decoded))
end

#matches?(token, stored) ⇒ Boolean

RBS:

  • (String, String) -> bool

Parameters:

  • (String)
  • (String)

Returns:

  • (Boolean)


78
79
80
81
82
83
84
85
86
# File 'lib/solid_objects/web/csrf_protection.rb', line 78

def matches?(token, stored)
  candidate = case token.bytesize
  when TOKEN_BYTES then token
  when TOKEN_BYTES * 2 then unmask(token)
  else return false
  end

  ::Rack::Utils.secure_compare(candidate, decode(stored).to_s)
end

#session!(env) ⇒ Hash[untyped, untyped]

RBS:

  • (Hash[String, untyped]) -> Hash[untyped, untyped]

Parameters:

  • (Hash[String, untyped])

Returns:

  • (Hash[untyped, untyped])


120
121
122
# File 'lib/solid_objects/web/csrf_protection.rb', line 120

def session!(env)
  env["rack.session"] || raise(MISSING_SESSION)
end

#unmask(masked) ⇒ String

RBS:

  • (String) -> String

Parameters:

  • (String)

Returns:

  • (String)


96
97
98
99
# File 'lib/solid_objects/web/csrf_protection.rb', line 96

def unmask(masked)
  half = masked.bytesize / 2
  exclusive_or(masked[0, half].to_s, masked[half..].to_s)
end

#valid?(env, given) ⇒ Boolean

RBS:

  • (Hash[String, untyped], String?) -> bool

Parameters:

  • (Hash[String, untyped])
  • (String, nil)

Returns:

  • (Boolean)


57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/solid_objects/web/csrf_protection.rb', line 57

def valid?(env, given)
  return false if given.nil? || given.empty?

  session = session!(env)
  stored = session[:csrf]
  return false if stored.nil?

  token = decode(given)
  return false unless token

  # The secret is not rotated here. A page renders one Retry form per
  # dead letter, and a browser keeps pages open in other tabs, so
  # spending the secret on the first submission would answer 403 to
  # every other form already rendered. Single use is not what a CSRF
  # token provides: it proves the request came from a page this session
  # was served, and the per-request mask below is what keeps the value
  # on the wire from repeating.
  matches?(token, stored)
end