Class: Tina4::Session

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

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 = {})
  @options = DEFAULT_OPTIONS.merge(options)
  # 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.cookie_name unless options.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 options.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 = (options[: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 options.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

#dataObject (readonly)

Returns the value of attribute data.



19
20
21
# File 'lib/tina4/session.rb', line 19

def data
  @data
end

#idObject (readonly)

Returns the value of attribute id.



19
20
21
# File 'lib/tina4/session.rb', line 19

def id
  @id
end

Class Method Details

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.cookie_name
  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.

Returns:

  • (Boolean)


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

#allObject

Return all session data



239
240
241
# File 'lib/tina4/session.rb', line 239

def all
  @data.dup
end

#clearObject



169
170
171
172
# File 'lib/tina4/session.rb', line 169

def clear
  @data = {}
  @modified = true
end


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 cookie_header(cookie_name = nil)
  name = cookie_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

#destroyObject

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_idObject

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

Returns:

  • (Boolean)


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

#regenerateObject

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

#saveObject

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_hashObject



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