Class: Tina4::Session
- Inherits:
-
Object
- Object
- Tina4::Session
- Defined in:
- lib/tina4/session.rb
Constant Summary collapse
- DEFAULT_OPTIONS =
{ secret: nil, max_age: 3600, handler: :file, handler_options: {}, # Opt-in, and OFF for every direct caller. See the construction guard in # #initialize: only the live REQUEST PATH degrades when the storage # handler cannot be built; boot, the CLI, a spec and app code that call # Session.new themselves still get the loud raise they rely on. degrade_on_backend_failure: false }.freeze
- SESSION_ID_PATTERN =
A session id is OPAQUE — an unguessable lookup token and nothing else. It is never a filename, a path, a SQL fragment or a Redis key fragment, so the only characters it may contain are the ones every backend treats as inert.
The alphabet is the RFC 4648 base64url set, which is exactly what all four frameworks already mint: Ruby SecureRandom.hex(32), Python secrets.token_urlsafe(32), PHP/Node hex(16). Validation is therefore non-breaking for every id the family has ever issued, while rejecting the "." and "/" that turn a cookie into a path traversal.
The constraint is the ALPHABET, not the length. There is deliberately NO entropy floor: unguessability comes from the framework MINTING the id (SecureRandom.hex(32)), never from inspecting one an app passed on purpose, so a floor would close no attack while breaking trusted callers that manage their own short programmatic ids (start("my-session-id")). An attacker-supplied id is stopped by strict mode (see #adopt_or_mint), not by its length. The 128-character ceiling just bounds what can be pushed through a backend key.
\A and \z, NEVER ^ and $: Ruby's ^/$ match LINE boundaries, so a "^...$" anchor would accept "legitimate_looking_id\n../../etc/passwd".
/\A[A-Za-z0-9_-]{1,128}\z/
Instance Attribute Summary collapse
-
#data ⇒ Object
readonly
Returns the value of attribute data.
-
#id ⇒ Object
readonly
Returns the value of attribute id.
Class Method Summary collapse
-
.cookie_name ⇒ Object
The session cookie name — the SINGLE source of truth shared by the WRITE side (#cookie_header) and the READ side (#extract_session_id AND RackApp's incoming-cookie parse), so a cookie written under a renamed name is read back on the next request.
-
.valid_session_id?(session_id) ⇒ Boolean
True when session_id is a well-formed opaque session identifier.
Instance Method Summary collapse
- #[](key) ⇒ Object
- #[]=(key, value) ⇒ Object
-
#all ⇒ Object
Return all session data.
- #clear ⇒ Object
- #cookie_header(cookie_name = nil) ⇒ Object
- #delete(key) ⇒ Object
-
#destroy ⇒ Object
Destroy the current session.
-
#flash(key, value = nil) ⇒ Object
Flash data: set a value that is removed after next read.
-
#gc(max_lifetime = nil) ⇒ Object
Garbage collection: remove expired sessions from the handler.
-
#get(key, default = nil) ⇒ Object
Get a session value with optional default.
-
#get_flash(key, default = nil) ⇒ Object
Get flash data by key (alias for flash(key) without value).
-
#get_session_id ⇒ Object
Returns the current session ID string.
-
#has?(key) ⇒ Boolean
Check if a key exists in the session.
-
#initialize(env, options = {}) ⇒ Session
constructor
A new instance of Session.
-
#read(session_id) ⇒ Object
Reads raw session data for a given session ID from backend storage.
-
#regenerate ⇒ Object
Regenerate the session ID while preserving data — returns the new ID.
-
#save ⇒ Object
Persist the session if dirty.
-
#set(key, value) ⇒ Object
Set a session value.
-
#start(session_id = nil) ⇒ Object
Start or resume a session.
- #to_hash ⇒ Object
-
#write(session_id, data, ttl = nil) ⇒ Object
Writes raw session data for a given session ID to backend storage.
Constructor Details
#initialize(env, options = {}) ⇒ Session
Returns a new instance of Session.
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 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 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 |
# File 'lib/tina4/session.rb', line 65 def initialize(env, = {}) @options = DEFAULT_OPTIONS.merge() # TINA4_SESSION_NAME — resolved by the ONE shared resolver (Session.cookie_name) # that BOTH the write path (#cookie_header) and the read paths # (#extract_session_id + RackApp incoming-cookie parse) go through, so a # renamed cookie is emitted and read back under the same name. An explicit # :cookie_name option still wins (same precedence as :handler below). @options[:cookie_name] = self.class. unless .key?(:cookie_name) # No guessable built-in secret. The session never signs with this value # (IDs are SecureRandom.hex(32)), so we resolve it from TINA4_SECRET only # — nil when unset. This honours the framework's blank-secret discipline # (Auth.ensure_dev_secret never uses a guessable default); Python/Node # sessions carry no secret field at all. @options[:secret] ||= ENV["TINA4_SECRET"] # TINA4_SESSION_TTL — cookie Max-Age (parity with Python's Session._ttl, # which reads the same var). Only consulted when the caller did NOT pass # an explicit :max_age (an explicit option always wins). Keeps the cookie # honouring TINA4_SESSION_TTL now that rack_app routes the Set-Cookie # through #cookie_header instead of hand-writing it (issue #31). unless .key?(:max_age) ttl_env = ENV["TINA4_SESSION_TTL"] @options[:max_age] = Integer(ttl_env) if ttl_env && !ttl_env.strip.empty? end # The BACKEND lifetime, resolved once and forwarded to handler#write on # every save (parity with Python's Session._ttl, which flows the same way). # #save used to call safe_write(@id, @data) with NO ttl, so the cookie said # Max-Age=900 while the stored record lived for the handler's own default - # a silent disagreement between what the browser was told and what the # store actually did. One resolver, both directions. @ttl = ([:ttl] || ENV["TINA4_SESSION_TTL"] || 3600).to_i # TINA4_SESSION_BACKEND — selects the storage handler unless the caller # explicitly passed :handler (same precedence as :cookie_name above; an # explicit option always wins over the environment). Without this the # shipped redis/valkey/mongo/database handlers are unreachable by config. # Parity with Python's Session._resolve_handler. @options[:handler] = env_session_backend || @options[:handler] unless .key?(:handler) # Backend-failure policy strict flag (parity with Python's # TINA4_SESSION_STRICT). When truthy, read/write/destroy/gc failures # RE-RAISE instead of logging + degrading. @strict = Tina4::Env.is_truthy(ENV["TINA4_SESSION_STRICT"]) # Whether THIS request reached us over https, decided PROXY-AWARE from the # Rack env at construction (x-forwarded-proto first hop, else rack scheme / # HTTPS). #cookie_header ORs this into the Secure flag so a cookie set on an # https request is Secure even when TINA4_SESSION_SECURE is unset — a # session cookie for an encrypted request must never be sent in the clear. # Uses the SAME detector as Request#url so the two never disagree (issue #31). @request_secure = Tina4::Request.secure_scheme?(env || {}) # LOG LOUD, THEN DEGRADE (ADR-0021) - for handler CONSTRUCTION too. # # The read/write/destroy/gc policy further down has always been right, but # it sat BELOW this line: create_handler ran bare. A handler whose # constructor touches the network - the database backend opens its # connection and issues DDL in #initialize - raised straight out of # Session.new, out of Request#session, and into RackApp's 500 handler, so # an unreachable backend took the whole REQUEST down instead of degrading # it, and TINA4_SESSION_STRICT was INERT because the non-strict path # already produced the identical 500. # # WHO DEGRADES, AND WHO STILL RAISES. Only the caller that opts in, which # is the live request path (Request#session, RackApp.enforce_route_auth). # Every other caller keeps the loud raise, deliberately: an unknown # TINA4_SESSION_BACKEND is a CONFIGURATION error, not an outage, and the # owner decision of 2026-07-31 (session_backend_validation_spec.rb, all # four frameworks) is that it must fail fast where a human can fix it # rather than serve on the wrong storage. This is the same split the # Python master makes: its Session raises, and core/server.py's request # path is what logs and degrades. # # A DEGRADED SESSION IS AN IN-MEMORY-ONLY SESSION: no handler, so nothing # is read from or written to any store. The route still receives a working # Session object - Ruby cannot hand back Python's `request.session = None` # without turning every `session[...]` into a NoMethodError, which would # 500 the very request this is saving - so reads yield an empty session # and #save returns false, which is exactly the contract. The failure is # logged ONCE, here, where it happened; see #degraded? for why not again. @handler = nil begin @handler = create_handler rescue StandardError => e raise unless @options[:degrade_on_backend_failure] log_backend_error("handler construction", e) raise if @strict end # The cookie is the live server's session-id source and is fully # attacker-controlled, so it goes through the same strict-mode funnel as # an explicit #start. adopt_or_mint(extract_session_id(env)) end |
Instance Attribute Details
#data ⇒ Object (readonly)
Returns the value of attribute data.
19 20 21 |
# File 'lib/tina4/session.rb', line 19 def data @data end |
#id ⇒ Object (readonly)
Returns the value of attribute id.
19 20 21 |
# File 'lib/tina4/session.rb', line 19 def id @id end |
Class Method Details
.cookie_name ⇒ Object
The session cookie name — the SINGLE source of truth shared by the WRITE side (#cookie_header) and the READ side (#extract_session_id AND RackApp's incoming-cookie parse), so a cookie written under a renamed name is read back on the next request. Keeping the default literal in ONE place means it can never drift between the emit and parse paths. Parity with Python's module-level session_cookie_name() (tina4_python/session/init.py).
TINA4_SESSION_NAME Cookie name (default: tina4_session)
60 61 62 63 |
# File 'lib/tina4/session.rb', line 60 def self. name = ENV["TINA4_SESSION_NAME"] name.nil? || name.empty? ? "tina4_session" : name end |
.valid_session_id?(session_id) ⇒ Boolean
True when session_id is a well-formed opaque session identifier.
Callers pass UNTRUSTED input here (the session cookie is attacker-chosen), so anything that is not a String of the opaque alphabet is rejected.
48 49 50 |
# File 'lib/tina4/session.rb', line 48 def self.valid_session_id?(session_id) session_id.is_a?(String) && SESSION_ID_PATTERN.match?(session_id) end |
Instance Method Details
#[](key) ⇒ Object
155 156 157 |
# File 'lib/tina4/session.rb', line 155 def [](key) @data[key.to_s] end |
#[]=(key, value) ⇒ Object
159 160 161 162 |
# File 'lib/tina4/session.rb', line 159 def []=(key, value) @data[key.to_s] = value @modified = true end |
#all ⇒ Object
Return all session data
239 240 241 |
# File 'lib/tina4/session.rb', line 239 def all @data.dup end |
#clear ⇒ Object
169 170 171 172 |
# File 'lib/tina4/session.rb', line 169 def clear @data = {} @modified = true end |
#cookie_header(cookie_name = nil) ⇒ Object
318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 |
# File 'lib/tina4/session.rb', line 318 def ( = nil) name = || @options[:cookie_name] samesite = ENV["TINA4_SESSION_SAMESITE"] || "Lax" # HttpOnly defaults to true (existing behaviour); flip off only when explicitly false. httponly = !%w[false 0 no off].include?((ENV["TINA4_SESSION_HTTPONLY"] || "true").to_s.strip.downcase) # Secure is set when ANY of the unified-contract conditions hold (parity # with tina4-php#175/#179): TINA4_SESSION_SECURE is truthy, OR SameSite is # None (browsers reject a None cookie without Secure per RFC 6265bis), OR # the request itself is https (proxy-aware, from @request_secure). A plain # HTTP request with the flag unset and SameSite != None stays non-Secure. secure = %w[true 1 yes on].include?((ENV["TINA4_SESSION_SECURE"] || "false").to_s.strip.downcase) || samesite.to_s.strip.casecmp("none").zero? || @request_secure == true parts = ["#{name}=#{@id}", "Path=/"] parts << "HttpOnly" if httponly parts << "Secure" if secure parts << "SameSite=#{samesite}" parts << "Max-Age=#{@options[:max_age]}" parts.join("; ") end |
#delete(key) ⇒ Object
164 165 166 167 |
# File 'lib/tina4/session.rb', line 164 def delete(key) @data.delete(key.to_s) @modified = true end |
#destroy ⇒ Object
Destroy the current session. ENDS the session: the stored record is removed and the id is CLEARED (@id = nil), so a later set()+save() with no new #start has no id to persist under and cannot RESURRECT the just- destroyed record. Mirrors the Python master (nulls _session_id) and Node (nulls sessionId). A fresh session needs a new #start, which mints a new id.
200 201 202 203 204 205 |
# File 'lib/tina4/session.rb', line 200 def destroy safe_destroy(@id) if @id @id = nil @data = {} @modified = false end |
#flash(key, value = nil) ⇒ Object
Flash data: set a value that is removed after next read. Call with value to set, call without value to get (and remove).
245 246 247 248 249 250 251 252 253 254 255 256 |
# File 'lib/tina4/session.rb', line 245 def flash(key, value = nil) flash_key = "_flash_#{key}" if value.nil? val = @data.delete(flash_key.to_s) @modified = true if val val else @data[flash_key.to_s] = value @modified = true value end end |
#gc(max_lifetime = nil) ⇒ Object
Garbage collection: remove expired sessions from the handler. A backend failure is logged and swallowed (never crashes the request).
308 309 310 311 312 313 314 315 316 |
# File 'lib/tina4/session.rb', line 308 def gc(max_lifetime = nil) return unless @handler.respond_to?(:gc) max_lifetime ||= @options[:max_age] @handler.gc(max_lifetime) rescue StandardError => e log_backend_error("gc", e) raise if @strict nil end |
#get(key, default = nil) ⇒ Object
Get a session value with optional default.
The default is returned for an ABSENT key, never for a stored FALSE. This
was @data[key.to_s] || default, which handed back the caller's default
for any falsy stored value — so a feature flag stored as false read back
as the caller's true default. Python (dict.get), PHP (??) and Node
(??) all return the stored false, so Ruby was the 1-of-4 outlier.
Deliberately the nil? form and NOT @data.key?(k) ? @data[k] : default:
both fix the false case, but the key? form would ALSO flip a stored nil
from the default to nil. Ruby currently agrees with PHP and Node there
(stored nil -> default) and only Python disagrees (stored None -> None),
so changing it is a cross-framework decision, not a side effect of this
fix. This form is exactly PHP's ?? and Node's ??. Same idiom as
#get_flash below.
222 223 224 225 |
# File 'lib/tina4/session.rb', line 222 def get(key, default = nil) value = @data[key.to_s] value.nil? ? default : value end |
#get_flash(key, default = nil) ⇒ Object
Get flash data by key (alias for flash(key) without value)
259 260 261 262 |
# File 'lib/tina4/session.rb', line 259 def get_flash(key, default = nil) result = flash(key) result.nil? ? default : result end |
#get_session_id ⇒ Object
Returns the current session ID string.
290 291 292 |
# File 'lib/tina4/session.rb', line 290 def get_session_id @id end |
#has?(key) ⇒ Boolean
Check if a key exists in the session
234 235 236 |
# File 'lib/tina4/session.rb', line 234 def has?(key) @data.key?(key.to_s) end |
#read(session_id) ⇒ Object
Reads raw session data for a given session ID from backend storage. Returns the data hash, or {} on a backend failure (logged + degraded).
296 297 298 |
# File 'lib/tina4/session.rb', line 296 def read(session_id) safe_read(session_id) end |
#regenerate ⇒ Object
Regenerate the session ID while preserving data — returns the new ID. Call this right after login or any privilege change to defend against session fixation (a pre-auth session ID must not survive into the authenticated session). Destroys the old backend record (best-effort) and persists under the new ID.
269 270 271 272 273 274 275 276 |
# File 'lib/tina4/session.rb', line 269 def regenerate old_id = @id @id = SecureRandom.hex(32) safe_destroy(old_id) @modified = true save @id end |
#save ⇒ Object
Persist the session if dirty. On a backend write failure the error is logged and false is returned — the @modified (dirty) flag is RETAINED so a later save can retry. Returns true on a successful (or no-op) write.
A cleared id (@id nil, e.g. after #destroy) is a no-op: there is nothing
to persist, and a write would re-create the just-destroyed record. Mirrors
the Python master's if self._session_id and self._dirty.
185 186 187 188 189 190 191 192 193 |
# File 'lib/tina4/session.rb', line 185 def save return true unless @id && @modified if safe_write(@id, @data, @ttl) @modified = false true else false # dirty flag retained for retry end end |
#set(key, value) ⇒ Object
Set a session value
228 229 230 231 |
# File 'lib/tina4/session.rb', line 228 def set(key, value) @data[key.to_s] = value @modified = true end |
#start(session_id = nil) ⇒ Object
Start or resume a session. Returns the session ID string.
session_id is UNTRUSTED, so it goes through #adopt_or_mint: it is resumed only when it is a well-formed opaque id AND one the backend already holds a session under (strict mode). Otherwise a genuinely NEW session is started under a fresh SecureRandom.hex(32). A session already in flight keeps both its id and its data.
285 286 287 |
# File 'lib/tina4/session.rb', line 285 def start(session_id = nil) adopt_or_mint(session_id) end |
#to_hash ⇒ Object
174 175 176 |
# File 'lib/tina4/session.rb', line 174 def to_hash @data.dup end |
#write(session_id, data, ttl = nil) ⇒ Object
Writes raw session data for a given session ID to backend storage. Returns true on success, false on a backend failure (logged + degraded).
302 303 304 |
# File 'lib/tina4/session.rb', line 302 def write(session_id, data, ttl = nil) safe_write(session_id, data, ttl) end |