repost 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41b967cb4d60089de6bdb34b27fa765201b3216186c7da3d0259b4ed18694ac4
4
- data.tar.gz: a348d14926f1b1df688dd7d8bca93ad3f721f7f4259f366e6075447e48d63882
3
+ metadata.gz: 13ce32e38cce21aa7a91ca98660f2aa14472e8a909cb038c8d232d4b3ebcdc11
4
+ data.tar.gz: 1dc85d08c2671b04b7c6b59e87f5f56ef70680cce4e180f8c1251af23645b717
5
5
  SHA512:
6
- metadata.gz: fcf88d8239d00a462d962b4659f6cb29c1ed85027941a73d96b06d014e76d019745318fb2a5d535d1c52051b3ed69f86f7fa7430066d12b8eaed38a4ce11fa32
7
- data.tar.gz: b1e9ac130d2b5e8d2ae213da35ce40a3a5b6ffe468a7b536a9390935792cbb00d15d3a414f530810578ff515139101e74976c5915b18f79f56c8a551c64fa969
6
+ metadata.gz: 50a8cde0aa5fbc95f090b53be99062f2faaa02a28aa3f49395fd7f70b53005c22e47a72bcb21b37becab3431b3a365607491514d9e76d59d3eff612ce3536820
7
+ data.tar.gz: 6edb7e357589700615ee12ac0f6b7582d8bfc6d3c06ce18d83d11e8663538f86126f291d7d68007f12711a66cece65e08782ae4213298ed3317ba2b7de001eed
data/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ ## 0.6.0 (2026-11-05)
4
+
5
+ ### Security
6
+ - The target URL must be `http`, `https` or relative. `javascript:`, `data:` and other schemes now raise `Repost::UnsafeUrlError`, because escaping can't make them safe. Allow more schemes with `options: { allowed_schemes: %w[https myapp] }`.
7
+
8
+ ### Changed
9
+ - All markup is built by one helper that escapes every attribute value, so a value can't be left unescaped by mistake. The output is the same apart from formatting: the autosubmit script is on one line, and the CSRF input lists `type` first.
10
+ - The Rails helpers now live in `Repost::ExtendController`, included into `ActionController::Base` through `on_load(:action_controller_base)`. Before, the gem reopened `ActionController::Base` from a hook that also ran for API controllers.
11
+ - No longer requires the `cgi` library, which prints a warning on Ruby 4.0.
12
+
13
+ ### Added
14
+ - Rails integration specs, run on Rails 7.1 through 8.x.
15
+
16
+ ## 0.5.3 (2026-11-05)
17
+ - Fixed autosubmit failing when a param is named `submit`: the hidden input shadowed `form.submit`, leaving the user on a blank page.
18
+ - Fixed autosubmit failing when `form_id` contains `"`, `&`, `<` or `>`.
19
+ - CI moved from Travis to GitHub Actions (Ruby 2.7 to 4.0).
20
+
21
+ ## 0.5.2 (2026-07-03)
22
+ - `method: :put`, `:patch` and `:delete` are sent as POST with a hidden `_method` field (Rails-style override).
23
+ - An explicit `autosubmit_nonce`, including `nil`, overrides the Rails CSP nonce. The Rails helper no longer modifies the options hash passed to it.
24
+ - `class` attributes are left out when no decor classes are set. Generated form ids are random instead of timestamp-based.
25
+
26
+ ## 0.5.1 (2026-01-28)
27
+
28
+ ### Security
29
+ - Fixed cross-site scripting ([#29](https://github.com/vergilet/repost/issues/29)): the URL, param names and values, and all options are now HTML-escaped. All earlier versions are affected; please upgrade.
30
+
31
+ ## 0.5.0 (2026-01-28)
32
+ - Attempted fix for [#29](https://github.com/vergilet/repost/issues/29) by sanitizing the output of the Rails helper. This also stripped the form, so `repost` and `redirect_post` stopped working in Rails. Use 0.5.1 or later.
33
+
34
+ ## 0.4.2 and earlier
35
+ See the [commit history](https://github.com/vergilet/repost/commits/master).
data/README.md CHANGED
@@ -50,8 +50,8 @@ I faced with this problem when was dealing with bank transactions. You can see t
50
50
  <img src="https://user-images.githubusercontent.com/2478436/55143646-d0da3500-5147-11e9-91a3-1bac9d560fb2.png"></a>
51
51
  </p>
52
52
 
53
- > **P.S. The `repost` gem was initially created in response to the redirection process required by [Adyen 3D Secure 1](https://docs.adyen.com/online-payments/classic-integrations/api-integration-ecommerce/3d-secure/3d-secure-1/#step-2-redirect-to-the-card-issuer), which often involved creating an HTML form and submitting it via POST. <br>
54
- However, with the advent of 3D Secure 2, which aims for a more integrated authentication experience, the use of such forms for POST submissions has not been encountered. 3D Secure 2 typically manages authentication data exchanges in the background, potentially eliminating the need for manual form submission.**
53
+ > **P.S. The `repost` gem was initially created for the redirect required by [Adyen 3D Secure 1](https://docs.adyen.com/online-payments/classic-integrations/api-integration-ecommerce/3d-secure/3d-secure-1/#step-2-redirect-to-the-card-issuer), which meant building an HTML form and submitting it via POST.** <br>
54
+ The same pattern is still needed wherever another site expects the browser to arrive with a POST: payment pages and 3D Secure 2 browser challenges, SAML's HTTP-POST binding, OpenID Connect's `form_post` response mode, and partner logins.
55
55
 
56
56
 
57
57
  ## Usage
@@ -143,6 +143,7 @@ redirect_post('http://examp.io/endpoint', # URL, looks understandable
143
143
  form_id: 'CustomFormID', # OPTIONAL - DEFAULT is autogenerated
144
144
  autosubmit: false, # OPTIONAL - DEFAULT is true, if you want to get a confirmation for redirect
145
145
  autosubmit_nonce: '1d3n7i4ier', # RAILS - DEFAULT is content_security_policy_nonce, for pure Ruby - string identifier, more info - https://edgeguides.rubyonrails.org/security.html#content-security-policy
146
+ allowed_schemes: %w[https myapp], # OPTIONAL - DEFAULT is %w[http https]. Relative URLs are always allowed; other schemes raise Repost::UnsafeUrlError
146
147
  decor: { # If autosubmit is turned off or Javascript is disabled on client
147
148
  section: { # ... you can decorate confirmation section and button
148
149
  classes: 'red-bg red-text', # OPTIONAL - <DIV> section, set classNames, separate with space
@@ -177,6 +178,14 @@ Currently you can pass the **authenticity token** in two ways:
177
178
  ```
178
179
 
179
180
 
181
+ ## Security
182
+
183
+ - Every value repost writes into the page (URL, param names and values, all options) is HTML-escaped, so it's safe to pass user input. The one exception is `decor: { section: { html: ... } }`, which is inserted as-is by design: never put user input there.
184
+ - Only `http`, `https` and relative target URLs are allowed by default. `javascript:`, `data:` and other schemes raise `Repost::UnsafeUrlError`, because escaping can't make them safe. Use the `allowed_schemes` option to allow others.
185
+ - Versions before **0.5.1** don't escape these values and allow cross-site scripting ([#29](https://github.com/vergilet/repost/issues/29)). Please upgrade.
186
+
187
+ See [CHANGELOG.md](CHANGELOG.md) for what changed in each version.
188
+
180
189
 
181
190
  ## License
182
191
  The gem is available as open source under the terms of the MIT License.
@@ -1,36 +1,28 @@
1
- if defined?(Rails) && defined?(ActiveSupport)
2
- ActiveSupport.on_load(:action_controller) do
3
- class ::ActionController::Base
4
- def repost(url, params: {}, options: {})
5
- options = options.dup
6
- status = options.delete(:status) || :ok
7
- authenticity_token = if ['auto', :auto].include?(options[:authenticity_token])
8
- form_authenticity_token
9
- else
10
- options[:authenticity_token]
11
- end
12
- # Rails' per-request CSP nonce is the default; an explicitly passed
13
- # :autosubmit_nonce (even nil, to opt out) takes precedence.
14
- autosubmit_nonce = options.fetch(:autosubmit_nonce) { content_security_policy_nonce }
15
-
16
- html_payload = Repost::Senpai.perform(
17
- url,
18
- params: params,
19
- options: options.merge({
20
- authenticity_token: authenticity_token,
21
- autosubmit_nonce: autosubmit_nonce
22
- }.compact)
23
- )
24
-
25
- render html: html_payload.html_safe, status: status
1
+ module Repost
2
+ # Adds #repost and #redirect_post to Rails controllers. Included into
3
+ # ActionController::Base when it loads (not into ActionController::API).
4
+ module ExtendController
5
+ def repost(url, params: {}, options: {})
6
+ options = options.dup
7
+ status = options.delete(:status) || :ok
8
+ if ['auto', :auto].include?(options[:authenticity_token])
9
+ options[:authenticity_token] = form_authenticity_token
10
+ end
11
+ # Rails' per-request CSP nonce is the default; an explicitly passed
12
+ # :autosubmit_nonce (even nil, to opt out) takes precedence.
13
+ unless options.key?(:autosubmit_nonce)
14
+ options[:autosubmit_nonce] = content_security_policy_nonce if respond_to?(:content_security_policy_nonce, true)
26
15
  end
27
16
 
28
- alias :redirect_post :repost
17
+ # html_safe is correct here: Senpai escapes every value it inserts
18
+ # (except decor section html, which is raw by design).
19
+ render html: Repost::Senpai.perform(url, params: params, options: options).html_safe, status: status
29
20
  end
21
+
22
+ alias redirect_post repost
30
23
  end
31
24
  end
32
25
 
33
- # Sinatra & Rack Protection
34
- # TODO
35
- # defined?(Sinatra::Base) && defined?(Rack::Protection::AuthenticityToken)
36
- # env&.fetch('rack.session', :csrf)
26
+ if defined?(ActiveSupport) && ActiveSupport.respond_to?(:on_load)
27
+ ActiveSupport.on_load(:action_controller_base) { include Repost::ExtendController }
28
+ end
data/lib/repost/senpai.rb CHANGED
@@ -1,127 +1,124 @@
1
- require 'cgi'
2
1
  require 'json'
3
2
  require 'securerandom'
4
3
 
5
4
  module Repost
5
+ # Builds an HTML form that submits itself, so a browser can be "redirected"
6
+ # with a POST body. All markup comes from #tag, which escapes every
7
+ # attribute value, so no call site can forget to escape.
6
8
  class Senpai < Action
7
9
  DEFAULT_SUBMIT_BUTTON_TEXT = 'Continue'
8
10
  DEFAULT_CHARSET = 'UTF-8'
11
+ DEFAULT_ALLOWED_SCHEMES = %w[http https].freeze
9
12
  # Browsers only submit forms via GET or POST; other verbs are sent as POST
10
13
  # with a Rails-style "_method" override field.
11
14
  OVERRIDE_METHODS = %w[put patch delete].freeze
15
+ HTML_ESCAPES = { '&' => '&amp;', '<' => '&lt;', '>' => '&gt;', '"' => '&quot;', "'" => '&#39;' }.freeze
16
+ # Characters that must not appear raw in a JS string inside <script>.
17
+ JS_UNSAFE = /[<>&\u{2028}\u{2029}]/.freeze
12
18
 
13
19
  def initialize(url, params: {}, options: {})
14
- @url = url
15
- @params = params
16
- @options = options
17
- @method = options.fetch(:method, :post).to_s.downcase
20
+ @url = url.to_s
21
+ @params = params || {}
22
+ @http_method = options.fetch(:method, :post).to_s.downcase
18
23
  @authenticity_token = options.fetch(:authenticity_token, nil)
19
24
  @charset = options.fetch(:charset, DEFAULT_CHARSET)
20
- @form_id = options.fetch(:form_id, generated_form_id)
25
+ @form_id = options.fetch(:form_id) { generated_form_id }
21
26
  @autosubmit = options.fetch(:autosubmit, true)
22
27
  @autosubmit_nonce = options.fetch(:autosubmit_nonce, nil)
28
+ @allowed_schemes = Array(options.fetch(:allowed_schemes, DEFAULT_ALLOWED_SCHEMES)).map { |s| s.to_s.downcase }
23
29
  @section_classes = options.dig(:decor, :section, :classes)
24
30
  @section_html = options.dig(:decor, :section, :html)
25
31
  @submit_classes = options.dig(:decor, :submit, :classes)
26
32
  @submit_text = options.dig(:decor, :submit, :text) || DEFAULT_SUBMIT_BUTTON_TEXT
33
+ check_url_scheme!
27
34
  end
28
35
 
29
36
  def perform
30
- compiled_body = if autosubmit
31
- form_body << auto_submit_script << no_script
32
- else
33
- form_body << submit_section
34
- end
35
- form_head << compiled_body << form_footer
37
+ controls = autosubmit ? auto_submit_script + tag(:noscript, submit_section) : submit_section
38
+ tag(:form, form_inputs + controls,
39
+ id: form_id, action: url, method: form_method, 'accept-charset': charset)
36
40
  end
37
41
 
38
42
  private
39
43
 
40
- attr_reader :url, :params, :options, :method, :form_id, :autosubmit,
44
+ attr_reader :url, :params, :http_method, :form_id, :autosubmit, :allowed_schemes,
41
45
  :section_classes, :section_html, :submit_classes,
42
46
  :submit_text, :authenticity_token, :charset, :autosubmit_nonce
43
47
 
48
+ # The only place markup is produced. Attribute values are always escaped
49
+ # (nil drops the attribute); content is markup built by this class, or
50
+ # decor section html, which is raw by design.
51
+ def tag(name, content = nil, **attributes)
52
+ attrs = attributes.compact.map { |key, value| %( #{key}="#{escape(value)}") }.join
53
+ content.nil? ? "<#{name}#{attrs}>" : "<#{name}#{attrs}>#{content}</#{name}>"
54
+ end
55
+
44
56
  def escape(value)
45
- CGI.escapeHTML(value.to_s)
57
+ value.to_s.gsub(/[&<>"']/, HTML_ESCAPES)
46
58
  end
47
59
 
48
- def form_head
49
- %Q(<form id="#{escape(form_id)}" action="#{escape(url)}" method="#{escape(form_method)}" accept-charset="#{escape(charset)}">)
60
+ # Escaping can't make a javascript: or data: URL safe, so only http(s)
61
+ # and relative URLs are allowed unless the caller allows more schemes.
62
+ def check_url_scheme!
63
+ # Browsers ignore leading whitespace/control characters and drop tabs
64
+ # and newlines anywhere, so "java\nscript:" still runs as javascript:.
65
+ normalized = url.gsub(/[\t\n\r]/, '').sub(/\A[\x00-\x20]+/, '')
66
+ scheme = normalized[/\A([a-z][a-z0-9+.\-]*):/i, 1]
67
+ return if scheme.nil? || allowed_schemes.include?(scheme.downcase)
68
+
69
+ raise UnsafeUrlError,
70
+ "repost refuses to submit a form to a #{scheme}: URL. Allowed schemes: " \
71
+ "#{allowed_schemes.join(', ')} (change with options[:allowed_schemes])"
50
72
  end
51
73
 
52
74
  def form_method
53
- method_override? ? 'post' : method
75
+ method_override? ? 'post' : http_method
54
76
  end
55
77
 
56
78
  def method_override?
57
- OVERRIDE_METHODS.include?(method)
79
+ OVERRIDE_METHODS.include?(http_method)
58
80
  end
59
81
 
60
- def form_body
61
- inputs = params.map do |key, value|
62
- form_input(key, value)
63
- end
64
- inputs.unshift(csrf_token) if authenticity_token
65
- inputs.unshift(method_override_input) if method_override?
82
+ def form_inputs
83
+ inputs = []
84
+ inputs << hidden_input('_method', http_method) if method_override?
85
+ inputs << hidden_input('authenticity_token', authenticity_token) if authenticity_token
86
+ params.each { |key, value| inputs << form_input(key, value) }
66
87
  inputs.join
67
88
  end
68
89
 
69
- def method_override_input
70
- %Q(<input type="hidden" name="_method" value="#{escape(method)}">)
71
- end
72
-
73
- def form_input(key, value)
90
+ def form_input(name, value)
74
91
  case value
75
- when Hash
76
- value.map do |inner_key, inner_value|
77
- form_input("#{key}[#{inner_key}]", inner_value)
78
- end.join
79
- when Array
80
- value.map do |inner_value|
81
- form_input("#{key}[]", inner_value)
82
- end.join
83
- else
84
- %Q(<input type="hidden" name="#{escape(key)}" value="#{escape(value)}">)
92
+ when Hash then value.map { |key, inner| form_input("#{name}[#{key}]", inner) }.join
93
+ when Array then value.map { |inner| form_input("#{name}[]", inner) }.join
94
+ else hidden_input(name, value)
85
95
  end
86
96
  end
87
97
 
88
- def form_footer
89
- %Q(</form>)
90
- end
91
-
92
- def csrf_token
93
- %Q(<input name="authenticity_token" value="#{escape(authenticity_token)}" type="hidden">)
94
- end
95
-
96
- def no_script
97
- %Q(<noscript>#{submit_section}</noscript>)
98
+ def hidden_input(name, value)
99
+ tag(:input, type: 'hidden', name: name, value: value.to_s)
98
100
  end
99
101
 
100
102
  def submit_section
101
- %Q(<div#{class_attr(section_classes)}>#{section_html}<input#{class_attr(submit_classes)} type="submit" value="#{escape(submit_text)}"></div>)
102
- end
103
-
104
- def class_attr(classes)
105
- %Q( class="#{escape(classes)}") if classes
106
- end
107
-
108
- def generated_form_id
109
- "repost-#{SecureRandom.hex(8)}"
103
+ button = tag(:input, class: submit_classes, type: 'submit', value: submit_text)
104
+ tag(:div, "#{section_html}#{button}", class: section_classes)
110
105
  end
111
106
 
112
107
  # Calls the prototype's submit() so a param named "submit" (which the
113
108
  # browser exposes as form.submit, shadowing the method) can't break it.
114
109
  def auto_submit_script
115
- nonce_attr = %Q( nonce="#{escape(autosubmit_nonce)}") if autosubmit_nonce
116
- %Q(<script#{nonce_attr}>
117
- HTMLFormElement.prototype.submit.call(document.getElementById(#{js_string(form_id)}));
118
- </script>)
110
+ tag(:script, "HTMLFormElement.prototype.submit.call(document.getElementById(#{js_string(form_id)}));",
111
+ nonce: autosubmit_nonce)
119
112
  end
120
113
 
121
114
  # A JS string literal that is also safe inside an HTML <script> element.
122
115
  # HTML escaping is wrong here: entities are not decoded inside <script>.
123
116
  def js_string(value)
124
- JSON.generate(value.to_s).gsub(/[<>&

]/) { |char| format('\u%04x', char.ord) }
117
+ JSON.generate(value.to_s).gsub(/[<>&

]/JS_UNSAFE) { |char| format('\u%04x', char.ord) }
118
+ end
119
+
120
+ def generated_form_id
121
+ "repost-#{SecureRandom.hex(8)}"
125
122
  end
126
123
  end
127
124
  end
@@ -1,3 +1,3 @@
1
1
  module Repost
2
- VERSION = "0.5.3"
2
+ VERSION = "0.6.0"
3
3
  end
data/lib/repost.rb CHANGED
@@ -1,9 +1,10 @@
1
- require "repost/version"
2
- require "repost/action"
3
- require "repost/senpai"
4
- require "repost/extend_controller"
5
-
6
1
  module Repost
7
2
  class Error < StandardError; end
3
+ # Raised when the target URL uses a scheme that isn't allowed (javascript:, data:, ...).
4
+ class UnsafeUrlError < Error; end
8
5
  end
9
6
 
7
+ require "repost/version"
8
+ require "repost/action"
9
+ require "repost/senpai"
10
+ require "repost/extend_controller"
metadata CHANGED
@@ -1,15 +1,35 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: repost
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.3
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - YaroslavO
8
8
  autorequire:
9
- bindir: exe
9
+ bindir: bin
10
10
  cert_chain: []
11
11
  date: 2026-11-05 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: actionpack
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.1'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9'
23
+ type: :development
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '7.1'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9'
13
33
  - !ruby/object:Gem::Dependency
14
34
  name: rake
15
35
  requirement: !ruby/object:Gem::Requirement
@@ -46,9 +66,9 @@ executables: []
46
66
  extensions: []
47
67
  extra_rdoc_files: []
48
68
  files:
69
+ - CHANGELOG.md
49
70
  - LICENSE.txt
50
71
  - README.md
51
- - Rakefile
52
72
  - lib/repost.rb
53
73
  - lib/repost/action.rb
54
74
  - lib/repost/extend_controller.rb
@@ -58,8 +78,9 @@ homepage: https://github.com/vergilet/repost
58
78
  licenses:
59
79
  - MIT
60
80
  metadata:
61
- homepage_uri: https://github.com/vergilet/repost
62
81
  source_code_uri: https://github.com/vergilet/repost
82
+ changelog_uri: https://github.com/vergilet/repost/blob/master/CHANGELOG.md
83
+ bug_tracker_uri: https://github.com/vergilet/repost/issues
63
84
  post_install_message:
64
85
  rdoc_options: []
65
86
  require_paths:
data/Rakefile DELETED
@@ -1,6 +0,0 @@
1
- require "bundler/gem_tasks"
2
- require "rspec/core/rake_task"
3
-
4
- RSpec::Core::RakeTask.new(:spec)
5
-
6
- task :default => :spec