diff --git a/.contributors.yaml b/.contributors.yaml new file mode 100644 index 0000000..536df36 --- /dev/null +++ b/.contributors.yaml @@ -0,0 +1,792 @@ +- time: 2022-02-15T18:41:48+13:00 + author: + name: Samuel Williams + email: samuel.williams@oriontransfer.co.nz +- time: 2021-05-13T10:35:53-04:00 + author: + name: Michael Coyne + email: mikeycgto@gmail.com +- time: 2022-02-03T13:20:12-08:00 + author: + name: Jeremy Evans + email: code@jeremyevans.net +- time: 2022-01-26T17:55:30-08:00 + author: + name: Jeremy Evans + email: code@jeremyevans.net +- time: 2020-03-11T08:06:43-07:00 + author: + name: Jeremy Evans + email: code@jeremyevans.net +- time: 2020-07-14T10:41:24-07:00 + author: + name: Jeremy Evans + email: code@jeremyevans.net +- time: 2020-09-16T16:45:22-04:00 + author: + name: Alec Clarke + email: alec.clarke@clio.com +- time: 2020-06-28T12:51:51-04:00 + author: + name: Bart de Water + email: bartdewater@gmail.com +- time: 2020-05-27T19:41:59-07:00 + author: + name: Jeremy Evans + email: code@jeremyevans.net +- time: 2020-02-24T14:34:28+09:00 + author: + name: Yudai Suzuki + email: 3280467rec@gmail.com +- time: 2020-02-10T17:33:15+09:00 + author: + name: Ryuta Kamizono + email: kamipo@gmail.com +- time: 2020-02-01T03:16:31+00:00 + author: + name: Alex Speller + email: alex@alexspeller.com +- time: 2020-01-27T14:30:11-08:00 + author: + name: Jeremy Evans + email: code@jeremyevans.net +- time: 2020-01-27T12:33:40-08:00 + author: + name: Jeremy Evans + email: code@jeremyevans.net +- time: 2020-01-16T12:44:12-08:00 + author: + name: Jeremy Evans + email: code@jeremyevans.net +- time: 2020-01-11T22:48:15+01:00 + author: + name: Pavel Rosicky + email: pavel.rosicky@easy.cz +- time: 2019-12-16T10:36:52+02:00 + author: + name: Oleh Demianiuk + email: oleh.demianiuk@managebac.com +- time: 2020-01-10T16:25:47-08:00 + author: + name: Jeremy Evans + email: code@jeremyevans.net +- time: 2020-01-11T10:58:12+13:00 + author: + name: Samuel Williams + email: samuel.williams@oriontransfer.co.nz +- time: 2020-01-09T12:55:06-08:00 + author: + name: Jeremy Evans + email: code@jeremyevans.net +- time: 2020-01-09T11:21:43+13:00 + author: + name: Samuel Williams + email: samuel.williams@oriontransfer.co.nz +- time: 2019-12-18T10:07:23-08:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2019-11-29T01:10:32+02:00 + author: + name: Dima Fatko + email: fatkodima123@gmail.com +- time: 2019-10-19T00:54:35+02:00 + author: + name: Pavel Rosicky + email: pavel.rosicky@easy.cz +- time: 2019-10-21T16:39:00-04:00 + author: + name: Rafael Mendonça França + email: rafael@franca.dev +- time: 2019-10-17T19:00:09+07:00 + author: + name: Adrian Setyadi + email: a.styd@yahoo.com +- time: 2019-10-16T14:07:36-04:00 + author: + name: Rafael Mendonça França + email: rafael@franca.dev +- time: 2019-10-09T19:14:08-04:00 + author: + name: Rafael Mendonça França + email: rafael@franca.dev +- time: 2019-10-09T18:06:23-04:00 + author: + name: Rafael Mendonça França + email: rafael@franca.dev +- time: 2019-10-09T17:50:45-04:00 + author: + name: Rafael Mendonça França + email: rafael@franca.dev +- time: 2019-10-09T23:16:00+07:00 + author: + name: Adrian Setyadi + email: a.styd@yahoo.com +- time: 2019-10-09T13:42:48+07:00 + author: + name: Adrian Setyadi + email: a.styd@yahoo.com +- time: 2019-09-30T06:42:41+07:00 + author: + name: Adrian Setyadi + email: a.styd@yahoo.com +- time: 2019-08-13T17:16:23-04:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2019-08-13T16:48:41-04:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2019-08-13T16:45:04-04:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2019-08-13T16:38:01-04:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2019-08-13T16:31:06-04:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2019-08-13T16:20:32-04:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2019-08-13T15:43:58-04:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2019-08-13T15:32:20-04:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2019-07-27T14:57:42+01:00 + author: + name: Frederick Cheung + email: frederick.cheung@gmail.com +- time: 2019-04-17T18:52:13+02:00 + author: + name: Krzysztof Rybka + email: krzysztof.rybka@gmail.com +- time: 2018-04-17T17:50:18+09:00 + author: + name: Yoshiyuki Hirano + email: yhirano@me.com +- time: 2018-04-17T02:41:39+09:00 + author: + name: Yoshiyuki Hirano + email: yhirano@me.com +- time: 2018-04-13T21:48:52-07:00 + author: + name: Dillon Welch + email: daw0328@gmail.com +- time: 2017-05-12T14:00:36-07:00 + author: + name: Jordan Raine + email: jnraine@gmail.com +- time: 2016-12-06T15:45:10+08:00 + author: + name: Jian Weihang + email: tonytonyjan@gmail.com +- time: 2016-10-24T14:47:14+02:00 + author: + name: Yann Vanhalewyn + email: yann.vanhalewyn@gmail.com +- time: 2016-09-09T21:59:10-04:00 + author: + name: Kir Shatrov + email: kirs@users.noreply.github.com +- time: 2015-12-10T13:03:35+01:00 + author: + name: Michael Sauter + email: michael.sauter@experteer.com +- time: 2015-10-29T13:03:04+09:00 + author: + name: Yuichiro Kaneko + email: spiketeika@gmail.com +- time: 2015-10-11T02:21:42+02:00 + author: + name: Francesco Rodríguez + email: frodsan@me.com +- time: 2015-09-25T12:11:05-07:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2015-09-25T11:24:59-07:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2015-09-24T16:00:40-07:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2015-09-24T15:53:14-07:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2015-09-13T10:59:04-07:00 + author: + name: David Runger + email: daverunger@gmail.com +- time: 2015-09-05T11:19:00-07:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2015-09-04T18:57:29-07:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2015-09-04T16:11:16-07:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2015-09-03T07:15:12+02:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2015-08-27T10:22:02-07:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2015-08-22T17:51:36-07:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2015-08-22T16:45:38-07:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2015-08-22T21:11:38+02:00 + author: + name: deepj + email: deepjungle.maca@gmail.com +- time: 2015-06-18T17:54:07-07:00 + author: + name: Doug McInnes + email: doug@dougmcinnes.com +- time: 2015-06-13T21:37:19-03:00 + author: + name: Santiago Pastorino + email: santiago@wyeworks.com +- time: 2015-06-12T14:17:35-07:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2015-06-12T23:10:39+02:00 + author: + name: deepj + email: deepjungle.maca@gmail.com +- time: 2015-06-11T19:05:19-07:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2014-07-08T12:18:40+02:00 + author: + name: Michal Bryxí + email: michal.bryxi@gmail.com +- time: 2014-06-12T23:37:19+02:00 + author: + name: Michal Bryxí + email: michal.bryxi@gmail.com +- time: 2014-01-10T13:00:14-02:00 + author: + name: Santiago Pastorino + email: santiago@wyeworks.com +- time: 2013-12-06T01:10:37-02:00 + author: + name: Santiago Pastorino + email: santiago@wyeworks.com +- time: 2013-12-05T20:32:08-02:00 + author: + name: Santiago Pastorino + email: santiago@wyeworks.com +- time: 2013-12-05T11:49:34-08:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2013-09-16T19:12:28+02:00 + author: + name: Charles Hornberger + email: charles.hornberger@gmail.com +- time: 2013-09-16T09:23:02+02:00 + author: + name: Charles Hornberger + email: charles.hornberger@gmail.com +- time: 2013-05-24T01:47:29+05:30 + author: + name: Vipul A M + email: vipulnsward@gmail.com +- time: 2013-04-29T11:55:32-07:00 + author: + name: James Tucker + email: jftucker@gmail.com +- time: 2013-04-13T11:22:33+05:30 + author: + name: Vipul A M + email: vipulnsward@gmail.com +- time: 2013-02-07T14:47:10-08:00 + author: + name: James Tucker + email: jftucker@gmail.com +- time: 2013-02-06T14:13:10-08:00 + author: + name: James Tucker + email: jftucker@gmail.com +- time: 2013-02-05T17:43:10-08:00 + author: + name: James Tucker + email: jftucker@gmail.com +- time: 2013-01-30T02:56:58-08:00 + author: + name: Postmodern + email: postmodern.mod3@gmail.com +- time: 2013-01-29T12:01:44-02:00 + author: + name: Santiago Pastorino + email: santiago@wyeworks.com +- time: 2013-01-28T13:44:57-08:00 + author: + name: James Tucker + email: jftucker@gmail.com +- time: 2013-01-28T13:30:52-08:00 + author: + name: James Tucker + email: jftucker@gmail.com +- time: 2013-01-24T21:02:23-08:00 + author: + name: Andrew Cole + email: aocole@gmail.com +- time: 2013-01-11T01:57:54-02:00 + author: + name: Santiago Pastorino + email: santiago@wyeworks.com +- time: 2013-01-10T12:03:34-02:00 + author: + name: Santiago Pastorino + email: santiago@wyeworks.com +- time: 2013-01-10T00:53:40-02:00 + author: + name: Santiago Pastorino + email: santiago@wyeworks.com +- time: 2013-01-10T00:44:02-02:00 + author: + name: Santiago Pastorino + email: santiago@wyeworks.com +- time: 2013-01-09T00:59:10-02:00 + author: + name: Santiago Pastorino + email: santiago@wyeworks.com +- time: 2012-11-03T10:30:31-07:00 + author: + name: James Tucker + email: raggi@google.com +- time: 2012-11-03T10:29:00-07:00 + author: + name: James Tucker + email: jftucker@gmail.com +- time: 2012-11-02T09:48:52-07:00 + author: + name: James Tucker + email: raggi@google.com +- time: 2012-10-15T22:22:22-02:00 + author: + name: Santiago Pastorino + email: santiago@wyeworks.com +- time: 2012-07-19T12:46:50-07:00 + author: + name: Jamie Macey + email: jamie@tracefunc.com +- time: 2012-03-18T19:20:54-07:00 + author: + name: James Tucker + email: jftucker@gmail.com +- time: 2012-03-18T18:36:31-07:00 + author: + name: James Tucker + email: jftucker@gmail.com +- time: 2012-03-18T02:50:05+04:00 + author: + name: Ravil Bayramgalin + email: brainopia@evilmartians.com +- time: 2012-02-18T16:19:10+04:00 + author: + name: Ravil Bayramgalin + email: brainopia@evilmartians.com +- time: 2012-02-18T16:15:45+04:00 + author: + name: Ravil Bayramgalin + email: brainopia@evilmartians.com +- time: 2012-02-18T15:44:06+04:00 + author: + name: Ravil Bayramgalin + email: brainopia@evilmartians.com +- time: 2012-02-18T13:22:43+04:00 + author: + name: Ravil Bayramgalin + email: brainopia@evilmartians.com +- time: 2012-02-18T13:21:02+04:00 + author: + name: Ravil Bayramgalin + email: brainopia@evilmartians.com +- time: 2012-02-18T12:47:53+04:00 + author: + name: Ravil Bayramgalin + email: brainopia@evilmartians.com +- time: 2012-02-04T01:44:04+04:00 + author: + name: Ravil Bayramgalin + email: brainopia@evilmartians.com +- time: 2012-02-02T04:23:20-08:00 + author: + name: Konstantin Haase + email: k.haase@finn.de +- time: 2012-01-30T14:23:45-08:00 + author: + name: Timothy Elliott + email: tle@holymonkey.com +- time: 2012-01-27T15:08:08+04:00 + author: + name: Ravil Bayramgalin + email: brainopia@evilmartians.com +- time: 2012-01-27T15:07:08+04:00 + author: + name: Ravil Bayramgalin + email: brainopia@evilmartians.com +- time: 2012-01-27T14:56:16+04:00 + author: + name: Ravil Bayramgalin + email: brainopia@evilmartians.com +- time: 2012-01-27T12:24:02+04:00 + author: + name: Ravil Bayramgalin + email: brainopia@evilmartians.com +- time: 2012-01-27T12:19:42+04:00 + author: + name: Ravil Bayramgalin + email: brainopia@evilmartians.com +- time: 2012-01-21T15:48:16-08:00 + author: + name: James Tucker + email: jftucker@gmail.com +- time: 2012-01-18T19:05:36+04:00 + author: + name: Ravil Bayramgalin + email: brainopia@evilmartians.com +- time: 2012-01-18T19:00:20+04:00 + author: + name: Ravil Bayramgalin + email: brainopia@evilmartians.com +- time: 2012-01-18T18:23:24+04:00 + author: + name: Ravil Bayramgalin + email: brainopia@evilmartians.com +- time: 2012-01-16T17:08:42+11:00 + author: + name: Yun Huang Yong + email: gumby@mooh.org +- time: 2012-01-07T14:51:59-08:00 + author: + name: James Tucker + email: jftucker@gmail.com +- time: 2011-12-27T19:39:42+01:00 + author: + name: José Valim + email: jose.valim@gmail.com +- time: 2011-12-17T14:41:39-08:00 + author: + name: James Tucker + email: jftucker@gmail.com +- time: 2011-12-17T13:52:22-08:00 + author: + name: James Tucker + email: jftucker@gmail.com +- time: 2011-12-16T19:03:58-08:00 + author: + name: John Manoogian III + email: jm3@jm3.net +- time: 2011-12-04T15:42:32-08:00 + author: + name: James Tucker + email: jftucker@gmail.com +- time: 2011-11-30T12:26:44+01:00 + author: + name: José Valim + email: jose.valim@gmail.com +- time: 2011-11-12T19:09:50-08:00 + author: + name: Will Leinweber + email: will@bitfission.com +- time: 2011-07-16T14:54:43+02:00 + author: + name: Konstantin Haase + email: konstantin.mailinglists@googlemail.com +- time: 2011-06-09T09:14:04+02:00 + author: + name: Konstantin Haase + email: konstantin.mailinglists@googlemail.com +- time: 2011-05-31T11:15:42+02:00 + author: + name: Konstantin Haase + email: konstantin.mailinglists@googlemail.com +- time: 2011-05-08T11:38:12-07:00 + author: + name: James Tucker + email: jftucker@gmail.com +- time: 2011-05-08T11:37:57-07:00 + author: + name: James Tucker + email: jftucker@gmail.com +- time: 2011-05-04T20:36:37+02:00 + author: + name: José Valim + email: jose.valim@gmail.com +- time: 2011-05-04T12:04:45+02:00 + author: + name: Konstantin Haase + email: konstantin.mailinglists@googlemail.com +- time: 2011-05-04T11:19:35+02:00 + author: + name: Konstantin Haase + email: konstantin.mailinglists@googlemail.com +- time: 2011-05-03T14:50:56+02:00 + author: + name: Konstantin Haase + email: konstantin.mailinglists@googlemail.com +- time: 2011-01-24T19:11:37-05:00 + author: + name: Max Cantor + email: max@maxcantor.net +- time: 2010-12-18T06:01:13+08:00 + author: + name: Aaron Patterson + email: aaron.patterson@gmail.com +- time: 2010-10-03T14:47:55-07:00 + author: + name: José Valim + email: jose.valim@gmail.com +- time: 2010-10-03T17:32:02-03:00 + author: + name: James Tucker + email: jftucker@gmail.com +- time: 2010-10-03T19:30:20+02:00 + author: + name: José Valim + email: jose.valim@gmail.com +- time: 2010-09-28T14:16:49+02:00 + author: + name: José Valim + email: jose.valim@gmail.com +- time: 2010-09-19T23:26:16+02:00 + author: + name: José Valim + email: jose.valim@gmail.com +- time: 2010-07-19T12:41:56+02:00 + author: + name: José Valim + email: jose.valim@gmail.com +- time: 2010-07-19T11:09:46+02:00 + author: + name: José Valim + email: jose.valim@gmail.com +- time: 2010-05-05T11:54:07-06:00 + author: + name: Simon Chiang + email: simon.chiang@pinnacol.com +- time: 2009-12-03T13:07:46-08:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2009-11-22T20:15:28-08:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2009-11-22T18:08:53-08:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2009-11-24T20:35:04+08:00 + author: + name: Mickaël Riga + email: mig@mypeplum.com +- time: 2009-08-05T11:01:43-05:00 + author: + name: Joshua Peek + email: josh@joshpeek.com +- time: 2009-08-03T16:03:30-05:00 + author: + name: Joshua Peek + email: josh@joshpeek.com +- time: 2009-08-03T12:02:37-05:00 + author: + name: Joshua Peek + email: josh@joshpeek.com +- time: 2009-01-16T14:53:58-08:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2009-01-10T11:18:01-08:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2009-01-14T12:38:51-06:00 + author: + name: Joshua Peek + email: josh@joshpeek.com +- time: 2009-01-07T21:15:44-08:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-11-19T22:07:38+01:00 + author: + name: Daniel Roethlisberger + email: daniel@roe.ch +- time: 2008-11-19T22:23:30+01:00 + author: + name: Daniel Roethlisberger + email: daniel@roe.ch +- time: 2008-09-30T19:18:35+02:00 + author: + name: Leah Neukirchen + email: chneukirchen@gmail.com +- time: 2008-08-07T03:32:31-07:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-06-28T14:37:09-07:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-06-03T21:55:55-07:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-04-26T21:37:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-29T04:32:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-25T11:15:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-19T11:43:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-17T15:59:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-17T11:19:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-17T09:12:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-16T14:30:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-16T11:55:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-16T09:01:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-16T08:33:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-16T08:26:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-16T08:23:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-16T08:21:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-16T04:59:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-14T23:57:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-11T12:02:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-11T11:59:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-11T11:56:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-11T11:52:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-11T11:29:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-11T11:25:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2008-03-11T11:11:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2007-12-31T18:34:00+00:00 + author: + name: Leah Neukirchen + email: chneukirchen@gmail.com +- time: 2007-11-18T19:20:00+00:00 + author: + name: Leah Neukirchen + email: chneukirchen@gmail.com +- time: 2007-11-18T05:08:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2007-08-28T23:14:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2007-08-11T17:28:00+00:00 + author: + name: Scytrin dai Kinthra + email: scytrin@gmail.com +- time: 2007-05-16T14:53:00+00:00 + author: + name: Leah Neukirchen + email: chneukirchen@gmail.com +- time: 2007-03-12T16:04:00+00:00 + author: + name: Leah Neukirchen + email: chneukirchen@gmail.com +- time: 2007-03-10T14:38:00+00:00 + author: + name: Leah Neukirchen + email: chneukirchen@gmail.com +- time: 2007-03-09T23:40:00+00:00 + author: + name: Leah Neukirchen + email: chneukirchen@gmail.com diff --git a/.github/workflows/test-external.yaml b/.github/workflows/test-external.yaml index e6a5238..dde01f0 100644 --- a/.github/workflows/test-external.yaml +++ b/.github/workflows/test-external.yaml @@ -32,9 +32,6 @@ jobs: ruby-version: ${{matrix.ruby}} bundler-cache: true - - name: Installing packages - run: sudo apt-get install libfcgi-dev libmemcached-dev - - name: Run tests timeout-minutes: 10 run: bundle exec bake test:external diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5c765b6..ed734f6 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -48,4 +48,4 @@ jobs: - name: Run tests timeout-minutes: 10 - run: bundle exec bake test + run: bundle exec rake test diff --git a/config/external.yaml b/config/external.yaml index dc09707..1cd7f57 100644 --- a/config/external.yaml +++ b/config/external.yaml @@ -1,4 +1,3 @@ -rack-2.2: +rack: url: https://github.com/rack/rack.git command: bundle exec rake test - branch: 2-2-stable diff --git a/lib/rack/session.rb b/lib/rack/session.rb new file mode 100644 index 0000000..fd3fd1f --- /dev/null +++ b/lib/rack/session.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2023, by Samuel Williams. +# Copyright, 2022, by Jeremy Evans. + +module Rack + module Session + autoload :Cookie, "rack/session/cookie" + autoload :Pool, "rack/session/pool" + autoload :Memcache, "rack/session/memcache" + end +end diff --git a/lib/rack/session/abstract/id.rb b/lib/rack/session/abstract/id.rb new file mode 100644 index 0000000..a7d7747 --- /dev/null +++ b/lib/rack/session/abstract/id.rb @@ -0,0 +1,533 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2023, by Samuel Williams. +# Copyright, 2022, by Jeremy Evans. + +require 'time' +require 'securerandom' +require 'digest/sha2' + +require 'rack/constants' +require 'rack/request' +require 'rack/response' + +require_relative '../constants' + +module Rack + + module Session + + class SessionId + ID_VERSION = 2 + + attr_reader :public_id + + def initialize(public_id) + @public_id = public_id + end + + def private_id + "#{ID_VERSION}::#{hash_sid(public_id)}" + end + + alias :cookie_value :public_id + alias :to_s :public_id + + def empty?; false; end + def inspect; public_id.inspect; end + + private + + def hash_sid(sid) + Digest::SHA256.hexdigest(sid) + end + end + + module Abstract + # SessionHash is responsible to lazily load the session from store. + + class SessionHash + include Enumerable + attr_writer :id + + Unspecified = Object.new + + def self.find(req) + req.get_header RACK_SESSION + end + + def self.set(req, session) + req.set_header RACK_SESSION, session + end + + def self.set_options(req, options) + req.set_header RACK_SESSION_OPTIONS, options.dup + end + + def initialize(store, req) + @store = store + @req = req + @loaded = false + end + + def id + return @id if @loaded or instance_variable_defined?(:@id) + @id = @store.send(:extract_session_id, @req) + end + + def options + @req.session_options + end + + def each(&block) + load_for_read! + @data.each(&block) + end + + def [](key) + load_for_read! + @data[key.to_s] + end + + def dig(key, *keys) + load_for_read! + @data.dig(key.to_s, *keys) + end + + def fetch(key, default = Unspecified, &block) + load_for_read! + if default == Unspecified + @data.fetch(key.to_s, &block) + else + @data.fetch(key.to_s, default, &block) + end + end + + def has_key?(key) + load_for_read! + @data.has_key?(key.to_s) + end + alias :key? :has_key? + alias :include? :has_key? + + def []=(key, value) + load_for_write! + @data[key.to_s] = value + end + alias :store :[]= + + def clear + load_for_write! + @data.clear + end + + def destroy + clear + @id = @store.send(:delete_session, @req, id, options) + end + + def to_hash + load_for_read! + @data.dup + end + + def update(hash) + load_for_write! + @data.update(stringify_keys(hash)) + end + alias :merge! :update + + def replace(hash) + load_for_write! + @data.replace(stringify_keys(hash)) + end + + def delete(key) + load_for_write! + @data.delete(key.to_s) + end + + def inspect + if loaded? + @data.inspect + else + "#<#{self.class}:0x#{self.object_id.to_s(16)} not yet loaded>" + end + end + + def exists? + return @exists if instance_variable_defined?(:@exists) + @data = {} + @exists = @store.send(:session_exists?, @req) + end + + def loaded? + @loaded + end + + def empty? + load_for_read! + @data.empty? + end + + def keys + load_for_read! + @data.keys + end + + def values + load_for_read! + @data.values + end + + private + + def load_for_read! + load! if !loaded? && exists? + end + + def load_for_write! + load! unless loaded? + end + + def load! + @id, session = @store.send(:load_session, @req) + @data = stringify_keys(session) + @loaded = true + end + + def stringify_keys(other) + # Use transform_keys after dropping Ruby 2.4 support + hash = {} + other.to_hash.each do |key, value| + hash[key.to_s] = value + end + hash + end + end + + # ID sets up a basic framework for implementing an id based sessioning + # service. Cookies sent to the client for maintaining sessions will only + # contain an id reference. Only #find_session, #write_session and + # #delete_session are required to be overwritten. + # + # All parameters are optional. + # * :key determines the name of the cookie, by default it is + # 'rack.session' + # * :path, :domain, :expire_after, :secure, :httponly, and :same_site set + # the related cookie options as by Rack::Response#set_cookie + # * :skip will not a set a cookie in the response nor update the session state + # * :defer will not set a cookie in the response but still update the session + # state if it is used with a backend + # * :renew (implementation dependent) will prompt the generation of a new + # session id, and migration of data to be referenced at the new id. If + # :defer is set, it will be overridden and the cookie will be set. + # * :sidbits sets the number of bits in length that a generated session + # id will be. + # + # These options can be set on a per request basis, at the location of + # env['rack.session.options']. Additionally the id of the + # session can be found within the options hash at the key :id. It is + # highly not recommended to change its value. + # + # Is Rack::Utils::Context compatible. + # + # Not included by default; you must require 'rack/session/abstract/id' + # to use. + + class Persisted + DEFAULT_OPTIONS = { + key: RACK_SESSION, + path: '/', + domain: nil, + expire_after: nil, + secure: false, + httponly: true, + defer: false, + renew: false, + sidbits: 128, + cookie_only: true, + secure_random: ::SecureRandom + }.freeze + + attr_reader :key, :default_options, :sid_secure, :same_site + + def initialize(app, options = {}) + @app = app + @default_options = self.class::DEFAULT_OPTIONS.merge(options) + @key = @default_options.delete(:key) + @cookie_only = @default_options.delete(:cookie_only) + @same_site = @default_options.delete(:same_site) + initialize_sid + end + + def call(env) + context(env) + end + + def context(env, app = @app) + req = make_request env + prepare_session(req) + status, headers, body = app.call(req.env) + res = Rack::Response::Raw.new status, headers + commit_session(req, res) + [status, headers, body] + end + + private + + def make_request(env) + Rack::Request.new env + end + + def initialize_sid + @sidbits = @default_options[:sidbits] + @sid_secure = @default_options[:secure_random] + @sid_length = @sidbits / 4 + end + + # Generate a new session id using Ruby #rand. The size of the + # session id is controlled by the :sidbits option. + # Monkey patch this to use custom methods for session id generation. + + def generate_sid(secure = @sid_secure) + if secure + secure.hex(@sid_length) + else + "%0#{@sid_length}x" % Kernel.rand(2**@sidbits - 1) + end + rescue NotImplementedError + generate_sid(false) + end + + # Sets the lazy session at 'rack.session' and places options and session + # metadata into 'rack.session.options'. + + def prepare_session(req) + session_was = req.get_header RACK_SESSION + session = session_class.new(self, req) + req.set_header RACK_SESSION, session + req.set_header RACK_SESSION_OPTIONS, @default_options.dup + session.merge! session_was if session_was + end + + # Extracts the session id from provided cookies and passes it and the + # environment to #find_session. + + def load_session(req) + sid = current_session_id(req) + sid, session = find_session(req, sid) + [sid, session || {}] + end + + # Extract session id from request object. + + def extract_session_id(request) + sid = request.cookies[@key] + sid ||= request.params[@key] unless @cookie_only + sid + end + + # Returns the current session id from the SessionHash. + + def current_session_id(req) + req.get_header(RACK_SESSION).id + end + + # Check if the session exists or not. + + def session_exists?(req) + value = current_session_id(req) + value && !value.empty? + end + + # Session should be committed if it was loaded, any of specific options like :renew, :drop + # or :expire_after was given and the security permissions match. Skips if skip is given. + + def commit_session?(req, session, options) + if options[:skip] + false + else + has_session = loaded_session?(session) || forced_session_update?(session, options) + has_session && security_matches?(req, options) + end + end + + def loaded_session?(session) + !session.is_a?(session_class) || session.loaded? + end + + def forced_session_update?(session, options) + force_options?(options) && session && !session.empty? + end + + def force_options?(options) + options.values_at(:max_age, :renew, :drop, :defer, :expire_after).any? + end + + def security_matches?(request, options) + return true unless options[:secure] + request.ssl? + end + + # Acquires the session from the environment and the session id from + # the session options and passes them to #write_session. If successful + # and the :defer option is not true, a cookie will be added to the + # response with the session's id. + + def commit_session(req, res) + session = req.get_header RACK_SESSION + options = session.options + + if options[:drop] || options[:renew] + session_id = delete_session(req, session.id || generate_sid, options) + return unless session_id + end + + return unless commit_session?(req, session, options) + + session.send(:load!) unless loaded_session?(session) + session_id ||= session.id + session_data = session.to_hash.delete_if { |k, v| v.nil? } + + if not data = write_session(req, session_id, session_data, options) + req.get_header(RACK_ERRORS).puts("Warning! #{self.class.name} failed to save session. Content dropped.") + elsif options[:defer] and not options[:renew] + req.get_header(RACK_ERRORS).puts("Deferring cookie for #{session_id}") if $VERBOSE + else + cookie = Hash.new + cookie[:value] = cookie_value(data) + cookie[:expires] = Time.now + options[:expire_after] if options[:expire_after] + cookie[:expires] = Time.now + options[:max_age] if options[:max_age] + + if @same_site.respond_to? :call + cookie[:same_site] = @same_site.call(req, res) + else + cookie[:same_site] = @same_site + end + set_cookie(req, res, cookie.merge!(options)) + end + end + public :commit_session + + def cookie_value(data) + data + end + + # Sets the cookie back to the client with session id. We skip the cookie + # setting if the value didn't change (sid is the same) or expires was given. + + def set_cookie(request, response, cookie) + if request.cookies[@key] != cookie[:value] || cookie[:expires] + response.set_cookie(@key, cookie) + end + end + + # Allow subclasses to prepare_session for different Session classes + + def session_class + SessionHash + end + + # All thread safety and session retrieval procedures should occur here. + # Should return [session_id, session]. + # If nil is provided as the session id, generation of a new valid id + # should occur within. + + def find_session(env, sid) + raise '#find_session not implemented.' + end + + # All thread safety and session storage procedures should occur here. + # Must return the session id if the session was saved successfully, or + # false if the session could not be saved. + + def write_session(req, sid, session, options) + raise '#write_session not implemented.' + end + + # All thread safety and session destroy procedures should occur here. + # Should return a new session id or nil if options[:drop] + + def delete_session(req, sid, options) + raise '#delete_session not implemented' + end + end + + class PersistedSecure < Persisted + class SecureSessionHash < SessionHash + def [](key) + if key == "session_id" + load_for_read! + case id + when SessionId + id.public_id + else + id + end + else + super + end + end + end + + def generate_sid(*) + public_id = super + + SessionId.new(public_id) + end + + def extract_session_id(*) + public_id = super + public_id && SessionId.new(public_id) + end + + private + + def session_class + SecureSessionHash + end + + def cookie_value(data) + data.cookie_value + end + end + + class ID < Persisted + def self.inherited(klass) + k = klass.ancestors.find { |kl| kl.respond_to?(:superclass) && kl.superclass == ID } + unless k.instance_variable_defined?(:"@_rack_warned") + warn "#{klass} is inheriting from #{ID}. Inheriting from #{ID} is deprecated, please inherit from #{Persisted} instead" if $VERBOSE + k.instance_variable_set(:"@_rack_warned", true) + end + super + end + + # All thread safety and session retrieval procedures should occur here. + # Should return [session_id, session]. + # If nil is provided as the session id, generation of a new valid id + # should occur within. + + def find_session(req, sid) + get_session req.env, sid + end + + # All thread safety and session storage procedures should occur here. + # Must return the session id if the session was saved successfully, or + # false if the session could not be saved. + + def write_session(req, sid, session, options) + set_session req.env, sid, session, options + end + + # All thread safety and session destroy procedures should occur here. + # Should return a new session id or nil if options[:drop] + + def delete_session(req, sid, options) + destroy_session req.env, sid, options + end + end + end + end +end diff --git a/lib/rack/session/constants.rb b/lib/rack/session/constants.rb new file mode 100644 index 0000000..d40d9a2 --- /dev/null +++ b/lib/rack/session/constants.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2023, by Samuel Williams. +# Copyright, 2022, by Jeremy Evans. + +module Rack + module Session + RACK_SESSION = 'rack.session' + RACK_SESSION_OPTIONS = 'rack.session.options' + RACK_SESSION_UNPACKED_COOKIE_DATA = 'rack.session.unpacked_cookie_data' + end +end diff --git a/lib/rack/session/cookie.rb b/lib/rack/session/cookie.rb new file mode 100644 index 0000000..830a4e3 --- /dev/null +++ b/lib/rack/session/cookie.rb @@ -0,0 +1,313 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2023, by Samuel Williams. +# Copyright, 2022, by Jeremy Evans. +# Copyright, 2022, by Jon Dufresne. + +require 'openssl' +require 'zlib' +require 'json' +require 'base64' +require 'delegate' + +require 'rack/constants' +require 'rack/utils' + +require_relative 'abstract/id' +require_relative 'encryptor' +require_relative 'constants' + +module Rack + + module Session + + # Rack::Session::Cookie provides simple cookie based session management. + # By default, the session is a Ruby Hash that is serialized and encoded as + # a cookie set to :key (default: rack.session). + # + # This middleware accepts a :secrets option which enables encryption of + # session cookies. This option should be one or more random "secret keys" + # that are each at least 64 bytes in length. Multiple secret keys can be + # supplied in an Array, which is useful when rotating secrets. + # + # Several options are also accepted that are passed to Rack::Session::Encryptor. + # These options include: + # * :serialize_json + # Use JSON for message serialization instead of Marshal. This can be + # viewed as a security enhancement. + # * :gzip_over + # For message data over this many bytes, compress it with the deflate + # algorithm. + # + # Refer to Rack::Session::Encryptor for more details on these options. + # + # Prior to version TODO, the session hash was stored as base64 encoded + # marshalled data. When a :secret option was supplied, the integrity of the + # encoded data was protected with HMAC-SHA1. This functionality is still + # supported using a set of a legacy options. + # + # Lastly, a :coder option is also accepted. When used, both encryption and + # the legacy HMAC will be skipped. This option could create security issues + # in your application! + # + # Example: + # + # use Rack::Session::Cookie, { + # key: 'rack.session', + # domain: 'foo.com', + # path: '/', + # expire_after: 2592000, + # secrets: 'a randomly generated, raw binary string 64 bytes in size', + # } + # + # Example using legacy HMAC options: + # + # Rack::Session:Cookie.new(application, { + # # The secret used for legacy HMAC cookies, this enables the functionality + # legacy_hmac_secret: 'legacy secret', + # # legacy_hmac_coder will default to Rack::Session::Cookie::Base64::Marshal + # legacy_hmac_coder: Rack::Session::Cookie::Identity.new, + # # legacy_hmac will default to OpenSSL::Digest::SHA1 + # legacy_hmac: OpenSSL::Digest::SHA256 + # }) + # + # Example of a cookie with no encoding: + # + # Rack::Session::Cookie.new(application, { + # :coder => Rack::Session::Cookie::Identity.new + # }) + # + # Example of a cookie with custom encoding: + # + # Rack::Session::Cookie.new(application, { + # :coder => Class.new { + # def encode(str); str.reverse; end + # def decode(str); str.reverse; end + # }.new + # }) + # + + class Cookie < Abstract::PersistedSecure + # Encode session cookies as Base64 + class Base64 + def encode(str) + ::Base64.strict_encode64(str) + end + + def decode(str) + ::Base64.decode64(str) + end + + # Encode session cookies as Marshaled Base64 data + class Marshal < Base64 + def encode(str) + super(::Marshal.dump(str)) + end + + def decode(str) + return unless str + ::Marshal.load(super(str)) rescue nil + end + end + + # N.B. Unlike other encoding methods, the contained objects must be a + # valid JSON composite type, either a Hash or an Array. + class JSON < Base64 + def encode(obj) + super(::JSON.dump(obj)) + end + + def decode(str) + return unless str + ::JSON.parse(super(str)) rescue nil + end + end + + class ZipJSON < Base64 + def encode(obj) + super(Zlib::Deflate.deflate(::JSON.dump(obj))) + end + + def decode(str) + return unless str + ::JSON.parse(Zlib::Inflate.inflate(super(str))) + rescue + nil + end + end + end + + # Use no encoding for session cookies + class Identity + def encode(str); str; end + def decode(str); str; end + end + + class Marshal + def encode(str) + ::Marshal.dump(str) + end + + def decode(str) + ::Marshal.load(str) if str + end + end + + attr_reader :coder, :encryptors + + def initialize(app, options = {}) + # support both :secrets and :secret for backwards compatibility + secrets = [*(options[:secrets] || options[:secret])] + + encryptor_opts = { + purpose: options[:key], serialize_json: options[:serialize_json] + } + + # For each secret, create an Encryptor. We have iterate this Array at + # decryption time to achieve key rotation. + @encryptors = secrets.map do |secret| + Rack::Session::Encryptor.new secret, encryptor_opts + end + + # If a legacy HMAC secret is present, initialize those features. + # Fallback to :secret for backwards compatibility. + if options.has_key?(:legacy_hmac_secret) || options.has_key?(:secret) + @legacy_hmac = options.fetch(:legacy_hmac, 'SHA1') + + @legacy_hmac_secret = options[:legacy_hmac_secret] || options[:secret] + @legacy_hmac_coder = options.fetch(:legacy_hmac_coder, Base64::Marshal.new) + else + @legacy_hmac = false + end + + warn <<-MSG unless secure?(options) + SECURITY WARNING: No secret option provided to Rack::Session::Cookie. + This poses a security threat. It is strongly recommended that you + provide a secret to prevent exploits that may be possible from crafted + cookies. This will not be supported in future versions of Rack, and + future versions will even invalidate your existing user cookies. + + Called from: #{caller[0]}. + MSG + + # Potential danger ahead! Marshal without verification and/or + # encryption could present a major security issue. + @coder = options[:coder] ||= Base64::Marshal.new + + super(app, options.merge!(cookie_only: true)) + end + + private + + def find_session(req, sid) + data = unpacked_cookie_data(req) + data = persistent_session_id!(data) + [data["session_id"], data] + end + + def extract_session_id(request) + unpacked_cookie_data(request)&.[]("session_id") + end + + def unpacked_cookie_data(request) + request.fetch_header(RACK_SESSION_UNPACKED_COOKIE_DATA) do |k| + if cookie_data = request.cookies[@key] + session_data = nil + + # Try to decrypt the session data with our encryptors + encryptors.each do |encryptor| + begin + session_data = encryptor.decrypt(cookie_data) + break + rescue Rack::Session::Encryptor::Error => error + request.env[Rack::RACK_ERRORS].puts "Session cookie encryptor error: #{error.message}" + + next + end + end + + # If session decryption fails but there is @legacy_hmac_secret + # defined, attempt legacy HMAC verification + if !session_data && @legacy_hmac_secret + # Parse and verify legacy HMAC session cookie + session_data, _, digest = cookie_data.rpartition('--') + session_data = nil unless legacy_digest_match?(session_data, digest) + + # Decode using legacy HMAC decoder + session_data = @legacy_hmac_coder.decode(session_data) + + elsif !session_data && coder + # Use the coder option, which has the potential to be very unsafe + session_data = coder.decode(cookie_data) + end + end + + request.set_header(k, session_data || {}) + end + end + + def persistent_session_id!(data, sid = nil) + data ||= {} + data["session_id"] ||= sid || generate_sid + data + end + + class SessionId < DelegateClass(Session::SessionId) + attr_reader :cookie_value + + def initialize(session_id, cookie_value) + super(session_id) + @cookie_value = cookie_value + end + end + + def write_session(req, session_id, session, options) + session = session.merge("session_id" => session_id) + session_data = encode_session_data(session) + + if session_data.size > (4096 - @key.size) + req.get_header(RACK_ERRORS).puts("Warning! Rack::Session::Cookie data size exceeds 4K.") + nil + else + SessionId.new(session_id, session_data) + end + end + + def delete_session(req, session_id, options) + # Nothing to do here, data is in the client + generate_sid unless options[:drop] + end + + def legacy_digest_match?(data, digest) + return false unless data && digest + + Rack::Utils.secure_compare(digest, legacy_generate_hmac(data)) + end + + def legacy_generate_hmac(data) + OpenSSL::HMAC.hexdigest(@legacy_hmac, @legacy_hmac_secret, data) + end + + def encode_session_data(session) + if encryptors.empty? + coder.encode(session) + else + encryptors.first.encrypt(session) + end + end + + # Were consider "secure" if: + # * Encrypted cookies are enabled and one or more encryptor is + # initialized + # * The legacy HMAC option is enabled + # * Customer :coder is used, with :let_coder_handle_secure_encoding + # set to true + def secure?(options) + !@encryptors.empty? || + @legacy_hmac || + (options[:coder] && options[:let_coder_handle_secure_encoding]) + end + end + end +end diff --git a/lib/rack/session/encryptor.rb b/lib/rack/session/encryptor.rb new file mode 100644 index 0000000..a57ae82 --- /dev/null +++ b/lib/rack/session/encryptor.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2023, by Samuel Williams. +# Copyright, 2022, by Philip Arndt. + +require 'base64' +require 'openssl' +require 'securerandom' +require 'zlib' + +require 'rack/utils' + +module Rack + module Session + class Encryptor + class Error < StandardError + end + + class InvalidSignature < Error + end + + class InvalidMessage < Error + end + + # The secret String must be at least 64 bytes in size. The first 32 bytes + # will be used for the encryption cipher key. The remainder will be used + # for an HMAC key. + # + # Options may include: + # * :serialize_json + # Use JSON for message serialization instead of Marshal. This can be + # viewed as a security enhancement. + # * :pad_size + # Pad encrypted message data, to a multiple of this many bytes + # (default: 32). This can be between 2-4096 bytes, or +nil+ to disable + # padding. + # * :purpose + # Limit messages to a specific purpose. This can be viewed as a + # security enhancement to prevent message reuse from different contexts + # if keys are reused. + # + # Cryptography and Output Format: + # + # urlsafe_encode64(version + random_data + IV + encrypted data + HMAC) + # + # Where: + # * version - 1 byte and is currently always 0x01 + # * random_data - 32 bytes used for generating the per-message secret + # * IV - 16 bytes random initialization vector + # * HMAC - 32 bytes HMAC-SHA-256 of all preceding data, plus the purpose + # value + def initialize(secret, opts = {}) + raise ArgumentError, "secret must be a String" unless String === secret + raise ArgumentError, "invalid secret: #{secret.bytesize}, must be >=64" unless secret.bytesize >= 64 + + case opts[:pad_size] + when nil + # padding is disabled + when Integer + raise ArgumentError, "invalid pad_size: #{opts[:pad_size]}" unless (2..4096).include? opts[:pad_size] + else + raise ArgumentError, "invalid pad_size: #{opts[:pad_size]}; must be Integer or nil" + end + + @options = { + serialize_json: false, pad_size: 32, purpose: nil + }.update(opts) + + @hmac_secret = secret.dup.force_encoding('BINARY') + @cipher_secret = @hmac_secret.slice!(0, 32) + + @hmac_secret.freeze + @cipher_secret.freeze + end + + def decrypt(base64_data) + data = Base64.urlsafe_decode64(base64_data) + + signature = data.slice!(-32..-1) + + verify_authenticity! data, signature + + # The version is reserved for future + _version = data.slice!(0, 1) + message_secret = data.slice!(0, 32) + cipher_iv = data.slice!(0, 16) + + cipher = new_cipher + cipher.decrypt + + set_cipher_key(cipher, cipher_secret_from_message_secret(message_secret)) + + cipher.iv = cipher_iv + data = cipher.update(data) << cipher.final + + deserialized_message data + rescue ArgumentError + raise InvalidSignature, 'Message invalid' + end + + def encrypt(message) + version = "\1" + + serialized_payload = serialize_payload(message) + message_secret, cipher_secret = new_message_and_cipher_secret + + cipher = new_cipher + cipher.encrypt + + set_cipher_key(cipher, cipher_secret) + + cipher_iv = cipher.random_iv + + encrypted_data = cipher.update(serialized_payload) << cipher.final + + data = String.new + data << version + data << message_secret + data << cipher_iv + data << encrypted_data + data << compute_signature(data) + + Base64.urlsafe_encode64(data) + end + + private + + def new_cipher + OpenSSL::Cipher.new('aes-256-ctr') + end + + def new_message_and_cipher_secret + message_secret = SecureRandom.random_bytes(32) + + [message_secret, cipher_secret_from_message_secret(message_secret)] + end + + def cipher_secret_from_message_secret(message_secret) + OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, @cipher_secret, message_secret) + end + + def set_cipher_key(cipher, key) + cipher.key = key + end + + def serializer + @serializer ||= @options[:serialize_json] ? JSON : Marshal + end + + def compute_signature(data) + signing_data = data + signing_data += @options[:purpose] if @options[:purpose] + + OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, @hmac_secret, signing_data) + end + + def verify_authenticity!(data, signature) + raise InvalidMessage, 'Message is invalid' if data.nil? || signature.nil? + + unless Rack::Utils.secure_compare(signature, compute_signature(data)) + raise InvalidSignature, 'HMAC is invalid' + end + end + + # Returns a serialized payload of the message. If a :pad_size is supplied, + # the message will be padded. The first 2 bytes of the returned string will + # indicating the amount of padding. + def serialize_payload(message) + serialized_data = serializer.dump(message) + + return "#{[0].pack('v')}#{serialized_data}" if @options[:pad_size].nil? + + padding_bytes = @options[:pad_size] - (2 + serialized_data.size) % @options[:pad_size] + padding_data = SecureRandom.random_bytes(padding_bytes) + + "#{[padding_bytes].pack('v')}#{padding_data}#{serialized_data}" + end + + # Return the deserialized message. The first 2 bytes will be read as the + # amount of padding. + def deserialized_message(data) + # Read the first 2 bytes as the padding_bytes size + padding_bytes, = data.unpack('v') + + # Slice out the serialized_data and deserialize it + serialized_data = data.slice(2 + padding_bytes, data.bytesize) + serializer.load serialized_data + end + end + end +end diff --git a/lib/rack/session/pool.rb b/lib/rack/session/pool.rb new file mode 100644 index 0000000..acdd8fe --- /dev/null +++ b/lib/rack/session/pool.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2023, by Samuel Williams. + +require_relative 'abstract/id' + +module Rack + module Session + # Rack::Session::Pool provides simple cookie based session management. + # Session data is stored in a hash held by @pool. + # In the context of a multithreaded environment, sessions being + # committed to the pool is done in a merging manner. + # + # The :drop option is available in rack.session.options if you wish to + # explicitly remove the session from the session cache. + # + # Example: + # myapp = MyRackApp.new + # sessioned = Rack::Session::Pool.new(myapp, + # :domain => 'foo.com', + # :expire_after => 2592000 + # ) + # Rack::Handler::WEBrick.run sessioned + + class Pool < Abstract::PersistedSecure + attr_reader :mutex, :pool + DEFAULT_OPTIONS = Abstract::ID::DEFAULT_OPTIONS.merge(drop: false, allow_fallback: true) + + def initialize(app, options = {}) + super + @pool = Hash.new + @mutex = Mutex.new + @allow_fallback = @default_options.delete(:allow_fallback) + end + + def generate_sid(*args, use_mutex: true) + loop do + sid = super(*args) + break sid unless use_mutex ? @mutex.synchronize { @pool.key? sid.private_id } : @pool.key?(sid.private_id) + end + end + + def find_session(req, sid) + @mutex.synchronize do + unless sid and session = get_session_with_fallback(sid) + sid, session = generate_sid(use_mutex: false), {} + @pool.store sid.private_id, session + end + [sid, session] + end + end + + def write_session(req, session_id, new_session, options) + @mutex.synchronize do + @pool.store session_id.private_id, new_session + session_id + end + end + + def delete_session(req, session_id, options) + @mutex.synchronize do + @pool.delete(session_id.public_id) + @pool.delete(session_id.private_id) + generate_sid(use_mutex: false) unless options[:drop] + end + end + + private + + def get_session_with_fallback(sid) + @pool[sid.private_id] || (@pool[sid.public_id] if @allow_fallback) + end + end + end +end diff --git a/lib/rack/session/version.rb b/lib/rack/session/version.rb index 7e236b0..60f11ce 100644 --- a/lib/rack/session/version.rb +++ b/lib/rack/session/version.rb @@ -5,6 +5,6 @@ module Rack module Session - VERSION = "1.0.0" + VERSION = "2.0.0" end end diff --git a/license.md b/license.md index f46d568..669175a 100644 --- a/license.md +++ b/license.md @@ -1,7 +1,55 @@ # MIT License -Copyright, 2022-2023, by Samuel Williams. -Copyright, 2022, by Jeremy Evans. +Copyright, 2007-2008, by Leah Neukirchen. +Copyright, 2007-2009, by Scytrin dai Kinthra. +Copyright, 2008, by Daniel Roethlisberger. +Copyright, 2009, by Joshua Peek. +Copyright, 2009, by Mickaël Riga. +Copyright, 2010, by Simon Chiang. +Copyright, 2010-2011, by José Valim. +Copyright, 2010-2013, by James Tucker. +Copyright, 2010-2019, by Aaron Patterson. +Copyright, 2011, by Max Cantor. +Copyright, 2011-2012, by Konstantin Haase. +Copyright, 2011, by Will Leinweber. +Copyright, 2011, by John Manoogian III. +Copyright, 2012, by Yun Huang Yong. +Copyright, 2012, by Ravil Bayramgalin. +Copyright, 2012, by Timothy Elliott. +Copyright, 2012, by Jamie Macey. +Copyright, 2012-2015, by Santiago Pastorino. +Copyright, 2013, by Andrew Cole. +Copyright, 2013, by Postmodern. +Copyright, 2013, by Vipul A M. +Copyright, 2013, by Charles Hornberger. +Copyright, 2014, by Michal Bryxí. +Copyright, 2015, by deepj. +Copyright, 2015, by Doug McInnes. +Copyright, 2015, by David Runger. +Copyright, 2015, by Francesco Rodríguez. +Copyright, 2015, by Yuichiro Kaneko. +Copyright, 2015, by Michael Sauter. +Copyright, 2016, by Kir Shatrov. +Copyright, 2016, by Yann Vanhalewyn. +Copyright, 2016, by Jian Weihang. +Copyright, 2017, by Jordan Raine. +Copyright, 2018, by Dillon Welch. +Copyright, 2018, by Yoshiyuki Hirano. +Copyright, 2019, by Krzysztof Rybka. +Copyright, 2019, by Frederick Cheung. +Copyright, 2019, by Adrian Setyadi. +Copyright, 2019, by Rafael Mendonça França. +Copyright, 2019-2020, by Pavel Rosicky. +Copyright, 2019, by Dima Fatko. +Copyright, 2019, by Oleh Demianiuk. +Copyright, 2020-2023, by Samuel Williams. +Copyright, 2020-2022, by Jeremy Evans. +Copyright, 2020, by Alex Speller. +Copyright, 2020, by Ryuta Kamizono. +Copyright, 2020, by Yudai Suzuki. +Copyright, 2020, by Bart de Water. +Copyright, 2020, by Alec Clarke. +Copyright, 2021, by Michael Coyne. Copyright, 2022, by Philip Arndt. Copyright, 2022, by Jon Dufresne. diff --git a/rack-session.gemspec b/rack-session.gemspec index 8005925..aed2fde 100644 --- a/rack-session.gemspec +++ b/rack-session.gemspec @@ -16,7 +16,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 2.4.0" - spec.add_dependency "rack", "<= 3" + spec.add_dependency "rack", ">= 3.0.0" spec.add_development_dependency "bundler" spec.add_development_dependency "minitest", "~> 5.0" diff --git a/test/helper.rb b/test/helper.rb new file mode 100644 index 0000000..c19ae0d --- /dev/null +++ b/test/helper.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2023, by Samuel Williams. + +if ENV.delete('COVERAGE') + require 'coverage' + require 'simplecov' + + def SimpleCov.rack_coverage(**opts) + start do + add_filter "/test/" + add_group('Missing'){|src| src.covered_percent < 100} + add_group('Covered'){|src| src.covered_percent == 100} + end + end + SimpleCov.rack_coverage +end + +$:.unshift(File.expand_path('../lib', __dir__)) +if ENV['SEPARATE'] + def self.separate_testing + yield + end +else + require_relative '../lib/rack/session' + + def self.separate_testing + end +end +require 'minitest/global_expectations/autorun' +require 'stringio' diff --git a/test/spec_session_abstract_id.rb b/test/spec_session_abstract_id.rb new file mode 100644 index 0000000..fd9167b --- /dev/null +++ b/test/spec_session_abstract_id.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2023, by Samuel Williams. + +require_relative 'helper' +require 'rack/request' + +### WARNING: there be hax in this file. + +require_relative '../lib/rack/session/abstract/id' + +describe Rack::Session::Abstract::ID do + attr_reader :id + + def setup + super + @id = Rack::Session::Abstract::ID + end + + it "use securerandom" do + assert_equal ::SecureRandom, id::DEFAULT_OPTIONS[:secure_random] + + id = @id.new nil + assert_equal ::SecureRandom, id.sid_secure + end + + it "allow to use another securerandom provider" do + secure_random = Class.new do + def hex(*args) + 'fake_hex' + end + end + id = Rack::Session::Abstract::ID.new nil, secure_random: secure_random.new + id.send(:generate_sid).must_equal 'fake_hex' + end + + it "should warn when subclassing" do + verbose = $VERBOSE + begin + $VERBOSE = true + warn_arg = nil + @id.define_singleton_method(:warn) do |arg| + warn_arg = arg + end + c = Class.new(@id) + regexp = /is inheriting from Rack::Session::Abstract::ID. Inheriting from Rack::Session::Abstract::ID is deprecated, please inherit from Rack::Session::Abstract::Persisted instead/ + warn_arg.must_match(regexp) + + warn_arg = nil + c = Class.new(c) + warn_arg.must_be_nil + ensure + $VERBOSE = verbose + @id.singleton_class.send(:remove_method, :warn) + end + end + + it "#find_session should find session in request" do + id = @id.new(nil) + def id.get_session(env, sid) + [env['rack.session'], generate_sid] + end + req = Rack::Request.new('rack.session' => {}) + session, sid = id.find_session(req, nil) + session.must_equal({}) + sid.must_match(/\A\h+\z/) + end + + it "#write_session should write session to request" do + id = @id.new(nil) + def id.set_session(env, sid, session, options) + [env, sid, session, options] + end + req = Rack::Request.new({}) + id.write_session(req, 1, 2, 3).must_equal [{}, 1, 2, 3] + end + + it "#delete_session should remove session from request" do + id = @id.new(nil) + def id.destroy_session(env, sid, options) + [env, sid, options] + end + req = Rack::Request.new({}) + id.delete_session(req, 1, 2).must_equal [{}, 1, 2] + end +end diff --git a/test/spec_session_abstract_persisted.rb b/test/spec_session_abstract_persisted.rb new file mode 100644 index 0000000..b7040be --- /dev/null +++ b/test/spec_session_abstract_persisted.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2023, by Samuel Williams. + +require_relative 'helper' +require 'rack/request' + +require_relative '../lib/rack/session/abstract/id' + +describe Rack::Session::Abstract::Persisted do + def setup + @class = Rack::Session::Abstract::Persisted + @pers = @class.new(nil) + end + + it "#generated_sid generates a session identifier" do + @pers.send(:generate_sid).must_match(/\A\h+\z/) + @pers.send(:generate_sid, nil).must_match(/\A\h+\z/) + + obj = Object.new + def obj.hex(_); raise NotImplementedError end + @pers.send(:generate_sid, obj).must_match(/\A\h+\z/) + end + + it "#commit_session? returns false if :skip option is given" do + @pers.send(:commit_session?, Rack::Request.new({}), {}, skip: true).must_equal false + end + + it "#commit_session writes to rack.errors if session cannot be written" do + @pers = @class.new(nil) + def @pers.write_session(*) end + errors = StringIO.new + env = { 'rack.errors' => errors } + req = Rack::Request.new(env) + store = Class.new do + def load_session(req) + ["id", {}] + end + def session_exists?(req) + true + end + end + session = env['rack.session'] = Rack::Session::Abstract::SessionHash.new(store.new, req) + session['foo'] = 'bar' + @pers.send(:commit_session, req, Rack::Response.new) + errors.rewind + errors.read.must_equal "Warning! Rack::Session::Abstract::Persisted failed to save session. Content dropped.\n" + end + + it "#cookie_value returns its argument" do + obj = Object.new + @pers.send(:cookie_value, obj).must_equal(obj) + end + + it "#session_class returns the default session class" do + @pers.send(:session_class).must_equal Rack::Session::Abstract::SessionHash + end + + it "#find_session raises" do + proc { @pers.send(:find_session, nil, nil) }.must_raise RuntimeError + end + + it "#write_session raises" do + proc { @pers.send(:write_session, nil, nil, nil, nil) }.must_raise RuntimeError + end + + it "#delete_session raises" do + proc { @pers.send(:delete_session, nil, nil, nil) }.must_raise RuntimeError + end +end diff --git a/test/spec_session_abstract_persisted_secure_secure_session_hash.rb b/test/spec_session_abstract_persisted_secure_secure_session_hash.rb new file mode 100644 index 0000000..b79bdd1 --- /dev/null +++ b/test/spec_session_abstract_persisted_secure_secure_session_hash.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2023, by Samuel Williams. + +require_relative 'helper' +require 'rack/request' + +require_relative '../lib/rack/session/abstract/id' + +describe Rack::Session::Abstract::PersistedSecure::SecureSessionHash do + attr_reader :hash + + def setup + super + @store = Class.new do + def load_session(req) + [Rack::Session::SessionId.new("id"), { foo: :bar, baz: :qux }] + end + def session_exists?(req) + true + end + end + @hash = Rack::Session::Abstract::PersistedSecure::SecureSessionHash.new(@store.new, nil) + end + + it "returns keys" do + assert_equal ["foo", "baz"], hash.keys + end + + it "returns values" do + assert_equal [:bar, :qux], hash.values + end + + describe "#[]" do + it "returns value for a matching key" do + assert_equal :bar, hash[:foo] + end + + it "returns value for a 'session_id' key" do + assert_equal "id", hash['session_id'] + end + + it "returns nil value for missing 'session_id' key" do + store = @store.new + def store.load_session(req) + [nil, {}] + end + @hash = Rack::Session::Abstract::PersistedSecure::SecureSessionHash.new(store, nil) + assert_nil hash['session_id'] + end + + it "returns value for non SessionId 'session_id' key" do + store = @store.new + def store.load_session(req) + ["id", {}] + end + @hash = Rack::Session::Abstract::PersistedSecure::SecureSessionHash.new(store, nil) + assert_equal "id", hash['session_id'] + end + end + + describe "#fetch" do + it "returns value for a matching key" do + assert_equal :bar, hash.fetch(:foo) + end + + it "works with a default value" do + assert_equal :default, hash.fetch(:unknown, :default) + end + + it "works with a block" do + assert_equal :default, hash.fetch(:unknown) { :default } + end + + it "it raises when fetching unknown keys without defaults" do + lambda { hash.fetch(:unknown) }.must_raise KeyError + end + end + + describe "#stringify_keys" do + it "returns hash or session hash with keys stringified" do + assert_equal({ "foo" => :bar, "baz" => :qux }, hash.send(:stringify_keys, hash).to_h) + end + end +end diff --git a/test/spec_session_abstract_session_hash.rb b/test/spec_session_abstract_session_hash.rb new file mode 100644 index 0000000..8f17ab4 --- /dev/null +++ b/test/spec_session_abstract_session_hash.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2023, by Samuel Williams. + +require_relative 'helper' +require 'rack/request' + +require_relative '../lib/rack/session/abstract/id' + +describe Rack::Session::Abstract::SessionHash do + attr_reader :hash + + def setup + super + store = Class.new do + def load_session(req) + ["id", { foo: :bar, baz: :qux, x: { y: 1 } }] + end + def session_exists?(req) + true + end + end + @class = Rack::Session::Abstract::SessionHash + @hash = @class.new(store.new, nil) + end + + it ".find finds entry in request" do + assert_equal({}, @class.find(Rack::Request.new('rack.session' => {}))) + end + + it ".set sets session in request" do + req = Rack::Request.new({}) + @class.set(req, {}) + req.env['rack.session'].must_equal({}) + end + + it ".set_options sets session options in request" do + req = Rack::Request.new({}) + h = {} + @class.set_options(req, h) + opts = req.env['rack.session.options'] + opts.must_equal(h) + opts.wont_be_same_as(h) + end + + it "#keys returns keys" do + assert_equal ["foo", "baz", "x"], hash.keys + end + + it "#values returns values" do + assert_equal [:bar, :qux, { y: 1 }], hash.values + end + + it "#dig operates like Hash#dig" do + assert_equal({ y: 1 }, hash.dig("x")) + assert_equal(1, hash.dig(:x, :y)) + assert_nil(hash.dig(:z)) + assert_nil(hash.dig(:x, :z)) + lambda { hash.dig(:x, :y, :z) }.must_raise TypeError + lambda { hash.dig }.must_raise ArgumentError + end + + it "#each iterates over entries" do + a = [] + @hash.each do |k, v| + a << [k, v] + end + a.must_equal [["foo", :bar], ["baz", :qux], ["x", { y: 1 }]] + end + + it "#has_key returns whether the key is in the hash" do + assert_equal true, hash.has_key?("foo") + assert_equal true, hash.has_key?(:foo) + assert_equal false, hash.has_key?("food") + assert_equal false, hash.has_key?(:food) + end + + it "#replace replaces hash" do + hash.replace({ bar: "foo" }) + assert_equal "foo", hash["bar"] + end + + describe "#fetch" do + it "returns value for a matching key" do + assert_equal :bar, hash.fetch(:foo) + end + + it "works with a default value" do + assert_equal :default, hash.fetch(:unknown, :default) + end + + it "works with a block" do + assert_equal :default, hash.fetch(:unknown) { :default } + end + + it "it raises when fetching unknown keys without defaults" do + lambda { hash.fetch(:unknown) }.must_raise KeyError + end + end + + it "#stringify_keys returns hash or session hash with keys stringified" do + assert_equal({ "foo" => :bar, "baz" => :qux, "x" => { y: 1 } }, hash.send(:stringify_keys, hash).to_h) + end +end diff --git a/test/spec_session_cookie.rb b/test/spec_session_cookie.rb new file mode 100644 index 0000000..ed41d39 --- /dev/null +++ b/test/spec_session_cookie.rb @@ -0,0 +1,597 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2023, by Samuel Williams. +# Copyright, 2022, by Jeremy Evans. + +require_relative 'helper' +require 'rack/response' +require 'rack/lint' +require 'rack/mock' +require 'json' + +require_relative '../lib/rack/session/cookie' + +describe Rack::Session::Cookie do + incrementor = lambda do |env| + env["rack.session"]["counter"] ||= 0 + env["rack.session"]["counter"] += 1 + hash = env["rack.session"].dup + hash.delete("session_id") + Rack::Response.new(hash.inspect).to_a + end + + session_id = lambda do |env| + Rack::Response.new(env["rack.session"].to_hash.inspect).to_a + end + + session_option = lambda do |opt| + lambda do |env| + Rack::Response.new(env["rack.session.options"][opt].inspect).to_a + end + end + + nothing = lambda do |env| + Rack::Response.new("Nothing").to_a + end + + renewer = lambda do |env| + env["rack.session.options"][:renew] = true + Rack::Response.new("Nothing").to_a + end + + only_session_id = lambda do |env| + Rack::Response.new(env["rack.session"]["session_id"].to_s).to_a + end + + bigcookie = lambda do |env| + env["rack.session"]["cookie"] = "big" * 3000 + Rack::Response.new(env["rack.session"].inspect).to_a + end + + destroy_session = lambda do |env| + env["rack.session"].destroy + Rack::Response.new("Nothing").to_a + end + + def response_for(options = {}) + request_options = options.fetch(:request, {}) + cookie = if options[:cookie].is_a?(Rack::Response) + options[:cookie]["Set-Cookie"] + else + options[:cookie] + end + request_options["HTTP_COOKIE"] = cookie || "" + + app_with_cookie = Rack::Session::Cookie.new(*options[:app]) + app_with_cookie = Rack::Lint.new(app_with_cookie) + Rack::MockRequest.new(app_with_cookie).get("/", request_options) + end + + def random_encryptor_secret + SecureRandom.random_bytes(64) + end + + before do + # Random key, as a hex string + @secret = random_encryptor_secret + + @warnings = warnings = [] + Rack::Session::Cookie.class_eval do + define_method(:warn) { |m| warnings << m } + end + end + + after do + Rack::Session::Cookie.class_eval { remove_method :warn } + end + + describe 'Base64' do + it 'uses base64 to encode' do + coder = Rack::Session::Cookie::Base64.new + str = 'fuuuuu' + coder.encode(str).must_equal [str].pack('m0') + end + + it 'uses base64 to decode' do + coder = Rack::Session::Cookie::Base64.new + str = ['fuuuuu'].pack('m0') + coder.decode(str).must_equal str.unpack('m0').first + end + + it 'handles non-strict base64 encoding' do + coder = Rack::Session::Cookie::Base64.new + str = ['A' * 256].pack('m') + coder.decode(str).must_equal 'A' * 256 + end + + describe 'Marshal' do + it 'marshals and base64 encodes' do + coder = Rack::Session::Cookie::Base64::Marshal.new + str = 'fuuuuu' + coder.encode(str).must_equal [::Marshal.dump(str)].pack('m0') + end + + it 'marshals and base64 decodes' do + coder = Rack::Session::Cookie::Base64::Marshal.new + str = [::Marshal.dump('fuuuuu')].pack('m0') + coder.decode(str).must_equal ::Marshal.load(str.unpack('m0').first) + end + + it 'rescues failures on decode' do + coder = Rack::Session::Cookie::Base64::Marshal.new + coder.decode('lulz').must_be_nil + end + end + + describe 'JSON' do + it 'JSON and base64 encodes' do + coder = Rack::Session::Cookie::Base64::JSON.new + obj = %w[fuuuuu] + coder.encode(obj).must_equal [::JSON.dump(obj)].pack('m0') + end + + it 'JSON and base64 decodes' do + coder = Rack::Session::Cookie::Base64::JSON.new + str = [::JSON.dump(%w[fuuuuu])].pack('m0') + coder.decode(str).must_equal ::JSON.parse(str.unpack('m0').first) + end + + it 'rescues failures on decode' do + coder = Rack::Session::Cookie::Base64::JSON.new + coder.decode('lulz').must_be_nil + end + end + + describe 'ZipJSON' do + it 'jsons, deflates, and base64 encodes' do + coder = Rack::Session::Cookie::Base64::ZipJSON.new + obj = %w[fuuuuu] + json = JSON.dump(obj) + coder.encode(obj).must_equal [Zlib::Deflate.deflate(json)].pack('m0') + end + + it 'base64 decodes, inflates, and decodes json' do + coder = Rack::Session::Cookie::Base64::ZipJSON.new + obj = %w[fuuuuu] + json = JSON.dump(obj) + b64 = [Zlib::Deflate.deflate(json)].pack('m0') + coder.decode(b64).must_equal obj + end + + it 'rescues failures on decode' do + coder = Rack::Session::Cookie::Base64::ZipJSON.new + coder.decode('lulz').must_be_nil + end + end + end + + it "warns if no secret is given" do + Rack::Session::Cookie.new(incrementor) + @warnings.first.must_match(/no secret/i) + @warnings.clear + Rack::Session::Cookie.new(incrementor, secrets: @secret) + @warnings.must_be :empty? + end + + it 'abort if secret is too short' do + lambda { + Rack::Session::Cookie.new(incrementor, secrets: @secret[0, 16]) + }.must_raise ArgumentError + end + + it "doesn't warn if coder is configured to handle encoding" do + Rack::Session::Cookie.new( + incrementor, + coder: Object.new, + let_coder_handle_secure_encoding: true) + @warnings.must_be :empty? + end + + it "still warns if coder is not set" do + Rack::Session::Cookie.new( + incrementor, + let_coder_handle_secure_encoding: true) + @warnings.first.must_match(/no secret/i) + end + + it 'uses a coder' do + identity = Class.new { + attr_reader :calls + + def initialize + @calls = [] + end + + def encode(hash); @calls << :encode; hash.to_json; end + def decode(str); @calls << :decode; JSON.parse(str); end + }.new + response = response_for(app: [incrementor, { coder: identity }]) + + response["Set-Cookie"].must_include "rack.session=" + response.body.must_equal '{"counter"=>1}' + identity.calls.must_equal [:encode] + + response = response_for(app: [incrementor, { coder: identity }], :cookie=>response["Set-Cookie"].split(';', 2).first) + identity.calls.must_equal [:encode, :decode, :encode] + end + + it "creates a new cookie" do + response = response_for(app: incrementor) + response["Set-Cookie"].must_include "rack.session=" + response.body.must_equal '{"counter"=>1}' + end + + it "passes through same_site option to session cookie" do + response = response_for(app: [incrementor, same_site: :none]) + response["Set-Cookie"].must_include "SameSite=None" + end + + it "allows using a lambda to specify same_site option, because some browsers require different settings" do + # Details of why this might need to be set dynamically: + # https://www.chromium.org/updates/same-site/incompatible-clients + # https://gist.github.com/bnorton/7dee72023787f367c48b3f5c2d71540f + + response = response_for(app: [incrementor, same_site: lambda { |req, res| :none }]) + response["Set-Cookie"].must_include "SameSite=None" + + response = response_for(app: [incrementor, same_site: lambda { |req, res| :lax }]) + response["Set-Cookie"].must_include "SameSite=Lax" + end + + it "loads from a cookie" do + response = response_for(app: incrementor) + + response = response_for(app: incrementor, cookie: response) + response.body.must_equal '{"counter"=>2}' + + response = response_for(app: incrementor, cookie: response) + response.body.must_equal '{"counter"=>3}' + end + + it "renew session id" do + response = response_for(app: incrementor) + cookie = response['Set-Cookie'] + response = response_for(app: only_session_id, cookie: cookie) + cookie = response['Set-Cookie'] if response['Set-Cookie'] + + response.body.wont_equal "" + old_session_id = response.body + + response = response_for(app: renewer, cookie: cookie) + cookie = response['Set-Cookie'] if response['Set-Cookie'] + response = response_for(app: only_session_id, cookie: cookie) + + response.body.wont_equal "" + response.body.wont_equal old_session_id + end + + it "destroys session" do + response = response_for(app: incrementor) + response = response_for(app: only_session_id, cookie: response) + + response.body.wont_equal "" + old_session_id = response.body + + response = response_for(app: destroy_session, cookie: response) + response = response_for(app: only_session_id, cookie: response) + + response.body.wont_equal "" + response.body.wont_equal old_session_id + end + + it "survives broken cookies" do + response = response_for( + app: incrementor, + cookie: "rack.session=blarghfasel" + ) + response.body.must_equal '{"counter"=>1}' + + response = response_for( + app: [incrementor, { secrets: @secret }], + cookie: "rack.session=" + ) + response.body.must_equal '{"counter"=>1}' + end + + it "barks on too big cookies" do + lambda{ + response_for(app: bigcookie, request: { fatal: true }) + }.must_raise Rack::MockRequest::FatalWarning + end + + it "loads from a cookie with encryption" do + app = [incrementor, { secrets: @secret }] + + response = response_for(app: app) + response = response_for(app: app, cookie: response) + response.body.must_equal '{"counter"=>2}' + + response = response_for(app: app, cookie: response) + response.body.must_equal '{"counter"=>3}' + + app = [incrementor, { secrets: random_encryptor_secret }] + + response = response_for(app: app, cookie: response) + response.body.must_equal '{"counter"=>1}' + end + + it "loads from a cookie with accept-only integrity hash for graceful key rotation" do + response = response_for(app: [incrementor, { secrets: @secret }]) + + new_secret = random_encryptor_secret + + app = [incrementor, { secrets: [new_secret, @secret] }] + response = response_for(app: app, cookie: response) + response.body.must_equal '{"counter"=>2}' + + newer_secret = random_encryptor_secret + + app = [incrementor, { secrets: [newer_secret, new_secret] }] + response = response_for(app: app, cookie: response) + + response.body.must_equal '{"counter"=>3}' + end + + it 'loads from a legacy hmac cookie' do + legacy_session = Rack::Session::Cookie::Base64::Marshal.new.encode({ 'counter' => 1, 'session_id' => 'abcdef' }) + legacy_secret = 'test legacy secret' + legacy_digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, legacy_secret, legacy_session) + + legacy_cookie = "rack.session=#{legacy_session}--#{legacy_digest}; path=/; HttpOnly" + + app = [incrementor, { secrets: @secret, legacy_hmac_secret: legacy_secret }] + response = response_for(app: app, cookie: legacy_cookie) + response.body.must_equal '{"counter"=>2}' + end + + it "ignores tampered session cookies" do + app = [incrementor, { secrets: @secret }] + + response = response_for(app: app) + response.body.must_equal '{"counter"=>1}' + + response = response_for(app: app, cookie: response) + response.body.must_equal '{"counter"=>2}' + + encoded_cookie = response["Set-Cookie"].split('=', 2).last.split(';').first + decoded_cookie = Base64.urlsafe_decode64(Rack::Utils.unescape(encoded_cookie)) + + tampered_cookie = "rack.session=#{Base64.urlsafe_encode64(decoded_cookie.tap { |m| + m[m.size - 1] = (m[m.size - 1].unpack('C')[0] ^ 1).chr + })}" + + response = response_for(app: app, cookie: tampered_cookie) + response.body.must_equal '{"counter"=>1}' + end + + it 'rejects session cookie with different purpose' do + app = [incrementor, { secrets: @secrets }] + other_app = [incrementor, { secrets: @secrets, key: 'other' }] + + response = response_for(app: app) + response.body.must_equal '{"counter"=>1}' + + response = response_for(app: app, cookie: response) + response.body.must_equal '{"counter"=>2}' + + response = response_for(app: other_app, cookie: response) + response.body.must_equal '{"counter"=>1}' + end + + it 'adds to RACK_ERRORS on encryptor errors' do + echo_rack_errors = lambda do |env| + env["rack.session"]["counter"] ||= 0 + env["rack.session"]["counter"] += 1 + Rack::Response.new(env[Rack::RACK_ERRORS].flush.tap(&:rewind).read).to_a + end + + app = [incrementor, { secrets: @secret }] + err_app = [echo_rack_errors, { secrets: @secret }] + + response = response_for(app: app) + response.body.must_equal '{"counter"=>1}' + + encoded_cookie = response["Set-Cookie"].split('=', 2).last.split(';').first + decoded_cookie = Base64.urlsafe_decode64(Rack::Utils.unescape(encoded_cookie)) + + tampered_cookie = "rack.session=#{Base64.urlsafe_encode64(decoded_cookie.tap { |m| + m[m.size - 1] = "\0" + })}" + + response = response_for(app: err_app, cookie: tampered_cookie) + response.body.must_equal "Session cookie encryptor error: HMAC is invalid\n" + end + + it 'ignores tampered with legacy hmac cookie' do + legacy_session = Rack::Session::Cookie::Base64::Marshal.new.encode({ 'counter' => 1, 'session_id' => 'abcdef' }) + legacy_secret = 'test legacy secret' + legacy_digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, legacy_secret, legacy_session).reverse + + legacy_cookie = "rack.session=#{legacy_session}--#{legacy_digest}; path=/; HttpOnly" + + app = [incrementor, { secret: @secret, legacy_hmac_secret: legacy_secret }] + response = response_for(app: app, cookie: legacy_cookie) + response.body.must_equal '{"counter"=>1}' + end + + it "supports custom digest instance for legacy hmac cookie" do + legacy_hmac = 'SHA256' + legacy_session = Rack::Session::Cookie::Base64::Marshal.new.encode({ 'counter' => 1, 'session_id' => 'abcdef' }) + legacy_secret = 'test legacy secret' + legacy_digest = OpenSSL::HMAC.hexdigest(legacy_hmac, legacy_secret, legacy_session) + legacy_cookie = "rack.session=#{legacy_session}--#{legacy_digest}; path=/; HttpOnly" + + app = [incrementor, { + secrets: @secret, legacy_hmac_secret: legacy_secret, legacy_hmac: legacy_hmac + }] + + response = response_for(app: app, cookie: legacy_cookie) + response.body.must_equal '{"counter"=>2}' + + response = response_for(app: app, cookie: response) + response.body.must_equal '{"counter"=>3}' + end + + it "can handle Rack::Lint middleware" do + response = response_for(app: incrementor) + + lint = Rack::Lint.new(session_id) + response = response_for(app: lint, cookie: response) + response.body.wont_be :nil? + end + + it "can handle middleware that inspects the env" do + class TestEnvInspector + def initialize(app) + @app = app + end + def call(env) + env.inspect + @app.call(env) + end + end + + response = response_for(app: incrementor) + + inspector = TestEnvInspector.new(session_id) + response = response_for(app: inspector, cookie: response) + response.body.wont_be :nil? + end + + it "returns the session id in the session hash" do + response = response_for(app: incrementor) + response.body.must_equal '{"counter"=>1}' + + response = response_for(app: session_id, cookie: response) + response.body.must_match(/"session_id"=>/) + response.body.must_match(/"counter"=>1/) + end + + it "does not return a cookie if set to secure but not using ssl" do + app = [incrementor, { secure: true }] + + response = response_for(app: app) + response["Set-Cookie"].must_be_nil + + response = response_for(app: app, request: { "HTTPS" => "on" }) + response["Set-Cookie"].wont_be :nil? + response["Set-Cookie"].must_match(/secure/) + end + + it "does not return a cookie if cookie was not read/written" do + response = response_for(app: nothing) + response["Set-Cookie"].must_be_nil + end + + it "does not return a cookie if cookie was not written (only read)" do + response = response_for(app: session_id) + response["Set-Cookie"].must_be_nil + end + + it "returns even if not read/written if :expire_after is set" do + app = [nothing, { expire_after: 3600 }] + request = { "rack.session" => { "not" => "empty" } } + response = response_for(app: app, request: request) + response["Set-Cookie"].wont_be :nil? + end + + it "returns no cookie if no data was written and no session was created previously, even if :expire_after is set" do + app = [nothing, { expire_after: 3600 }] + response = response_for(app: app) + response["Set-Cookie"].must_be_nil + end + + it "exposes :secrets in env['rack.session.option']" do + response = response_for(app: [session_option[:secrets], { secrets: @secret }]) + response.body.must_equal @secret.inspect + end + + it "exposes :coder in env['rack.session.option']" do + response = response_for(app: session_option[:coder]) + response.body.must_match(/Base64::Marshal/) + end + + it 'exposes correct :coder when a secrets is used' do + response = response_for(app: session_option[:coder], secrets: @secret) + response.body.must_match(/Marshal/) + end + + it "allows passing in a hash with session data from middleware in front" do + request = { 'rack.session' => { foo: 'bar' } } + response = response_for(app: session_id, request: request) + response.body.must_match(/foo/) + end + + it "allows modifying session data with session data from middleware in front" do + request = { 'rack.session' => { foo: 'bar' } } + response = response_for(app: incrementor, request: request) + response.body.must_match(/counter/) + response.body.must_match(/foo/) + end + + it "allows more than one '--' in the cookie when calculating legacy digests" do + @counter = 0 + app = lambda do |env| + env["rack.session"]["message"] ||= "" + env["rack.session"]["message"] += "#{(@counter += 1).to_s}--" + hash = env["rack.session"].dup + hash.delete("session_id") + Rack::Response.new(hash["message"]).to_a + end + + # another example of an unsafe coder is Base64.urlsafe_encode64 + unsafe_coder = Class.new { + def encode(hash); hash.inspect end + def decode(str); eval(str) if str; end + }.new + + legacy_session = unsafe_coder.encode('message' => "#{@counter += 1}--#{@counter += 1}--", 'session_id' => 'abcdef') + legacy_secret = 'test legacy secret' + legacy_digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, legacy_secret, legacy_session) + legacy_cookie = "rack.session=#{Rack::Utils.escape legacy_session}--#{legacy_digest}; path=/; HttpOnly" + + _app = [ app, { + secrets: @secret, + legacy_hmac_secret: legacy_secret, + legacy_hmac_coder: unsafe_coder + }] + + response = response_for(app: _app, cookie: legacy_cookie) + response.body.must_equal "1--2--3--" + end + + it 'allows for non-strict encoded cookie' do + long_session_app = lambda do |env| + env['rack.session']['value'] = 'A' * 256 + env['rack.session']['counter'] = 1 + hash = env["rack.session"].dup + hash.delete("session_id") + Rack::Response.new(hash.inspect).to_a + end + + non_strict_coder = Class.new { + def encode(str) + [Marshal.dump(str)].pack('m') + end + + def decode(str) + return unless str + + Marshal.load(str.unpack('m').first) + end + }.new + + non_strict_response = response_for(app: [ + long_session_app, { coder: non_strict_coder } + ]) + + response = response_for(app: [ + incrementor + ], cookie: non_strict_response) + + response.body.must_match %Q["value"=>"#{'A' * 256}"] + response.body.must_match '"counter"=>2' + response.body.must_match(/\A{[^}]+}\z/) + end +end diff --git a/test/spec_session_encryptor.rb b/test/spec_session_encryptor.rb new file mode 100644 index 0000000..61fd732 --- /dev/null +++ b/test/spec_session_encryptor.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2023, by Samuel Williams. + +require_relative 'helper' +require 'rack/session/encryptor' + +describe Rack::Session::Encryptor do + def setup + @secret = SecureRandom.random_bytes(64) + end + + it 'initialize does not destroy key string' do + encryptor = Rack::Session::Encryptor.new(@secret) + + @secret.size.must_equal 64 + end + + it 'initialize raises ArgumentError on invalid key' do + lambda { Rack::Session::Encryptor.new [:foo] }.must_raise ArgumentError + end + + it 'initialize raises ArgumentError on short key' do + lambda { Rack::Session::Encryptor.new 'key' }.must_raise ArgumentError + end + + it 'decrypts an encrypted message' do + encryptor = Rack::Session::Encryptor.new(@secret) + + message = encryptor.encrypt(foo: 'bar') + + encryptor.decrypt(message).must_equal foo: 'bar' + end + + it 'decrypt raises InvalidSignature for tampered messages' do + encryptor = Rack::Session::Encryptor.new(@secret) + + message = encryptor.encrypt(foo: 'bar') + + decoded_message = Base64.urlsafe_decode64(message) + tampered_message = Base64.urlsafe_encode64(decoded_message.tap { |m| + m[m.size - 1] = (m[m.size - 1].unpack('C')[0] ^ 1).chr + }) + + lambda { + encryptor.decrypt(tampered_message) + }.must_raise Rack::Session::Encryptor::InvalidSignature + end + + it 'decrypts an encrypted message with purpose' do + encryptor = Rack::Session::Encryptor.new(@secret, purpose: 'testing') + + message = encryptor.encrypt(foo: 'bar') + + encryptor.decrypt(message).must_equal foo: 'bar' + end + + it 'decrypts raises InvalidSignature without purpose' do + encryptor = Rack::Session::Encryptor.new(@secret, purpose: 'testing') + other_encryptor = Rack::Session::Encryptor.new(@secret) + + message = other_encryptor.encrypt(foo: 'bar') + + lambda { encryptor.decrypt(message) }.must_raise Rack::Session::Encryptor::InvalidSignature + end + + it 'decrypts raises InvalidSignature with different purpose' do + encryptor = Rack::Session::Encryptor.new(@secret, purpose: 'testing') + other_encryptor = Rack::Session::Encryptor.new(@secret, purpose: 'other') + + message = other_encryptor.encrypt(foo: 'bar') + + lambda { encryptor.decrypt(message) }.must_raise Rack::Session::Encryptor::InvalidSignature + end + + it 'initialize raises ArgumentError on invalid pad_size' do + lambda { Rack::Session::Encryptor.new @secret, pad_size: :bar }.must_raise ArgumentError + end + + it 'initialize raises ArgumentError on to short pad_size' do + lambda { Rack::Session::Encryptor.new @secret, pad_size: 1 }.must_raise ArgumentError + end + + it 'initialize raises ArgumentError on to long pad_size' do + lambda { Rack::Session::Encryptor.new @secret, pad_size: 8023 }.must_raise ArgumentError + end + + it 'decrypts an encrypted message without pad_size' do + encryptor = Rack::Session::Encryptor.new(@secret, purpose: 'testing', pad_size: nil) + + message = encryptor.encrypt(foo: 'bar') + + encryptor.decrypt(message).must_equal foo: 'bar' + end + + it 'encryptor with pad_size increases message size' do + no_pad_encryptor = Rack::Session::Encryptor.new(@secret, purpose: 'testing', pad_size: nil) + pad_encryptor = Rack::Session::Encryptor.new(@secret, purpose: 'testing', pad_size: 64) + + message_without = Base64.urlsafe_decode64(no_pad_encryptor.encrypt('')) + message_with = Base64.urlsafe_decode64(pad_encryptor.encrypt('')) + message_size_diff = message_with.bytesize - message_without.bytesize + + message_with.bytesize.must_be :>, message_without.bytesize + message_size_diff.must_equal 64 - Marshal.dump('').bytesize - 2 + end + + it 'encryptor with pad_size has message payload size to multiple of pad_size' do + encryptor = Rack::Session::Encryptor.new(@secret, purpose: 'testing', pad_size: 24) + message = encryptor.encrypt(foo: 'bar' * 4) + + decoded_message = Base64.urlsafe_decode64(message) + + # slice 1 byte for version, 32 bytes for cipher_secret, 16 bytes for IV + # from the start of the string and 32 bytes at the end of the string + encrypted_payload = decoded_message[(1 + 32 + 16)..-33] + + (encrypted_payload.bytesize % 24).must_equal 0 + end + + # This test checks the one-time message key IS NOT used as the cipher key. + # Doing so would remove the confidentiality assurances as the key is + # essentially included in plaintext then. + it 'uses a secret cipher key for encryption and decryption' do + cipher = OpenSSL::Cipher.new('aes-256-ctr') + encryptor = Rack::Session::Encryptor.new(@secret) + + message = encryptor.encrypt(foo: 'bar') + raw_message = Base64.urlsafe_decode64(message) + + ver = raw_message.slice!(0, 1) + key = raw_message.slice!(0, 32) + iv = raw_message.slice!(0, 16) + + cipher.decrypt + cipher.key = key + cipher.iv = iv + + data = cipher.update(raw_message) << cipher.final + + # "data" should now be random bytes because we tried to decrypt a message + # with the wrong key + + padding_bytes, = data.unpack('v') # likely a large number + serialized_data = data.slice(2 + padding_bytes, data.bytesize) # likely nil + + lambda { Marshal.load serialized_data }.must_raise TypeError + end + + it 'it calls set_cipher_key with the correct key' do + encryptor = Rack::Session::Encryptor.new(@secret, purpose: 'testing', pad_size: 24) + message = encryptor.encrypt(foo: 'bar') + + message_key = Base64.urlsafe_decode64(message).slice(1, 32) + + callable = proc do |cipher, key| + key.wont_equal @secret + key.wont_equal message_key + + cipher.key = key + end + + encryptor.stub :set_cipher_key, callable do + encryptor.decrypt message + end + end +end diff --git a/test/spec_session_pool.rb b/test/spec_session_pool.rb new file mode 100644 index 0000000..e0970b3 --- /dev/null +++ b/test/spec_session_pool.rb @@ -0,0 +1,291 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2023, by Samuel Williams. + +require_relative 'helper' + +require 'rack/response' +require 'rack/mock' +require 'rack/utils' +require 'rack/lint' + +require_relative '../lib/rack/session/pool' + +describe Rack::Session::Pool do + session_key = Rack::Session::Pool::DEFAULT_OPTIONS[:key] + session_match = /#{session_key}=([0-9a-fA-F]+);/ + + incrementor = lambda do |env| + env["rack.session"]["counter"] ||= 0 + env["rack.session"]["counter"] += 1 + Rack::Response.new(env["rack.session"].inspect).to_a + end + + get_session_id = Rack::Lint.new(lambda do |env| + Rack::Response.new(env["rack.session"].inspect).to_a + end) + + nothing = Rack::Lint.new(lambda do |env| + Rack::Response.new("Nothing").to_a + end) + + drop_session = Rack::Lint.new(lambda do |env| + env['rack.session.options'][:drop] = true + incrementor.call(env) + end) + + renew_session = Rack::Lint.new(lambda do |env| + env['rack.session.options'][:renew] = true + incrementor.call(env) + end) + + defer_session = Rack::Lint.new(lambda do |env| + env['rack.session.options'][:defer] = true + incrementor.call(env) + end) + + incrementor = Rack::Lint.new(incrementor) + + it "creates a new cookie" do + pool = Rack::Session::Pool.new(incrementor) + res = Rack::MockRequest.new(pool).get("/") + res["Set-Cookie"].must_match(session_match) + res.body.must_equal '{"counter"=>1}' + end + + it "determines session from a cookie" do + pool = Rack::Session::Pool.new(incrementor) + req = Rack::MockRequest.new(pool) + cookie = req.get("/")["Set-Cookie"] + req.get("/", "HTTP_COOKIE" => cookie). + body.must_equal '{"counter"=>2}' + req.get("/", "HTTP_COOKIE" => cookie). + body.must_equal '{"counter"=>3}' + end + + it "survives nonexistent cookies" do + pool = Rack::Session::Pool.new(incrementor) + res = Rack::MockRequest.new(pool). + get("/", "HTTP_COOKIE" => "#{session_key}=blarghfasel") + res.body.must_equal '{"counter"=>1}' + end + + it "does not send the same session id if it did not change" do + pool = Rack::Session::Pool.new(incrementor) + req = Rack::MockRequest.new(pool) + + res0 = req.get("/") + cookie = res0["Set-Cookie"][session_match] + res0.body.must_equal '{"counter"=>1}' + pool.pool.size.must_equal 1 + + res1 = req.get("/", "HTTP_COOKIE" => cookie) + res1["Set-Cookie"].must_be_nil + res1.body.must_equal '{"counter"=>2}' + pool.pool.size.must_equal 1 + + res2 = req.get("/", "HTTP_COOKIE" => cookie) + res2["Set-Cookie"].must_be_nil + res2.body.must_equal '{"counter"=>3}' + pool.pool.size.must_equal 1 + end + + it "deletes cookies with :drop option" do + pool = Rack::Session::Pool.new(incrementor) + req = Rack::MockRequest.new(pool) + drop = Rack::Utils::Context.new(pool, drop_session) + dreq = Rack::MockRequest.new(drop) + + res1 = req.get("/") + session = (cookie = res1["Set-Cookie"])[session_match] + res1.body.must_equal '{"counter"=>1}' + pool.pool.size.must_equal 1 + + res2 = dreq.get("/", "HTTP_COOKIE" => cookie) + res2["Set-Cookie"].must_be_nil + res2.body.must_equal '{"counter"=>2}' + pool.pool.size.must_equal 0 + + res3 = req.get("/", "HTTP_COOKIE" => cookie) + res3["Set-Cookie"][session_match].wont_equal session + res3.body.must_equal '{"counter"=>1}' + pool.pool.size.must_equal 1 + end + + it "provides new session id with :renew option" do + pool = Rack::Session::Pool.new(incrementor) + req = Rack::MockRequest.new(pool) + renew = Rack::Utils::Context.new(pool, renew_session) + rreq = Rack::MockRequest.new(renew) + + res1 = req.get("/") + session = (cookie = res1["Set-Cookie"])[session_match] + res1.body.must_equal '{"counter"=>1}' + pool.pool.size.must_equal 1 + + res2 = rreq.get("/", "HTTP_COOKIE" => cookie) + new_cookie = res2["Set-Cookie"] + new_session = new_cookie[session_match] + new_session.wont_equal session + res2.body.must_equal '{"counter"=>2}' + pool.pool.size.must_equal 1 + + res3 = req.get("/", "HTTP_COOKIE" => new_cookie) + res3.body.must_equal '{"counter"=>3}' + pool.pool.size.must_equal 1 + + res4 = req.get("/", "HTTP_COOKIE" => cookie) + res4.body.must_equal '{"counter"=>1}' + pool.pool.size.must_equal 2 + end + + it "omits cookie with :defer option" do + pool = Rack::Session::Pool.new(incrementor) + defer = Rack::Utils::Context.new(pool, defer_session) + dreq = Rack::MockRequest.new(defer) + + res1 = dreq.get("/") + res1["Set-Cookie"].must_be_nil + res1.body.must_equal '{"counter"=>1}' + pool.pool.size.must_equal 1 + end + + it "can read the session with the legacy id" do + pool = Rack::Session::Pool.new(incrementor) + req = Rack::MockRequest.new(pool) + + res0 = req.get("/") + cookie = res0["Set-Cookie"] + session_id = Rack::Session::SessionId.new cookie[session_match, 1] + ses0 = pool.pool[session_id.private_id] + pool.pool[session_id.public_id] = ses0 + pool.pool.delete(session_id.private_id) + + res1 = req.get("/", "HTTP_COOKIE" => cookie) + res1["Set-Cookie"].must_be_nil + res1.body.must_equal '{"counter"=>2}' + pool.pool[session_id.private_id].wont_be_nil + end + + it "cannot read the session with the legacy id if allow_fallback: false option is used" do + pool = Rack::Session::Pool.new(incrementor, allow_fallback: false) + req = Rack::MockRequest.new(pool) + + res0 = req.get("/") + cookie = res0["Set-Cookie"] + session_id = Rack::Session::SessionId.new cookie[session_match, 1] + ses0 = pool.pool[session_id.private_id] + pool.pool[session_id.public_id] = ses0 + pool.pool.delete(session_id.private_id) + + res1 = req.get("/", "HTTP_COOKIE" => cookie) + res1["Set-Cookie"].wont_be_nil + res1.body.must_equal '{"counter"=>1}' + end + + it "drops the session in the legacy id as well" do + pool = Rack::Session::Pool.new(incrementor) + req = Rack::MockRequest.new(pool) + drop = Rack::Utils::Context.new(pool, drop_session) + dreq = Rack::MockRequest.new(drop) + + res0 = req.get("/") + cookie = res0["Set-Cookie"] + session_id = Rack::Session::SessionId.new cookie[session_match, 1] + ses0 = pool.pool[session_id.private_id] + pool.pool[session_id.public_id] = ses0 + pool.pool.delete(session_id.private_id) + + res2 = dreq.get("/", "HTTP_COOKIE" => cookie) + res2["Set-Cookie"].must_be_nil + res2.body.must_equal '{"counter"=>2}' + pool.pool[session_id.private_id].must_be_nil + pool.pool[session_id.public_id].must_be_nil + end + + it "passes through same_site option to session pool" do + pool = Rack::Session::Pool.new(incrementor, same_site: :none) + pool.same_site.must_equal :none + req = Rack::MockRequest.new(pool) + res = req.get("/") + res["Set-Cookie"].must_include "SameSite=None" + end + + it "allows using a lambda to specify same_site option, because some browsers require different settings" do + pool = Rack::Session::Pool.new(incrementor, same_site: lambda { |req, res| :none }) + req = Rack::MockRequest.new(pool) + res = req.get("/") + res["Set-Cookie"].must_include "SameSite=None" + + pool = Rack::Session::Pool.new(incrementor, same_site: lambda { |req, res| :lax }) + req = Rack::MockRequest.new(pool) + res = req.get("/") + res["Set-Cookie"].must_include "SameSite=Lax" + end + + # anyone know how to do this better? + it "should merge sessions when multithreaded" do + unless $DEBUG + 1.must_equal 1 + next + end + + warn 'Running multithread tests for Session::Pool' + pool = Rack::Session::Pool.new(incrementor) + req = Rack::MockRequest.new(pool) + + res = req.get('/') + res.body.must_equal '{"counter"=>1}' + cookie = res["Set-Cookie"] + sess_id = cookie[/#{pool.key}=([^,;]+)/, 1] + + delta_incrementor = lambda do |env| + # emulate disconjoinment of threading + env['rack.session'] = env['rack.session'].dup + Thread.stop + env['rack.session'][(Time.now.usec * rand).to_i] = true + incrementor.call(env) + end + tses = Rack::Utils::Context.new pool, delta_incrementor + treq = Rack::MockRequest.new(tses) + tnum = rand(7).to_i + 5 + r = Array.new(tnum) do + Thread.new(treq) do |run| + run.get('/', "HTTP_COOKIE" => cookie) + end + end.reverse.map{|t| t.run.join.value } + r.each do |resp| + resp['Set-Cookie'].must_equal cookie + resp.body.must_include '"counter"=>2' + end + + session = pool.pool[sess_id] + session.size.must_equal tnum + 1 # counter + session['counter'].must_equal 2 # meeeh + end + + it "does not return a cookie if cookie was not read/written" do + app = Rack::Session::Pool.new(nothing) + res = Rack::MockRequest.new(app).get("/") + res["Set-Cookie"].must_be_nil + end + + it "does not return a cookie if cookie was not written (only read)" do + app = Rack::Session::Pool.new(get_session_id) + res = Rack::MockRequest.new(app).get("/") + res["Set-Cookie"].must_be_nil + end + + it "returns even if not read/written if :expire_after is set" do + app = Rack::Session::Pool.new(nothing, expire_after: 3600) + res = Rack::MockRequest.new(app).get("/", 'rack.session' => { 'not' => 'empty' }) + res["Set-Cookie"].wont_be :nil? + end + + it "returns no cookie if no data was written and no session was created previously, even if :expire_after is set" do + app = Rack::Session::Pool.new(nothing, expire_after: 3600) + res = Rack::MockRequest.new(app).get("/") + res["Set-Cookie"].must_be_nil + end +end