Skip to content

Commit f1f9b4c

Browse files
authored
Use app-specific cookie keys for sessions (#189)
* Update docs * Use app-specific key for sessions * Add debug logs * Allow to configure session key * bump version
1 parent a762e4e commit f1f9b4c

9 files changed

Lines changed: 296 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
## [Unreleased]
22

3+
## [1.19.1] - 2025-12-26
4+
5+
### Changed
6+
7+
- Use app-specific cookie keys for sessions (#189).
8+
39
## [1.19.0] - 2025-12-03
410

511
### Added

lib/rage/cable/cable.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
11
# frozen_string_literal: true
22

3+
##
4+
# `Rage::Cable` provides built-in WebSocket support for Rage apps, similar to Action Cable in Rails. It lets you mount a separate WebSocket application, define channels and connections, subscribe clients to named streams, and broadcast messages in real time.
5+
#
6+
# Define a channel:
7+
# ```ruby
8+
# class ChatChannel < Rage::Cable::Channel
9+
# def subscribed
10+
# stream_from "chat"
11+
# end
12+
#
13+
# def receive(data)
14+
# puts "Received message: #{data['message']}"
15+
# end
16+
# end
17+
# ```
18+
#
19+
# Mount the Cable application:
20+
# ```ruby
21+
# Rage.routes.draw do
22+
# mount Rage::Cable.application, at: "/cable"
23+
# end
24+
# ```
25+
#
26+
# Broadcast a message to a stream:
27+
# ```ruby
28+
# Rage.cable.broadcast("chat", { message: "Hello, world!" })
29+
# ```
30+
#
331
module Rage::Cable
432
# Create a new Cable application.
533
#

lib/rage/configuration.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,14 @@ def log_tags
203203
end
204204
# @!endgroup
205205

206+
# @!group Session Configuration
207+
# Allows configuring session settings.
208+
# @return [Rage::Configuration::Session]
209+
def session
210+
@session ||= Session.new
211+
end
212+
# @!endgroup
213+
206214
# @private
207215
def internal
208216
@internal ||= Internal.new
@@ -773,6 +781,17 @@ def parse_disk_backend_options(opts)
773781
end
774782
end
775783

784+
class Session
785+
# @!attribute key
786+
# Specify the name of the session cookie.
787+
# @return [String]
788+
# @example Change the session cookie name
789+
# Rage.configure do
790+
# config.session.key = "_myapp_session"
791+
# end
792+
attr_accessor :key
793+
end
794+
776795
# @private
777796
class Internal
778797
attr_accessor :rails_mode

lib/rage/cookies.rb

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,92 @@
1212
ERR
1313
end
1414

15+
##
16+
# Cookies provide a convenient way to store small amounts of data on the client side that persists across requests.
17+
# They are commonly used for session management, personalization, and tracking user preferences.
18+
#
19+
# Rage cookies support both simple string-based cookies and encrypted cookies for sensitive data.
20+
#
21+
# To use cookies, add the `domain_name` gem to your `Gemfile`:
22+
#
23+
# ```bash
24+
# bundle add domain_name
25+
# ```
26+
#
27+
# Additionally, if you need to use encrypted cookies, see {Session} for setup steps.
28+
#
29+
# ## Usage
30+
#
31+
# ### Basic Cookies
32+
#
33+
# Read and write simple string values:
34+
#
35+
# ```ruby
36+
# # Set a cookie
37+
# cookies[:user_name] = "Alice"
38+
#
39+
# # Read a cookie
40+
# cookies[:user_name] # => "Alice"
41+
#
42+
# # Delete a cookie
43+
# cookies.delete(:user_name)
44+
# ```
45+
#
46+
# ### Cookie Options
47+
#
48+
# Set cookies with additional options for security and control:
49+
#
50+
# ```ruby
51+
# cookies[:user_id] = {
52+
# value: "12345",
53+
# expires: 1.year.from_now,
54+
# secure: true,
55+
# httponly: true,
56+
# same_site: :lax
57+
# }
58+
# ```
59+
#
60+
# ### Encrypted Cookies
61+
#
62+
# Store sensitive data securely with automatic encryption:
63+
#
64+
# ```ruby
65+
# # Set an encrypted cookie
66+
# cookies.encrypted[:api_token] = "secret-token"
67+
#
68+
# # Read an encrypted cookie
69+
# cookies.encrypted[:api_token] # => "secret-token"
70+
#
71+
# ```
72+
#
73+
# ### Permanent Cookies
74+
#
75+
# Create cookies that expire 20 years from now:
76+
#
77+
# ```ruby
78+
# cookies.permanent[:remember_token] = "token-value"
79+
#
80+
# # Can be combined with encrypted
81+
# cookies.permanent.encrypted[:user_id] = current_user.id
82+
# ```
83+
#
84+
# ### Domain Configuration
85+
#
86+
# Control which domains can access your cookies:
87+
#
88+
# ```ruby
89+
# # Specific domain
90+
# cookies[:cross_domain] = { value: "data", domain: "example.com" }
91+
#
92+
# # All subdomains
93+
# cookies[:shared] = { value: "data", domain: :all }
94+
#
95+
# # Multiple allowed domains
96+
# cookies[:limited] = { value: "data", domain: ["app.example.com", "api.example.com"] }
97+
# ```
98+
#
99+
# @see Session
100+
#
15101
class Rage::Cookies
16102
# @private
17103
def initialize(env, headers)
@@ -190,10 +276,13 @@ def load(value)
190276
begin
191277
box.decrypt(Base64.urlsafe_decode64(value).byteslice(2..))
192278
rescue ArgumentError
279+
Rage.logger.debug("Failed to decode encrypted cookie")
193280
nil
194281
rescue RbNaCl::CryptoError
282+
Rage.logger.debug("Failed to decrypt encrypted cookie")
195283
i ||= 0
196284
if (box = fallback_boxes[i])
285+
Rage.logger.debug("Trying to decrypt with fallback key ##{i + 1}")
197286
i += 1
198287
retry
199288
end

lib/rage/session.rb

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,68 @@
22

33
require "json"
44

5+
##
6+
# Sessions securely store data between requests using cookies and are typically one of the most convenient and secure
7+
# authentication mechanisms for browser-based clients.
8+
#
9+
# Rage sessions are encrypted using a secret key. This prevents clients from reading or tampering with session data.
10+
#
11+
# ## Setup
12+
#
13+
# 1. Add the required gems to your `Gemfile`:
14+
#
15+
# ```bash
16+
# bundle add base64 domain_name rbnacl
17+
# ```
18+
#
19+
# 2. Generate a secret key base (keep this value private and out of version control):
20+
#
21+
# ```bash
22+
# ruby -r securerandom -e 'puts SecureRandom.hex(64)'
23+
# ```
24+
#
25+
# 3. Configure your application to use the generated key, either via configuration:
26+
#
27+
# ```ruby
28+
# Rage.configure do |config|
29+
# config.secret_key_base = "my-secret-key"
30+
# end
31+
# ```
32+
#
33+
# or via the `SECRET_KEY_BASE` environment variable:
34+
#
35+
# ```bash
36+
# export SECRET_KEY_BASE="my-secret-key"
37+
# ```
38+
#
39+
# ## System Dependencies
40+
#
41+
# Rage sessions use libsodium (via RbNaCl) for encryption. On many Debian-based systems
42+
# it is installed by default; if not, install it with:
43+
#
44+
# - Ubuntu / Debian:
45+
#
46+
# ```bash
47+
# sudo apt install libsodium23
48+
# ```
49+
#
50+
# - Fedora / RHEL / Amazon Linux:
51+
#
52+
# ```bash
53+
# sudo yum install libsodium
54+
# ```
55+
#
56+
# - macOS (using Homebrew):
57+
#
58+
# ```bash
59+
# brew install libsodium
60+
# ```
61+
#
562
class Rage::Session
663
# @private
7-
KEY = Rack::RACK_SESSION.to_sym
64+
def self.key
65+
@key ||= Rage.config.session.key&.to_sym || :"_#{Rage.root.basename.to_s.gsub(/\W/, "_").downcase}_session"
66+
end
867

968
# @private
1069
def initialize(cookies)
@@ -92,13 +151,15 @@ def write_session(add: nil, remove: nil, clear: nil)
92151
read_session.clear
93152
end
94153

95-
@cookies[KEY] = { httponly: true, same_site: :lax, value: read_session.to_json }
154+
@cookies[self.class.key] = { httponly: true, same_site: :lax, value: read_session.to_json }
96155
end
97156

98157
def read_session
99158
@session ||= begin
100-
JSON.parse(@cookies[KEY] || "{}", symbolize_names: true)
159+
session_value = @cookies[self.class.key] || @cookies[Rack::RACK_SESSION.to_sym] || "{}"
160+
JSON.parse(session_value, symbolize_names: true)
101161
rescue JSON::ParserError
162+
Rage.logger.debug("Failed to parse session cookie, resetting session")
102163
{}
103164
end
104165
end

lib/rage/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
22

33
module Rage
4-
VERSION = "1.19.0"
4+
VERSION = "1.19.1"
55
end

spec/configuration_spec.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,4 +421,19 @@ def unknown(_) = true
421421
end
422422
end
423423
end
424+
425+
describe "#session" do
426+
subject { described_class.new.session }
427+
428+
context "#key" do
429+
it "returns nil by default" do
430+
expect(subject.key).to be_nil
431+
end
432+
433+
it "persists configuration" do
434+
subject.key = "_my_test"
435+
expect(subject.key).to eq("_my_test")
436+
end
437+
end
438+
end
424439
end

spec/controller/api/cookies_spec.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@
7474
{ session: "MDC9exZmtbHuQ0hKQbfuf69gBQE0oER1y0DInAq686395nMYPVRxt0D3W8wt0jjegw1LNu4MSvQf1LSWdw==" }
7575
end
7676

77+
before do
78+
allow(Rage).to receive(:logger).and_return(double(debug: nil))
79+
end
80+
7781
it "correctly decrypts data" do
7882
expect(subject.cookies.encrypted[:session]).to eq("fallback-test-value")
7983
end
@@ -82,6 +86,10 @@
8286
context "with incorrectly encrypted data" do
8387
let(:cookies) { { session: "MDC9exZmtbHuQ0hK" } }
8488

89+
before do
90+
allow(Rage).to receive(:logger).and_return(double(debug: nil))
91+
end
92+
8593
it "return nil" do
8694
expect(subject.cookies.encrypted[:session]).to be_nil
8795
end
@@ -90,6 +98,10 @@
9098
context "with incorrectly base64 encoded data" do
9199
let(:cookies) { { session: ";;;;;;;" } }
92100

101+
before do
102+
allow(Rage).to receive(:logger).and_return(double(debug: nil))
103+
end
104+
93105
it "return nil" do
94106
expect(subject.cookies.encrypted[:session]).to be_nil
95107
end

0 commit comments

Comments
 (0)