rack-session-xfile 0.10.1

Sign up to get free protection for your applications and to get access to all the features.
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