rack-session-xfile 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 64d96bd5bb35fd04da4c0c2b7cd7f697f367617f
4
+ data.tar.gz: 81f1f785803a8447c899da0441fff97dc4d81766
5
+ SHA512:
6
+ metadata.gz: 2ea0309035a48c736404d70b5669056b18735a8c578da6badb5296d058a53aa54279721ff74d485d492f9f0fd07481db6a2cb584bc159c78c53183f6c4fa76a6
7
+ data.tar.gz: ee252d4880ff8e137c9718395d391b1a639d89ba24c1c314c3cc8b3bb51f592bf64c76dd7239451f9c584b1fb138370a3ab9957e6325e46a5a18da661b4ffe0b
data/README.rdoc ADDED
@@ -0,0 +1,164 @@
1
+ = Rack::Session::XFile --- Atomic File-Based Session Management
2
+
3
+ == Design and implementation
4
+
5
+ XFile leverages mutex and file locking to guarantee atomic updates to session
6
+ data. Exclusive create locks are also used to allocate the resource file when
7
+ generating a new session id (SID). This eliminates race conditions and ensures
8
+ data consistency for multi-threaded and multi-process applications.
9
+
10
+ File-based sessions are the fastest and simplest persistent-storage option. To
11
+ maximize performance, store session files on an SSD, an in-memory filesystem, or
12
+ a disk-based filesystem with performant caching, such as ZFS. Session files are
13
+ distributed across 62 directories to minimize access time on some filesystems.
14
+
15
+ === Goals
16
+
17
+ 1. *Simple*: We already have a working filesystem; no configuration.
18
+ 2. *Reliable*: We are using a time-tested filesystem and file lock mechanism.
19
+ 3. *Secure*: Session data is always stored on the server under our control.
20
+ 4. *Performant*: Session access is very responsive with memory cache or SSDs.
21
+ 5. *Persistent*: Sessions persist regardless of application crashes or restarts.
22
+
23
+
24
+ == How session data is stored and referenced
25
+
26
+ Session data is marshaled and written to the persistent file on the server
27
+ associated with the SID. The client cookie (set by the +key+ option) is then
28
+ assigned the SID.
29
+
30
+ == Request filtering
31
+
32
+ The request filter uses Rack's +skip+ option to completely bypass session
33
+ processing for matching requests. When a request matches the filter, a
34
+ temporary, in-memory session hash is created instead. The filesystem is never
35
+ accessed.
36
+
37
+ User agents can be filtered by setting the +user_agent_filter+ option with a
38
+ regular expression.
39
+
40
+ This feature was originally implemented to avoid processing sessions for robots.
41
+ Many sources state that one-third to two-thirds of all web traffic is from bots.
42
+ However, most robots don't store or forward cookies. Consequently, processing
43
+ sessions for robots wastes resources and pollutes the session store with
44
+ needless "one-time" sessions.
45
+
46
+ By default, SIDs are validated by the request filtering system to detect SID
47
+ tampering, which could lead to directory traversal exploits. Requests with
48
+ invalid SIDs are skipped.
49
+
50
+ == Purging expired session files
51
+
52
+ The XFile session manager does not concern itself with purging expired sessions.
53
+ This task is much better suited for an out-of-band process, such as cron.
54
+
55
+ For example, run an hourly cron job that executes +find+ and deletes expired
56
+ session files. For instance, if the +expire_after+ option is set to 30 days,
57
+ then delete session files with last-modification times greater than 30 days.
58
+
59
+ Additionally, files with modification times past an _X_ amount of time but less
60
+ than expiration could be truncated to free up disk space. This effectively
61
+ returns an empty session, but will preserve session IDs previously issued to
62
+ clients. SID preservation may be critical if these session IDs are referenced by
63
+ another backend system or service, such as an analytics system.
64
+
65
+ === Example cron job to purge sessions older than 30 days
66
+
67
+ 0 * * * * find /tmp/xfile-sessions -type f -mtime +30 -exec rm -- {} +
68
+
69
+ === Example cron job to clear sessions older than 15 days while maintaining SIDs
70
+
71
+ 0 0 * * * find /tmp/xfile-sessions -type f -mtime +15 -exec cp /dev/null {} \;
72
+
73
+ *Caveat*: There is potential for a race condition anytime a process modifies
74
+ XFile sessions without utilizing file locks. However, in this case with cron,
75
+ the potential is very small considering the session files to be deleted haven't
76
+ been accessed in a relatively long time and probably won't be any time soon.
77
+
78
+
79
+ == Usage Examples
80
+
81
+ === Basic usage with sessions stored under the application directory
82
+
83
+ require 'rack-session-xfile'
84
+
85
+ use Rack::Session::XFile,
86
+ session_dir: File.join(settings.root, 'tmp/sessions'),
87
+ expire_after: 60*60*24*30,
88
+ secure: true,
89
+ key: 'sid'
90
+
91
+ By default, sessions are stored in the directory named _xfile-sessions_ under
92
+ the operating system's temporary file path.
93
+
94
+ === Bypass session processing for robots
95
+
96
+ require 'rack-session-xfile'
97
+
98
+ use Rack::Session::XFile,
99
+ user_agent_filter: /(bot|crawler|spider)/i
100
+
101
+ A more extensive filter could include: wget|archive|search|seo|libwww|index.
102
+
103
+ == Install, test & contribute
104
+
105
+ * Install as a system-wide gem.
106
+
107
+ $ sudo gem install rack-session-xfile
108
+
109
+ * Install as a user-local gem.
110
+
111
+ $ gem install rack-session-xfile --user-install
112
+
113
+ * Clone the Mercurial repository.
114
+
115
+ $ hg clone https://bitbucket.org/pachl/rack-session-xfile
116
+
117
+ XFile is tested with Christian Neukirchen's awesome test framework,
118
+ {Bacon}[https://github.com/chneukirchen/bacon]. First, get bacon. Then crank up
119
+ the heat on the entire test suite. Let's get cooking!
120
+
121
+ $ sudo gem install bacon
122
+ $ cd rack-session-xfile
123
+ $ bacon -a
124
+
125
+ If you find or fix a bug, please send reports and patches to pachl@ecentryx.com
126
+ or open an issue on the bug tracker.
127
+
128
+
129
+ == Links
130
+
131
+ Homepage :: http://ecentryx.com/gems/rack-session-xfile
132
+ Ruby Gem :: https://rubygems.org/gems/rack-session-xfile
133
+ Source Code :: https://bitbucket.org/pachl/rack-session-xfile/src
134
+ Bug Tracker :: https://bitbucket.org/pachl/rack-session-xfile/issues
135
+
136
+ == Compatibility
137
+
138
+ Rack::Session::XFile was developed and tested on
139
+ OpenBSD[http://www.openbsd.org] 5.6
140
+ using Ruby[https://www.ruby-lang.org] 2.1.2
141
+ and Rack[https://github.com/rack/rack] 1.6.0.
142
+
143
+ == History
144
+
145
+ 1. 2015-01-29, v0.10.0: First public release
146
+ * XFile ran in production for more than a year before public release.
147
+
148
+ == License
149
+
150
+ ({ISC License}[http://opensource.org/licenses/ISC])
151
+
152
+ Copyright (c) 2015, Clint Pachl <pachl@ecentryx.com>
153
+
154
+ Permission to use, copy, modify, and/or distribute this software for any purpose
155
+ with or without fee is hereby granted, provided that the above copyright notice
156
+ and this permission notice appear in all copies.
157
+
158
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
159
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
160
+ FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
161
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
162
+ OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
163
+ TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
164
+ THIS SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,22 @@
1
+ verbose false
2
+
3
+ require 'rubygems/package_task'
4
+ Gem::PackageTask.new(eval File.read('xfile.gemspec')) {}
5
+
6
+ require 'rdoc/task'
7
+ RDoc::Task.new do |rdoc|
8
+ rdoc.main = 'README.rdoc'
9
+ rdoc.rdoc_files.include('README.rdoc', 'lib/**/*.rb')
10
+ end
11
+
12
+ desc 'Upload RDoc files to public server'
13
+ task :uprdoc => :rdoc do
14
+ dir = '/var/www/sites/ecentryx.com/public/gems/rack-session-xfile/'
15
+ host = 'hercules'
16
+ sh "cd ./html && pax -w . | ssh #{host} 'cd #{dir} && rm -rf * && pax -r'"
17
+ end
18
+
19
+ desc 'Run all specs'
20
+ task :spec do
21
+ sh 'bacon -aq'
22
+ end
@@ -0,0 +1,193 @@
1
+ require 'rack/session/abstract/id'
2
+ require 'tmpdir'
3
+
4
+ module Rack
5
+ module Session
6
+
7
+ class XFile < Abstract::ID
8
+
9
+ VERSION = '0.10.1'
10
+
11
+ File = ::File # override Rack::File
12
+
13
+ SIDCharacters = [*(0..9), *(:a..:z), *(:A..:Z)].map!(&:to_s)
14
+
15
+ Abstract::ID::DEFAULT_OPTIONS.merge! \
16
+ session_dir: File.join(Dir.tmpdir, 'xfile-sessions'),
17
+ user_agent_filter: nil,
18
+ key: 'xid'
19
+
20
+ def initialize(app, options = {})
21
+ super
22
+ @mutex = Mutex.new
23
+ @session_dir = @default_options[:session_dir]
24
+ @ua_filter = @default_options[:user_agent_filter]
25
+ @sid_nbytes = @default_options[:sidbits] / 8
26
+ setup_directories
27
+ check_permissions
28
+ end
29
+
30
+ def set_session(env, sid, session, options)
31
+ critical_section(env) do
32
+ write_session(sid, session)
33
+ end
34
+ end
35
+
36
+ def get_session(env, sid)
37
+ critical_section(env) do
38
+ return [nil, {}] if filter_request(env, sid)
39
+
40
+ unless sid and session = read_session(sid)
41
+ sid, session = generate_sid, {}
42
+ end
43
+ [sid, session]
44
+ end
45
+ end
46
+
47
+ def destroy_session(env, sid, options)
48
+ critical_section(env) do
49
+ File.unlink(session_file(sid)) rescue nil
50
+ generate_sid unless options[:drop]
51
+ end
52
+ end
53
+
54
+ def session_count
55
+ Dir[File.join(@session_dir, '?', '*')].count
56
+ end
57
+
58
+ private
59
+
60
+ #
61
+ # Provide thread-safety for critical sections.
62
+ #--
63
+ # Some systems do not implement, or implement correctly, advisory file
64
+ # locking for threads; therefore a mutex lock must encompass file locks.
65
+ #
66
+ def critical_section(env)
67
+ @mutex.lock if env['rack.multithread']
68
+ yield
69
+ ensure
70
+ @mutex.unlock if @mutex.locked?
71
+ end
72
+
73
+ #
74
+ # Atomically generate a unique session ID and allocate the resource file.
75
+ # Returns the session ID.
76
+ # --
77
+ # NOTE
78
+ # The sid is also a filename. Only SIDCharacters are allowed in the sid.
79
+ # If +super+ is called it returns a HEX sid, which is a subset.
80
+ #
81
+ def generate_sid
82
+ sid = SecureRandom.urlsafe_base64(@sid_nbytes) rescue super
83
+ sid.tr!('_-', SIDCharacters.sample(2).join)
84
+ File.open(session_file(sid), File::WRONLY|File::CREAT|File::EXCL).close
85
+ sid
86
+ rescue Errno::EEXIST
87
+ retry
88
+ end
89
+
90
+ #
91
+ # Read session from file using a shared lock.
92
+ #
93
+ def read_session(sid)
94
+ File.open(session_file(sid)) do |f|
95
+ f.flock(File::LOCK_SH)
96
+ begin
97
+ f.size == 0 ? {} : Marshal.load(f)
98
+ rescue ArgumentError, TypeError => e
99
+ warn "#{self.class} PATH=#{session_file(sid)}: #{e.message}"
100
+ {}
101
+ end
102
+ end
103
+ rescue Errno::ENOENT
104
+ end
105
+
106
+ #
107
+ # Write session to file using an exclusive lock.
108
+ #
109
+ def write_session(sid, session)
110
+ File.open(session_file(sid), 'r+') do |f|
111
+ f.flock(File::LOCK_EX)
112
+ f.write(Marshal.dump(session))
113
+ f.flush
114
+ f.truncate(f.pos)
115
+ end
116
+ sid
117
+ rescue => e
118
+ warn "#{self.class} cannot set session: #{e.message}"
119
+ false
120
+ end
121
+
122
+ #
123
+ # Skip session processing if the request matches a filter.
124
+ #
125
+ # == User agent filter
126
+ # User agents that do not utilize sessions, such as bots, may be filtered.
127
+ #
128
+ # == SID filter
129
+ # The +sid+ is provided by untrusted clients. A tampered sid could result
130
+ # in directory traversal, pathname, or guessing exploits. Do not waste
131
+ # resources on a client that sends an invalid sid.
132
+ #
133
+ # NOTE: Whitelist all valid sid characters. It's probably not sufficient
134
+ # to blacklist only potentially dangerous chars such as '..', '/' or '\'.
135
+ #
136
+ # sid bit range: 62^10-62^50 (approx. 2^60-2^297)
137
+ #
138
+ def filter_request(env, sid)
139
+ request = nil
140
+
141
+ if @ua_filter
142
+ request = Request.new(env)
143
+ if request.user_agent =~ @ua_filter
144
+ return request.session.options[:skip] = true
145
+ end
146
+ end
147
+
148
+ if sid and sid !~ /^[a-zA-Z0-9]{10,50}$/
149
+ warn "#{self.class} SID=#{sid}: tampered sid detected."
150
+ request ||= Request.new(env)
151
+ request.session.options[:skip] = true
152
+ end
153
+ end
154
+
155
+ def session_file(sid)
156
+ File.join @session_dir, sid[0], sid
157
+ end
158
+
159
+ #
160
+ # Create directory structure for session file distribution.
161
+ #
162
+ def setup_directories
163
+ Dir.mkdir(@session_dir, 0700) unless Dir.exist? @session_dir
164
+
165
+ SIDCharacters.each do |char|
166
+ dir = File.join @session_dir, char
167
+ Dir.mkdir(dir, 0700) unless Dir.exist? dir
168
+ end
169
+ end
170
+
171
+ #
172
+ # Ensure session directory has secure permissions, else raise exception.
173
+ #
174
+ def check_permissions
175
+ perms = File.stat(@session_dir).mode
176
+ msg = "#{self.class}: #{@session_dir} sessions directory is "
177
+
178
+ raise Errno::EACCES, msg + 'not owned' unless File.owned? @session_dir
179
+ raise Errno::EACCES, msg + 'not accessible' if perms & 0700 != 0700
180
+ raise SecurityError, msg + 'world accessible' if perms & 0007 != 0
181
+
182
+ if perms & 070 != 0
183
+ if File.grpowned? @session_dir
184
+ warn msg + 'group owned/accessible'
185
+ else
186
+ raise SecurityError, msg + 'group accessible'
187
+ end
188
+ end
189
+ end
190
+
191
+ end
192
+ end
193
+ end
@@ -0,0 +1 @@
1
+ require_relative 'rack/session/xfile'
@@ -0,0 +1,461 @@
1
+ require 'rack/session/xfile'
2
+ require 'rack/mock'
3
+ require 'fileutils'
4
+ require 'tmpdir'
5
+
6
+
7
+ describe Rack::Session::XFile do
8
+
9
+ def counter number
10
+ %[{"counter"=>#{number}}]
11
+ end
12
+
13
+ def empty_session
14
+ '{}'
15
+ end
16
+
17
+ def set_sid id
18
+ if id =~ TheSession
19
+ {'HTTP_COOKIE' => id}
20
+ else
21
+ {'HTTP_COOKIE' => "#{@session_key}=#{id}"}
22
+ end
23
+ end
24
+
25
+ # Create an xfile out-of-band in the current session directory.
26
+ def create_session data = nil
27
+ app = Rack::Session::XFile.new(nil)
28
+ sid = app.send(:generate_sid)
29
+ app.set_session({}, sid, (data || {sid: sid}), {})
30
+ sid
31
+ end
32
+
33
+ spec_tmp_dir = Dir.mktmpdir 'xfile-specs-'
34
+ @session_dir = Rack::Session::XFile::DEFAULT_OPTIONS[:session_dir].
35
+ replace(File.join(spec_tmp_dir, 'xfile-sessions'))
36
+ @session_key = Rack::Session::XFile::DEFAULT_OPTIONS[:key]
37
+ session_bits = Rack::Session::XFile::DEFAULT_OPTIONS[:sidbits]
38
+ session_chars = (session_bits / 8 / 3.0 * 4).ceil # urlsafe_base64 length
39
+ TheSession = /^#{@session_key}=[0-9a-zA-Z]{#{session_chars}};/
40
+
41
+ # Apps
42
+
43
+ incrementor = lambda do |env|
44
+ env['rack.session']['counter'] ||= 0
45
+ env['rack.session']['counter'] += 1
46
+ Rack::Response.new(env['rack.session'].inspect).to_a
47
+ end
48
+
49
+ noop = lambda do |env|
50
+ Rack::Response.new('noop').to_a
51
+ end
52
+
53
+ read_session_id = lambda do |env|
54
+ Rack::Response.new(env['rack.session'].id).to_a
55
+ end
56
+
57
+ read_session = lambda do |env|
58
+ env['rack.session'].send(:load_for_read!)
59
+ Rack::Response.new(env['rack.session'].inspect).to_a
60
+ end
61
+
62
+ defer_session = lambda do |env|
63
+ env['rack.session.options'][:defer] = true
64
+ incrementor.call(env)
65
+ end
66
+
67
+ drop_session = lambda do |env|
68
+ env['rack.session.options'][:drop] = true
69
+ incrementor.call(env)
70
+ end
71
+
72
+ renew_session = lambda do |env|
73
+ env['rack.session.options'][:renew] = true
74
+ incrementor.call(env)
75
+ end
76
+
77
+
78
+ before do
79
+ @warnings = warnings = []
80
+ Rack::Session::XFile.class_eval do
81
+ define_method(:warn) { |m| warnings << m }
82
+ end
83
+ end
84
+
85
+ after do
86
+ FileUtils.rm_rf(@session_dir)
87
+ Rack::Session::XFile.class_eval { remove_method :warn }
88
+ end
89
+
90
+ it 'sets default options' do
91
+ key = 'secret'
92
+ dir = File.join(spec_tmp_dir, 'secret-sessions')
93
+ filter = /useragent/
94
+ expire = 3600
95
+ app = Rack::Session::XFile.new(incrementor,
96
+ key: key,
97
+ session_dir: dir,
98
+ user_agent_filter: filter,
99
+ expire_after: expire)
100
+ app.key.should.equal key
101
+ app.default_options[:key].should.be.nil # deleted in superclass
102
+ app.default_options[:session_dir].should.equal dir
103
+ app.default_options[:user_agent_filter].should.equal filter
104
+ app.default_options[:expire_after].should.equal expire
105
+ cookie = Rack::MockRequest.new(app).get('/')['Set-Cookie']
106
+ cookie.should.match /^#{key}=/
107
+ FileUtils.rm_rf(dir)
108
+ end
109
+
110
+ it 'initializes session directory structure' do
111
+ app = Rack::Session::XFile.new(noop)
112
+ dir = app.default_options[:session_dir]
113
+ Dir[File.join(dir, '**', '*')].count.should == # greedy match
114
+ Rack::Session::XFile::SIDCharacters.count
115
+ app.session_count.should.be.zero
116
+ end
117
+
118
+ it 'sets secure permissions when creating session directory' do
119
+ # NOTE unable to test when no user/group ownership
120
+ Rack::Session::XFile.new(noop)
121
+ (File.stat(@session_dir).mode & 077).should.be.zero
122
+ File.owned?(@session_dir).should.be.true
123
+ end
124
+
125
+ it 'raises exception if session directory is world accessible' do
126
+ Dir.mkdir(@session_dir, 0707)
127
+ should.raise(SecurityError) { Rack::Session::XFile.new(noop) }.
128
+ message.should.include 'world accessible'
129
+ end
130
+
131
+ it 'raises exception if session directory is not user accessible' do
132
+ Dir.mkdir @session_dir
133
+ File.chmod 0000, @session_dir
134
+ should.raise(Errno::EACCES) { Rack::Session::XFile.new(noop) }
135
+ File.chmod 0100, @session_dir
136
+ should.raise(Errno::EACCES) { Rack::Session::XFile.new(noop) }
137
+ File.chmod 0200, @session_dir
138
+ should.raise(Errno::EACCES) { Rack::Session::XFile.new(noop) }
139
+ File.chmod 0400, @session_dir
140
+ should.raise(Errno::EACCES) { Rack::Session::XFile.new(noop) }
141
+ File.chmod 0700, @session_dir
142
+ should.not.raise(Errno::EACCES) { Rack::Session::XFile.new(noop) }
143
+ end
144
+
145
+ it 'warns if session directory is group accessible but group owned' do
146
+ Dir.mkdir(@session_dir, 0770)
147
+ should.not.raise { Rack::Session::XFile.new(noop) }
148
+ @warnings.count.should.equal 1
149
+ @warnings.first.should.include 'group owned/accessible'
150
+ end
151
+
152
+ it 'returns false when setting session if cannot serialize data' do
153
+ obj = Object.new
154
+ def obj.singleton() nil end
155
+ nonserializable_session = { data: obj }
156
+
157
+ sid = create_session
158
+ app = Rack::Session::XFile.new(noop)
159
+ app.set_session({}, sid, nonserializable_session, {}).should.be.false
160
+ @warnings.count.should.equal 1
161
+ @warnings.first.should.match /cannot set session.*singleton can't be dumped/
162
+ end
163
+
164
+ it 'returns false when setting session if sid is nonexistent' do
165
+ sid = 'nonexistent'
166
+ app = Rack::Session::XFile.new(noop)
167
+ app.set_session({}, sid, {}, {}).should.be.false
168
+ @warnings.count.should.equal 1
169
+ @warnings.first.should.match /cannot set session.*No such file/
170
+ end
171
+
172
+ it 'creates new xfile session' do
173
+ app = Rack::Session::XFile.new(incrementor)
174
+ res = Rack::MockRequest.new(app).get('/')
175
+ res['Set-Cookie'].should.match TheSession
176
+ res.body.should.equal counter(1)
177
+ end
178
+
179
+ it 'creates one xfile with six stateful requests' do
180
+ app = Rack::Session::XFile.new(incrementor)
181
+ req = Rack::MockRequest.new(app)
182
+
183
+ app.session_count.should.be.zero
184
+ cookie = req.get('/')['Set-Cookie']
185
+ app.session_count.should.equal 1
186
+ 6.times { req.get('/', set_sid(cookie)) }
187
+ app.session_count.should.equal 1
188
+ end
189
+
190
+ it 'creates six xfiles with six stateless requests' do
191
+ app = Rack::Session::XFile.new(incrementor)
192
+ req = Rack::MockRequest.new(app)
193
+
194
+ app.session_count.should.be.zero
195
+ 6.times { req.get('/') }
196
+ app.session_count.should.equal 6
197
+ end
198
+
199
+ it 'loads session data from existing xfile' do
200
+ app = Rack::Session::XFile.new(read_session)
201
+ sid = create_session(data = {'preloaded' => true}) # rack stringifies keys
202
+ res = Rack::MockRequest.new(app).get('/', set_sid(sid))
203
+ res.body.should.equal data.inspect
204
+ app.session_count.should.equal 1
205
+ end
206
+
207
+ it 'merges session data from middleware in front' do
208
+ app = Rack::Session::XFile.new(incrementor)
209
+ res = Rack::MockRequest.new(app).get('/', 'rack.session' => {foo: 'bar'})
210
+ res.body.should.match /counter/
211
+ res.body.should.match /foo/
212
+ end
213
+
214
+ it 'creates new session when xfile is nonexistent' do
215
+ app = Rack::Session::XFile.new(read_session)
216
+ res = Rack::MockRequest.new(app).get('/', set_sid('nonexistent'))
217
+ res.body.should.equal empty_session
218
+ res['Set-Cookie'].should.match TheSession
219
+ end
220
+
221
+ it 'creates new session when xfile is empty' do
222
+ app = Rack::Session::XFile.new(read_session)
223
+ sid = create_session
224
+ xfile = app.send(:session_file, sid)
225
+ File.truncate(xfile, 0)
226
+ File.size(xfile).should.be.zero
227
+
228
+ res = Rack::MockRequest.new(app).get('/', set_sid(sid))
229
+ res.body.should.equal empty_session
230
+ res['Set-Cookie'].should.be.nil # because sid already exists
231
+ app.session_count.should.equal 1
232
+ File.size(xfile).should.not.be.zero
233
+ end
234
+
235
+ it 'creates new session when xfile is corrupt' do
236
+ app = Rack::Session::XFile.new(read_session)
237
+ sid = create_session
238
+ session_file = app.send(:session_file, sid)
239
+ session_data = {'key' => 0}
240
+
241
+ # return an empty session if marshaled session data is corrupt in any way
242
+ critical_indices = [0, 3, 6] # positions in marhaled +session_data+
243
+ critical_indices.each do |i| # that are suceptible to corruption
244
+ corrupt_data = Marshal.dump(session_data).insert(i, 'FUBAR')
245
+ File.write(session_file, corrupt_data)
246
+ res = Rack::MockRequest.new(app).get('/', set_sid(sid))
247
+ res.body.should.equal empty_session
248
+ res['Set-Cookie'].should.be.nil
249
+ end
250
+
251
+ @warnings.count.should.equal critical_indices.count
252
+ @warnings[0].should.include 'incompatible marshal file format'
253
+ @warnings[1].should.include 'dump format error for symbol'
254
+ @warnings[2].should.include 'marshal data too short'
255
+ end
256
+
257
+ it 'protects against sid encoding and path traversal exploits' do
258
+ app = Rack::Session::XFile.new(read_session)
259
+
260
+ res1 = Rack::MockRequest.new(app).get('/', set_sid('%2e%2e%2f%2e%2e%2f%00'))
261
+ res1.body.should.equal empty_session
262
+ res1['Set-Cookie'].should.be.nil
263
+ app.session_count.should.be.zero
264
+
265
+ res2 = Rack::MockRequest.new(app).get('/', set_sid('../../../../etc/passwd'))
266
+ res2.body.should.equal empty_session
267
+ res2['Set-Cookie'].should.be.nil
268
+ app.session_count.should.be.zero
269
+
270
+ @warnings.size.should.equal 2
271
+ @warnings[0].should.include 'tampered sid detected'
272
+ @warnings[1].should.include 'tampered sid detected'
273
+ end
274
+
275
+ it 'returns new sid cookie with :renew option set' do
276
+ inc_cntr = Rack::Session::XFile.new(incrementor)
277
+ read_sid = Rack::Session::XFile.new(read_session_id) # doesn't inc counter
278
+ renew_sid = Rack::Session::XFile.new(renew_session) # increments counter
279
+
280
+ res = Rack::MockRequest.new(inc_cntr).get('/')
281
+ cookie = res['Set-Cookie']
282
+ res.body.should.equal counter(1)
283
+
284
+ res = Rack::MockRequest.new(read_sid).get('/', set_sid(cookie))
285
+ cookie = res['Set-Cookie'] if res['Set-Cookie']
286
+ res.body.length.should.equal session_chars
287
+ old_sid = res.body
288
+
289
+ res = Rack::MockRequest.new(renew_sid).get('/', set_sid(cookie))
290
+ cookie = res['Set-Cookie'] if res['Set-Cookie']
291
+ res.body.should.equal counter(2)
292
+
293
+ res = Rack::MockRequest.new(read_sid).get('/', set_sid(cookie))
294
+ cookie = res['Set-Cookie'] if res['Set-Cookie']
295
+ res.body.length.should.equal session_chars
296
+ new_sid = res.body
297
+
298
+ # the session id should have changed, ...
299
+ new_sid.should.not.equal old_sid
300
+
301
+ # but the session data should persist
302
+ res = Rack::MockRequest.new(inc_cntr).get('/', set_sid(cookie))
303
+ res.body.should.equal counter(3)
304
+
305
+ # and there should only be one xfile session
306
+ inc_cntr.session_count.should.equal 1
307
+ end
308
+
309
+ it 'returns no cookie and deletes xfile with :drop option set' do
310
+ app = Rack::Session::XFile.new(incrementor)
311
+ req = Rack::MockRequest.new(app)
312
+ drop = Rack::Utils::Context.new(app, drop_session)
313
+ dreq = Rack::MockRequest.new(drop)
314
+
315
+ res = req.get('/')
316
+ original_session = res['Set-Cookie'][TheSession]
317
+ res.body.should.equal counter(1)
318
+ app.session_count.should.equal 1
319
+
320
+ res = req.get('/', set_sid(original_session))
321
+ res.body.should.equal counter(2)
322
+ app.session_count.should.equal 1
323
+
324
+ res = dreq.get('/', set_sid(original_session))
325
+ res['Set-Cookie'].should.be.nil
326
+ res.body.should.equal counter(3)
327
+ app.session_count.should.be.zero
328
+
329
+ res = req.get('/', set_sid(original_session))
330
+ res['Set-Cookie'][TheSession].should.not.equal original_session
331
+ res.body.should.equal counter(1)
332
+ app.session_count.should.equal 1
333
+ end
334
+
335
+ it 'returns no cookie and updates xfile with :defer option set' do
336
+ app = Rack::Session::XFile.new(incrementor)
337
+ exp = Rack::Session::XFile.new(incrementor, expire_after:3600)
338
+ exdfr = Rack::Session::XFile.new(incrementor, expire_after:3600, defer:true)
339
+ defer = Rack::Session::XFile.new(defer_session)
340
+
341
+ res = Rack::MockRequest.new(defer).get('/')
342
+ res['Set-Cookie'].should.be.nil # normally, new sessions set cookies
343
+ res.body.should.equal counter(1) # this xfile is updated on the server,
344
+ app.session_count.should.equal 1 # but is not referenced by any client
345
+
346
+ res = Rack::MockRequest.new(app).get('/')
347
+ cookie = res['Set-Cookie']
348
+ cookie.should.not.be.nil # because new session created
349
+ res.body.should.equal counter(1)
350
+ app.session_count.should.equal 2
351
+
352
+ res = Rack::MockRequest.new(app).get('/', set_sid(cookie))
353
+ res['Set-Cookie'].should.be.nil # because sid did not change
354
+ res.body.should.equal counter(2)
355
+ app.session_count.should.equal 2
356
+
357
+ res = Rack::MockRequest.new(exp).get('/', set_sid(cookie))
358
+ cookie = res['Set-Cookie']
359
+ cookie.should.not.be.nil # because :expire_after forced update
360
+ res.body.should.equal counter(3)
361
+ app.session_count.should.equal 2
362
+
363
+ res = Rack::MockRequest.new(exdfr).get('/', set_sid(cookie))
364
+ res['Set-Cookie'].should.be.nil # because :defer overrides :expire_after
365
+ res.body.should.equal counter(4)
366
+ app.session_count.should.equal 2
367
+
368
+ res = Rack::MockRequest.new(app).get('/', set_sid(cookie))
369
+ res['Set-Cookie'].should.be.nil # because sid did not change,
370
+ res.body.should.equal counter(5) # but keeps state even after :defer
371
+ app.session_count.should.equal 2
372
+ end
373
+
374
+ it 'returns no cookie and updates xfile with :defer option set in context' do
375
+ app = Rack::Session::XFile.new(incrementor)
376
+ defer = Rack::Utils::Context.new(app, defer_session)
377
+
378
+ res = Rack::MockRequest.new(defer).get('/')
379
+ res['Set-Cookie'].should.be.nil
380
+ res.body.should.equal counter(1)
381
+ app.session_count.should.equal 1
382
+ end
383
+
384
+ it 'returns no cookie and skips session processing for filtered requests' do
385
+ human_user_agent = 'Mozilla/5.0 CoolSurf/100.50'
386
+ robot_user_agent = 'BigSpider v1.0'
387
+ robot_filter = /(bot|spider|crawler)/i
388
+ app = Rack::Session::XFile.new(incrementor, user_agent_filter: robot_filter)
389
+ req = Rack::MockRequest.new(app)
390
+
391
+ res = req.get('/', 'HTTP_USER_AGENT' => robot_user_agent)
392
+ res['Set-Cookie'].should.be.nil
393
+ app.session_count.should.be.zero
394
+
395
+ res = req.get('/', 'HTTP_USER_AGENT' => human_user_agent)
396
+ res['Set-Cookie'].should.not.be.nil
397
+ app.session_count.should.equal 1
398
+ end
399
+
400
+ it 'returns no cookie if sid is unchanged' do
401
+ app = Rack::Session::XFile.new(incrementor)
402
+ req = Rack::MockRequest.new(app)
403
+
404
+ cookie = req.get('/')['Set-Cookie']
405
+ cookie.should.match TheSession
406
+
407
+ res = req.get('/', set_sid(cookie))
408
+ res.body.should.equal counter(2)
409
+ res['Set-Cookie'].should.be.nil
410
+
411
+ res = req.get('/', set_sid(cookie))
412
+ res.body.should.equal counter(3)
413
+ res['Set-Cookie'].should.be.nil
414
+ end
415
+
416
+ it 'returns no cookie if session is not read or written' do
417
+ app = Rack::Session::XFile.new(noop)
418
+ res = Rack::MockRequest.new(app).get('/')
419
+ res['Set-Cookie'].should.be.nil
420
+ app.session_count.should.be.zero
421
+ end
422
+
423
+ it 'returns no cookie if previous session is not read or written' do
424
+ app = Rack::Session::XFile.new(noop)
425
+ res = Rack::MockRequest.new(app).get('/', set_sid(create_session))
426
+ res['Set-Cookie'].should.be.nil
427
+ app.session_count.should.equal 1
428
+ end
429
+
430
+ it 'returns no cookie if session is read but not written' do
431
+ app = Rack::Session::XFile.new(read_session)
432
+ res = Rack::MockRequest.new(app).get('/')
433
+ res['Set-Cookie'].should.be.nil
434
+ app.session_count.should.be.zero
435
+ end
436
+
437
+ it 'returns no cookie if previous session is read but not written' do
438
+ app = Rack::Session::XFile.new(read_session)
439
+ res = Rack::MockRequest.new(app).get('/', set_sid(create_session))
440
+ res['Set-Cookie'].should.be.nil
441
+ app.session_count.should.equal 1
442
+ end
443
+
444
+ it 'returns no cookie if session is not written and was not '\
445
+ 'previously created, regardless of the :expire_after option' do
446
+ app = Rack::Session::XFile.new(read_session, expire_after: 3600)
447
+ res = Rack::MockRequest.new(app).get('/')
448
+ res['Set-Cookie'].should.be.nil
449
+ app.session_count.should.be.zero
450
+ end
451
+
452
+ it 'returns cookie if previous session is not read or written '\
453
+ 'but :expire_after option is set' do
454
+ app = Rack::Session::XFile.new(noop, expire_after: 3600)
455
+ res = Rack::MockRequest.new(app).get('/', set_sid(create_session))
456
+ res['Set-Cookie'].should.not.be.nil
457
+ app.session_count.should.equal 1
458
+ end
459
+
460
+ FileUtils.rm_rf(spec_tmp_dir)
461
+ end
data/xfile.gemspec ADDED
@@ -0,0 +1,19 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'rack-session-xfile'
3
+ s.version = File.read('lib/rack/session/xfile.rb')[/VERSION = '(.*)'/, 1]
4
+ s.author = 'Clint Pachl'
5
+ s.email = 'pachl@ecentryx.com'
6
+ s.homepage = 'http://ecentryx.com/gems/rack-session-xfile'
7
+ s.license = 'ISC'
8
+ s.summary = 'File-based session management with eXclusive R/W locking.'
9
+ s.description = <<-EOS
10
+ A simple and reliable file-based session manager with eXclusive R/W locking.
11
+ Session data is updated atomically using mutex and file locks, making it
12
+ suitable for multi-threaded and multi-process applications.
13
+ EOS
14
+ s.files = Dir['Rakefile', 'README*', '*.gemspec', 'lib/**/*.rb', 'spec/*']
15
+ s.test_files = Dir['spec/spec_*.rb']
16
+ s.required_ruby_version = '>= 1.9.2'
17
+ s.add_runtime_dependency 'rack', '~> 1.5'
18
+ s.add_development_dependency 'bacon', '~> 1.2'
19
+ end
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rack-session-xfile
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.10.1
5
+ platform: ruby
6
+ authors:
7
+ - Clint Pachl
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-01-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rack
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.5'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.5'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bacon
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.2'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.2'
41
+ description: |
42
+ A simple and reliable file-based session manager with eXclusive R/W locking.
43
+ Session data is updated atomically using mutex and file locks, making it
44
+ suitable for multi-threaded and multi-process applications.
45
+ email: pachl@ecentryx.com
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - README.rdoc
51
+ - Rakefile
52
+ - lib/rack-session-xfile.rb
53
+ - lib/rack/session/xfile.rb
54
+ - spec/spec_xfile.rb
55
+ - xfile.gemspec
56
+ homepage: http://ecentryx.com/gems/rack-session-xfile
57
+ licenses:
58
+ - ISC
59
+ metadata: {}
60
+ post_install_message:
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 1.9.2
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubyforge_project:
76
+ rubygems_version: 2.2.2
77
+ signing_key:
78
+ specification_version: 4
79
+ summary: File-based session management with eXclusive R/W locking.
80
+ test_files:
81
+ - spec/spec_xfile.rb