Class: Tina4::Request

Inherits:
Object
  • Object
show all
Defined in:
lib/tina4/request.rb

Defined Under Namespace

Classes: PayloadTooLarge

Constant Summary collapse

TINA4_MAX_UPLOAD_SIZE =

Maximum upload size in bytes (default 10 MB). Override via TINA4_MAX_UPLOAD_SIZE env var.

Integer(ENV.fetch("TINA4_MAX_UPLOAD_SIZE", 10_485_760))

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(env, path_params = {}) ⇒ Request

Returns a new instance of Request.



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
# File 'lib/tina4/request.rb', line 200

def initialize(env, path_params = {})
  @env = env
  @method = env["REQUEST_METHOD"]
  @path = env["PATH_INFO"] || "/"
  @query_string = env["QUERY_STRING"] || ""
  @content_type = env["CONTENT_TYPE"] || ""
  @path_params = path_params

  # Check upload size limit (DECLARED Content-Length). The running per-chunk
  # counter in read_stream_capped catches the chunked / under-declared case
  # this check cannot see.
  content_length = (env["CONTENT_LENGTH"] || 0).to_i
  upload_limit = Tina4::Request.max_upload_size
  if content_length > upload_limit
    raise PayloadTooLarge,
      "Request body (#{content_length} bytes) exceeds TINA4_MAX_UPLOAD_SIZE (#{upload_limit} bytes)"
  end

  # Raw socket peer — NEVER honours X-Forwarded-For, so it can be trusted
  # for security decisions. Resolved BEFORE @ip: the peer decides whether
  # the forwarding headers may be believed at all.
  @remote_ip = (env["REMOTE_ADDR"] || "").to_s
  @ip = extract_client_ip

  # Lazy-initialized fields (nil = not yet computed)
  @headers = nil
  @cookies = nil
  @session = nil
  @body_raw = nil
  @params = nil
  @files = nil
  @json_body = nil
  @query_hash = nil
  @body_parsed = nil
  # #body's own memoised RESULT can legitimately be nil (the no-body
  # sentinel, REQ-BODY-DIVERGE 3.13.99), so "nil = not yet computed" (the
  # convention above) cannot also mean "computed, and nil" for this one
  # field — a dedicated flag distinguishes them.
  @body_parsed_computed = false
end

Instance Attribute Details

#content_typeObject (readonly)

Returns the value of attribute content_type.



118
119
120
# File 'lib/tina4/request.rb', line 118

def content_type
  @content_type
end

#envObject (readonly)

Returns the value of attribute env.



118
119
120
# File 'lib/tina4/request.rb', line 118

def env
  @env
end

#ipObject (readonly)

Returns the value of attribute ip.



118
119
120
# File 'lib/tina4/request.rb', line 118

def ip
  @ip
end

#methodObject (readonly)

Returns the value of attribute method.



118
119
120
# File 'lib/tina4/request.rb', line 118

def method
  @method
end

#pathObject (readonly)

Returns the value of attribute path.



118
119
120
# File 'lib/tina4/request.rb', line 118

def path
  @path
end

#query_stringObject (readonly)

Returns the value of attribute query_string.



118
119
120
# File 'lib/tina4/request.rb', line 118

def query_string
  @query_string
end

#remote_ipObject (readonly)

Returns the value of attribute remote_ip.



118
119
120
# File 'lib/tina4/request.rb', line 118

def remote_ip
  @remote_ip
end

#routeObject

:route is the matched Route, attached by the dispatcher before post-match middleware runs (DispatchPipeline#prepare_route_request). CsrfMiddleware reads route.auth_required to honour a public write route (.no_auth).



123
124
125
# File 'lib/tina4/request.rb', line 123

def route
  @route
end

#userObject

:route is the matched Route, attached by the dispatcher before post-match middleware runs (DispatchPipeline#prepare_route_request). CsrfMiddleware reads route.auth_required to honour a public write route (.no_auth).



123
124
125
# File 'lib/tina4/request.rb', line 123

def user
  @user
end

Class Method Details

.max_upload_sizeObject

Effective upload cap in bytes. Read at CALL TIME (not frozen into the constant at load) so the limit honours a TINA4_MAX_UPLOAD_SIZE set after this file was required, and so a test can lower it. Falls back to the constant default when the env var is unset/blank.



134
135
136
137
# File 'lib/tina4/request.rb', line 134

def self.max_upload_size
  value = ENV["TINA4_MAX_UPLOAD_SIZE"]
  value.nil? || value.empty? ? TINA4_MAX_UPLOAD_SIZE : value.to_i
end

.read_stream_capped(input, limit = nil) ⇒ Object

Read an IO in bounded chunks, raising PayloadTooLarge the MOMENT the running total exceeds the upload cap, so an over-limit body is refused as it arrives instead of after the whole thing is buffered. Rewinds the input before and after so a later reader (Rack's parser, form-token extraction) sees the same stream. Parity with the Python/Node per-chunk body readers.



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/tina4/request.rb', line 144

def self.read_stream_capped(input, limit = nil)
  return "" unless input

  limit = max_upload_size if limit.nil?
  input.rewind if input.respond_to?(:rewind)
  buffer = +""
  if limit&.positive?
    while (chunk = input.read(65_536))
      buffer << chunk
      if buffer.bytesize > limit
        raise PayloadTooLarge,
              "Request body (#{buffer.bytesize}+ bytes) exceeds TINA4_MAX_UPLOAD_SIZE (#{limit} bytes)"
      end
    end
  else
    buffer = input.read || ""
  end
  input.rewind if input.respond_to?(:rewind)
  buffer
end

.save_upload(file, target_dir, filename: nil) ⇒ Object

Persist an uploaded file's content inside target_dir under a SAFE name.

The client-supplied filename is untrusted. Directory components are stripped (so "../../evil" or "/etc/passwd" becomes "evil"/"passwd"), a NUL byte or an unusable name ("", ".", "..") is refused, and the resolved path is confined to target_dir (realpath containment) so an upload can never write outside it. Returns the absolute path written; raises ArgumentError on an unsafe name.

Raises:

  • (ArgumentError)


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
# File 'lib/tina4/request.rb', line 173

def self.save_upload(file, target_dir, filename: nil)
  raw = (filename || file["filename"] || file[:filename] || "").to_s
  raise ArgumentError, "upload filename contains a null byte" if raw.include?("\u0000")

  # Reduce to a single path segment, handling BOTH separators so a Windows
  # "..\\..\\evil" cannot smuggle a directory part past a POSIX basename.
  base = raw.tr("\\", "/").split("/").last.to_s
  if base.empty? || base == "." || base == ".."
    raise ArgumentError, "upload filename is not a usable name: #{raw.inspect}"
  end

  require "fileutils"
  FileUtils.mkdir_p(target_dir)
  dest = File.join(target_dir, base)
  # Defence in depth: the resolved parent of the destination must be exactly
  # the resolved target dir (guards a pre-existing symlink at target/base).
  real_dir = File.realpath(target_dir)
  real_parent = File.realpath(File.dirname(dest))
  unless real_parent == real_dir
    raise ArgumentError, "refusing to write outside #{target_dir.inspect}: #{raw.inspect}"
  end

  content = file["content"] || file[:content] || ""
  File.binwrite(dest, content)
  dest
end

.secure_scheme?(env) ⇒ Boolean

Is this request HTTPS from the CLIENT's point of view?

TLS is normally terminated at a proxy (nginx, HAProxy, ALB, Cloudflare, most container deploys) that then forwards plain HTTP to the app, so rack.url_scheme is "http" on exactly the deployments that ARE encrypted. x-forwarded-proto carries the scheme the client actually used, and a chain of proxies appends each hop ("https, http") — the FIRST is the client-facing one. Falls back to the native rack scheme, then the CGI HTTPS var.

This is the single source of truth for BOTH the URL scheme (#url) and the session-cookie Secure flag (Session#cookie_header). They used to decide it independently and could disagree — url() said https while the cookie concluded plain HTTP and dropped Secure (ruby#31, parity with PHP Request::isSecureScheme / tina4-php#175).

Parameters:

  • env (Hash)

    the Rack environment.

Returns:

  • (Boolean)

    true when the client's scheme is https.



259
260
261
262
263
264
265
266
267
268
# File 'lib/tina4/request.rb', line 259

def self.secure_scheme?(env)
  forwarded = (env["HTTP_X_FORWARDED_PROTO"] || "").to_s
  unless forwarded.strip.empty?
    return forwarded.split(",").first.to_s.strip.casecmp("https").zero?
  end
  scheme = (env["rack.url_scheme"] || "").to_s
  return true if scheme.casecmp("https").zero?
  https = (env["HTTPS"] || "").to_s
  !https.empty? && https.casecmp("off") != 0
end

Instance Method Details

#[](key) ⇒ Object



382
383
384
# File 'lib/tina4/request.rb', line 382

def [](key)
  param(key)
end

#bearer_tokenObject



404
405
406
407
# File 'lib/tina4/request.rb', line 404

def bearer_token
  auth = header("authorization") || ""
  auth.sub(/\ABearer\s+/i, "") if auth =~ /\ABearer\s+/i
end

#bodyObject Also known as: body_parsed

Parsed body (JSON -> Hash, form-urlencoded -> Hash, multipart -> fields Hash, else the current fallback). This matches Python's request.body, PHP's, and Node's: body is the PARSED payload, not the raw bytes. For the raw string use body_raw.

No-body is now a real nil result (REQ-BODY-DIVERGE, 3.13.99), so this can no longer memoise with ||= (nil/false never "stick" — every call would re-run parse_body). @body_parsed_computed distinguishes "never computed" from "computed and the answer was nil".



316
317
318
319
320
# File 'lib/tina4/request.rb', line 316

def body
  return @body_parsed if @body_parsed_computed
  @body_parsed_computed = true
  @body_parsed = parse_body
end

#body_rawObject

Raw body string — the bytes exactly as the client sent them. (This is what body used to return before the cross-framework parity flip; SOAP/GraphQL and any consumer that needs the raw text reads this.)



326
327
328
# File 'lib/tina4/request.rb', line 326

def body_raw
  @body_raw ||= read_body
end

#cookiesObject



291
292
293
# File 'lib/tina4/request.rb', line 291

def cookies
  @cookies ||= parse_cookies
end

#filesObject



338
339
340
# File 'lib/tina4/request.rb', line 338

def files
  @files ||= extract_files
end

#header(name) ⇒ Object



386
387
388
389
390
391
392
393
394
# File 'lib/tina4/request.rb', line 386

def header(name)
  # Headers are stored in a CaseInsensitiveHash keyed by lowercase-
  # dashed names ("content-type", "x-api-key"). The hash normalises the
  # lookup CASE automatically; this only translates the DASH convention.
  # No underscore->dash remap any more (REQ-HEADER-DASH-DIVERGE,
  # 3.13.99): case-fold only, matching the PHP/Node reference — a caller
  # passing "content_type" no longer matches "Content-Type".
  headers[name.to_s]
end

#headersObject

Lazy accessors



287
288
289
# File 'lib/tina4/request.rb', line 287

def headers
  @headers ||= extract_headers
end

#json_bodyObject



396
397
398
399
400
401
402
# File 'lib/tina4/request.rb', line 396

def json_body
  @json_body ||= begin
    JSON.parse(body_raw)
  rescue JSON::ParserError, TypeError
    {}
  end
end

#param(key, default = nil) ⇒ Object

Look up a value by key: the matched ROUTE param first, then the query string. A read convenience only — params and query stay separate collections (REQ-PARAM-POLLUTION); a route value always wins over a client-supplied query value of the same name. Mirrors PHP's/Node's param(). Accepts a symbol or string key (indifferent, like params).



376
377
378
379
380
# File 'lib/tina4/request.rb', line 376

def param(key, default = nil)
  value = params[key]
  return value unless value.nil?
  query[key.to_s] || default
end

#paramsObject



363
364
365
366
367
368
369
# File 'lib/tina4/request.rb', line 363

def params
  @params ||= begin
    result = IndifferentHash.new
    @path_params.each { |k, v| result[k] = v }
    result
  end
end

#params=(value) ⇒ Object

Route params ONLY — never query or body (REQ-PARAM-POLLUTION, 3.13.99, a param-pollution/security fix). A route /{id} hit with ?id=other yields params["id"] == the route value; the client value is only ever in query. Supports both string and symbol key access (indifferent access — matches Route#match_path, which captures path-param names as SYMBOLS, so params[:id] and params["id"] both resolve). Renamed from path_params to unify the route-param accessor NAME with Python/PHP/Node (REQ-ROUTE-PARAM-NAME); the old MERGED params (query + body + path_params, via #build_params) is deleted outright — no back-compat alias (nothing in the ledger asked for one).

The request is built BEFORE route matching, so pre-match middleware has something to read and mutate. Path params are only known once a route has matched, so they are set here and the memoised #params is dropped - without that reset a pre-match middleware that touched #params would freeze a param-less copy for the handler.



358
359
360
361
# File 'lib/tina4/request.rb', line 358

def params=(value)
  @path_params = value || {}
  @params = nil
end

#queryObject

Parsed query string as hash



334
335
336
# File 'lib/tina4/request.rb', line 334

def query
  @query_hash ||= parse_query_to_hash(@query_string)
end

#sessionObject

The session for THIS request.

degrade_on_backend_failure: this is the live request path, so a storage handler that cannot be built (an unreachable database, a refused backend name) is LOGGED and then degraded to an in-memory-only session rather than unwinding into RackApp's 500 handler and taking the request down with it (ADR-0021). TINA4_SESSION_STRICT still re-raises. Direct Session.new callers keep the loud raise - see the guard in Session#initialize.



303
304
305
# File 'lib/tina4/request.rb', line 303

def session
  @session ||= Tina4::Session.new(@env, degrade_on_backend_failure: true)
end

#urlObject

Full absolute URL — scheme://host/path. Honours X-Forwarded-Proto / X-Forwarded-Host so apps behind a proxy still see the URL the client used. Matches Python/PHP/Node parity.



273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/tina4/request.rb', line 273

def url
  scheme = self.class.secure_scheme?(env) ? "https" : "http"
  host = env["HTTP_X_FORWARDED_HOST"] || env["HTTP_HOST"] || env["SERVER_NAME"] || "localhost"
  port = env["SERVER_PORT"]
  url_str = "#{scheme}://#{host}"
  # Only append :port when the host doesn't already include one
  # (HTTP_HOST often does) and it's not the default for the scheme.
  url_str += ":#{port}" if port && !host.include?(":") && port.to_s != "80" && port.to_s != "443"
  url_str += @path
  url_str += "?#{@query_string}" unless @query_string.empty?
  url_str
end