Class: Tina4::Request
- Inherits:
-
Object
- Object
- Tina4::Request
- 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
-
#content_type ⇒ Object
readonly
Returns the value of attribute content_type.
-
#env ⇒ Object
readonly
Returns the value of attribute env.
-
#ip ⇒ Object
readonly
Returns the value of attribute ip.
-
#method ⇒ Object
readonly
Returns the value of attribute method.
-
#path ⇒ Object
readonly
Returns the value of attribute path.
-
#query_string ⇒ Object
readonly
Returns the value of attribute query_string.
-
#remote_ip ⇒ Object
readonly
Returns the value of attribute remote_ip.
-
#route ⇒ Object
:route is the matched Route, attached by the dispatcher before post-match middleware runs (DispatchPipeline#prepare_route_request).
-
#user ⇒ Object
:route is the matched Route, attached by the dispatcher before post-match middleware runs (DispatchPipeline#prepare_route_request).
Class Method Summary collapse
-
.max_upload_size ⇒ Object
Effective upload cap in bytes.
-
.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.
-
.save_upload(file, target_dir, filename: nil) ⇒ Object
Persist an uploaded file's content inside target_dir under a SAFE name.
-
.secure_scheme?(env) ⇒ Boolean
Is this request HTTPS from the CLIENT's point of view?.
Instance Method Summary collapse
- #[](key) ⇒ Object
- #bearer_token ⇒ Object
-
#body ⇒ Object
(also: #body_parsed)
Parsed body (JSON -> Hash, form-urlencoded -> Hash, multipart -> fields Hash, else the current fallback).
-
#body_raw ⇒ Object
Raw body string — the bytes exactly as the client sent them.
- #cookies ⇒ Object
- #files ⇒ Object
- #header(name) ⇒ Object
-
#headers ⇒ Object
Lazy accessors.
-
#initialize(env, path_params = {}) ⇒ Request
constructor
A new instance of Request.
- #json_body ⇒ Object
-
#param(key, default = nil) ⇒ Object
Look up a value by key: the matched ROUTE param first, then the query string.
- #params ⇒ Object
-
#params=(value) ⇒ Object
Route params ONLY — never query or body (REQ-PARAM-POLLUTION, 3.13.99, a param-pollution/security fix).
-
#query ⇒ Object
Parsed query string as hash.
-
#session ⇒ Object
The session for THIS request.
- #url ⇒ Object
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_type ⇒ Object (readonly)
Returns the value of attribute content_type.
118 119 120 |
# File 'lib/tina4/request.rb', line 118 def content_type @content_type end |
#env ⇒ Object (readonly)
Returns the value of attribute env.
118 119 120 |
# File 'lib/tina4/request.rb', line 118 def env @env end |
#ip ⇒ Object (readonly)
Returns the value of attribute ip.
118 119 120 |
# File 'lib/tina4/request.rb', line 118 def ip @ip end |
#method ⇒ Object (readonly)
Returns the value of attribute method.
118 119 120 |
# File 'lib/tina4/request.rb', line 118 def method @method end |
#path ⇒ Object (readonly)
Returns the value of attribute path.
118 119 120 |
# File 'lib/tina4/request.rb', line 118 def path @path end |
#query_string ⇒ Object (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_ip ⇒ Object (readonly)
Returns the value of attribute remote_ip.
118 119 120 |
# File 'lib/tina4/request.rb', line 118 def remote_ip @remote_ip end |
#route ⇒ Object
: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 |
#user ⇒ Object
: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_size ⇒ Object
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.
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).
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_token ⇒ Object
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 |
#body ⇒ Object 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_raw ⇒ Object
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 |
#cookies ⇒ Object
291 292 293 |
# File 'lib/tina4/request.rb', line 291 def @cookies ||= end |
#files ⇒ Object
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 |
#headers ⇒ Object
Lazy accessors
287 288 289 |
# File 'lib/tina4/request.rb', line 287 def headers @headers ||= extract_headers end |
#json_body ⇒ Object
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 |
#params ⇒ Object
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 |
#query ⇒ Object
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 |
#session ⇒ Object
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 |
#url ⇒ Object
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 |