Module: ZeroClick::Sellers::Middleware

Defined in:
lib/zeroclick/sellers/middleware.rb

Overview

Rack middleware. Rails is a Rack app, so this covers Rails, Sinatra, Hanami, Roda and anything else Rack — one implementation, every framework.

Deliberately named Middleware rather than Rack: a nested ::ZeroClick::Sellers::Rack would shadow the real ::Rack for every constant lookup inside this file.

Nothing here requires the rack gem. A Rack middleware is duck-typed — an object with #call(env) returning [status, headers, body] — so the SDK adds no runtime dependency, and these are testable with a plain Hash.

Defined Under Namespace

Classes: Base, Identify, Meter

Constant Summary collapse

CONTEXT_ENV_KEY =

What a guarded request proved about its caller, in the Rack env:

zc = env["zeroclick.context"]
zc.zc_agent_id    # the buyer
zc.zc_request_id  # correlates with ZeroClick's logs; use for idempotency
"zeroclick.context"
DEFAULT_MAX_BODY_BYTES =

Bounds the request body a guarded endpoint will read.

Verification covers the whole body, so the whole body must be held in memory to check the signature. Without a ceiling that is an unauthenticated memory DoS: the attacker does not need a valid signature to make us buffer.

10 * 1024 * 1024
PATH_SAFE =

Characters legal unencoded in a path, which must survive a re-encode.

%r{[^a-zA-Z0-9/~\-._!$&'()*+,;=:@%]}
RAW_TARGET_KEYS =

Servers that expose the raw request target set one of these. First match wins; each carries the full target, query string included.

Puma sets REQUEST_URI. Rails copies it to ORIGINAL_FULLPATH.

%w[REQUEST_URI ORIGINAL_FULLPATH].freeze

Class Method Summary collapse

Class Method Details

.path_and_query_from_env(env) ⇒ Object

Recover the raw, percent-encoded request target from a Rack env.

Prefers the server's raw target. Falls back to re-encoding PATH_INFO, which is exact for every character except an encoded separator: a server that decodes %2F to / before we are called leaves nothing downstream able to tell that apart from a literal /. Deploy behind Puma (which sets REQUEST_URI) if your routes can contain one.



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/zeroclick/sellers/middleware.rb', line 54

def path_and_query_from_env(env)
  RAW_TARGET_KEYS.each do |key|
    raw = env[key]
    next if raw.nil? || raw.empty?

    # Some servers give absolute-form ("http://host/path?q"). The
    # signature covers the path and query only.
    return raw.sub(%r{\Ahttps?://[^/]+}, "")
  end

  path = "#{env["SCRIPT_NAME"]}#{env["PATH_INFO"]}"
  encoded = path.gsub(PATH_SAFE) { |char| format("%%%02X", char.ord) }
  query = env["QUERY_STRING"]
  query.nil? || query.empty? ? encoded : "#{encoded}?#{query}"
end

.request_from_env(env, max_body_bytes: DEFAULT_MAX_BODY_BYTES) ⇒ Object

Build a Request from a Rack env, and leave rack.input readable by the app behind us.

It REPLACES rack.input rather than rewinding it. Rack 3 dropped the requirement that input be rewindable — a streaming server's input is consumed once and #rewind either raises or silently does nothing, which hands the application an empty body. Since verification must read the whole body anyway, handing back a fresh stream over those same bytes is both correct and free.

Returns [request, too_large].



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
# File 'lib/zeroclick/sellers/middleware.rb', line 81

def request_from_env(env, max_body_bytes: DEFAULT_MAX_BODY_BYTES)
  headers = {}
  env.each do |key, value|
    next unless key.is_a?(String) && key.start_with?("HTTP_")

    headers[key[5..].tr("_", "-").downcase] = value
  end
  headers["content-type"] = env["CONTENT_TYPE"] if env["CONTENT_TYPE"]
  headers["content-length"] = env["CONTENT_LENGTH"] if env["CONTENT_LENGTH"]

  input = env["rack.input"]
  body = +""
  if input
    # Read one byte past the ceiling so an over-size body is detected
    # rather than silently truncated into an invalid signature.
    body = input.read(max_body_bytes + 1) || +""
    body = body.dup.force_encoding(Encoding::BINARY)
    env["rack.input"] = StringIO.new(body)
  end

  [
    Request.new(
      method: env["REQUEST_METHOD"] || "GET",
      path_and_query: path_and_query_from_env(env),
      headers: headers,
      body: body
    ),
    body.bytesize > max_body_bytes
  ]
end