Headline
CVE-2023-28846: GitHub - unpoly/unpoly-rails: Ruby on Rails bindings for Unpoly
Unpoly is a JavaScript framework for server-side web applications. There is a possible Denial of Service (DoS) vulnerability in the unpoly-rails
gem that implements the Unpoly server protocol for Rails applications. This issues affects Rails applications that operate as an upstream of a load balancer’s that uses passive health checks. The unpoly-rails
gem echoes the request URL as an X-Up-Location
response header. By making a request with exceedingly long URLs (paths or query string), an attacker can cause unpoly-rails to write a exceedingly large response header. If the response header is too large to be parsed by a load balancer downstream of the Rails application, it may cause the load balancer to remove the upstream from a load balancing group. This causes that application instance to become unavailable until a configured timeout is reached or until an active healthcheck succeeds. This issue has been fixed and released as version 2.7.2.2 which is available via RubyGems and GitHub. Users unable to upgrade may: Configure your load balancer to use active health checks, e.g. by periodically requesting a route with a known response that indicates healthiness; Configure your load balancer so the maximum size of response headers is at least twice the maximum size of a URL; or instead of changing your server configuration you may also configure your Rails application to delete redundant X-Up-Location
headers set by unpoly-rails.
unpoly-rails: Ruby on Rails bindings for Unpoly
Unpoly is an unobtrusive JavaScript framework for server-side web applications.
The unpoly-rails gem helps integrating Unpoly with Ruby on Rails applications.
This branch tracks the next major version, Unpoly 3.x.
If you’re using Unpoly 2.x, use the 2.x-stable branch.
If you’re using Unpoly 1.x or 0.x, use the 1.x-stable branch in the unpoly repository.
Installing the gem
Add the following line to your Gemfile:
Now run bundle install and restart your development server.
Installing frontend assets****With esbuild or Webpacker
If you’re using esbuild or Webpacker, install the unpoly npm package to get Unpoly’s frontend files.
Now import Unpoly from your application.js pack:
import ‘unpoly/unpoly.js’ import ‘unpoly/unpoly.css’
You may need to import additional files, e.g. when migrating from an old Unpoly version.
With the Asset Pipeline
If you’re using the Asset Pipeline, this unpoly-rails gem also contains Unpoly’s frontend files. The files are automatically added to the Asset Pipeline’s search path.
Add the following line to your application.js manifest:
Also add the following line to your application.css manifest:
You may need to require additional files, e.g. when migrating from an old Unpoly version.
Server protocol helpers
This unpoly-rails gem implements the optional server protocol by providing the following helper methods to your controllers, views and helpers.
Detecting a fragment update
Use up? to test whether the current request is a fragment update:
To retrieve the CSS selector that is being updated, use up.target:
up.target # => ‘.content’
The Unpoly frontend will expect an HTML response containing an element that matches this selector. Your Rails app is free to render a smaller response that only contains HTML matching the targeted selector. You may call up.target? to test whether a given CSS selector has been targeted:
if up.target?(‘.sidebar’) render(‘expensive_sidebar_partial’) end
Fragment updates may target different selectors for successful (HTTP status 200 OK) and failed (status 4xx or 5xx) responses. Use these methods to inspect the target for failed responses:
- up.fail_target: The CSS selector targeted for a failed response
- up.fail_target?(selector): Whether the given selector is targeted for a failed response
- up.any_target?(selector): Whether the given selector is targeted for either a successful or a failed response
Changing the render target
The server may instruct the frontend to render a different target by assigning a new CSS selector to the up.target property:
unless signed_in? up.target = ‘body’ render ‘sign_in’ end
The frontend will use the server-provided target for both successful (HTTP status 200 OK) and failed (status 4xx or 5xx) responses.
Rendering nothing
Sometimes it’s OK to render nothing, e.g. when you know that the current layer is to be closed.
In this case use head(:no_content):
class NotesController < ApplicationController def create @note = Note.new(note_params) if @note.save if up.layer.overlay? up.accept_layer(@note.id) head :no_content else redirect_to @note end end end end
Pushing a document title to the client
To force Unpoly to set a document title when processing the response:
up.title = ‘Title from server’
This is useful when you skip rendering the <head> in an Unpoly request.
Emitting events on the frontend
You may use up.emit to emit an event on the document after the fragment was updated:
class UsersController < ApplicationController
def show @user = User.find(params[:id]) up.emit('user:selected’, id: @user.id) end
end
If you wish to emit an event on the current layer instead of the document, use up.layer.emit:
class UsersController < ApplicationController
def show @user = User.find(params[:id]) up.layer.emit('user:selected’, id: @user.id) end
end
Detecting an Unpoly form validation
To test whether the current request is a form validation:
When detecting a validation request, the server is expected to validate (but not save) the form submission and render a new copy of the form with validation errors. A typical saving action should behave like this:
class UsersController < ApplicationController
def create user_params = params[:user].permit(:email, :password) @user = User.new(user_params) if up.validate? @user.valid? # run validations, but don’t save to the database render ‘form’ # render form with error messages elsif @user.save? sign_in @user else render 'form’, status: :bad_request end end
end
You may also access the names of the fields that triggered the validation request:
up.validate_names # => ['email’, ‘password’]
Detecting a fragment reload
When Unpoly reloads or polls a fragment, the server will often render the same HTML. You can configure your controller actions to only render HTML if the underlying content changed since an earlier request.
Only rendering when needed saves CPU time on your server, which spends most of its response time rendering HTML. This also reduces the bandwidth cost for a request/response exchange to ~1 KB.
When a fragment is reloaded, Unpoly sends an If-Modified-Since request header with the fragment’s earlier Last-Modified time. It also sends an If-None-Match header with the fragment’s earlier ETag.
Rails’ conditional GET support lets you compare and set modification times and ETags with methods like #fresh_when or #stale?:
class MessagesController < ApplicationController
def index @messages = current_user.messages.order(time: :desc)
\# If the request's ETag and last modification time matches the given \`@messages\`,
\# does not render and send a a \`304 Not Modified\` response.
\# If the request's ETag or last modification time does not match, we will render
\# the \`index\` view with fresh \`ETag\` and \`Last-Modified\` headers.
fresh\_when(@messages)
end
end
Allowing callbacks with a strict CSP
When your Content Security Policy disallows eval(), Unpoly cannot directly run callbacks HTML attributes. This affects [up-] attributes like [up-on-loaded] or [up-on-accepted]. See Unpoly’s CSP guide for details.
The following callback would crash the fragment update with an error like Uncaught EvalError: call to Function() blocked by CSP:
link_to 'Click me’, '/path, 'up-follow’: true, 'up-on-loaded’: "alert()"
Unpoly lets your work around this by prefixing your callback with your response’s CSP nonce:
link_to 'Click me’, '/path’, 'up-follow’: true, 'up-on-loaded’: 'nonce-kO52Iphm8BAVrcdGcNYjIA== alert()')
To keep your callbacks compact, you may use the up.safe_callback helper for this:
link_to 'Click me’, '/path, 'up-follow’: true, 'up-on-loaded’: up.safe_callback("alert()")
For this to work you must also include the <meta name="csp-nonce"> tag in the <head> of your initial page. Rails has a csp_meta_tag helper for that purpose.
Working with context
Calling up.context will return the context object of the targeted layer.
The context is a JSON object shared between the frontend and the server. It persists for a series of Unpoly navigation, but is cleared when the user makes a full page load. Different Unpoly layers will usually have separate context objects, although layers may choose to share their context scope.
You may read and change the context object. Changes will be sent to the frontend with your response.
class GamesController < ApplicationController
def restart up.context[:lives] = 3 render ‘stage1’ end
end
Keys can be accessed as either strings or symbols:
puts “You have " + up.layer.context[:lives] + " lives left” puts “You have " + up.layer.context[‘lives’] + " lives left”
You may delete a key from the frontend by calling up.context.delete:
You may replace the entire context by calling up.context.replace:
context_from_file = JSON.parse(File.read('context.json)) up.context.replace(context_from_file)
up.context is an alias for up.layer.context.
Accessing the targeted layer
Use the methods below to interact with the layer of the fragment being targeted.
Note that fragment updates may target different layers for successful (HTTP status 200 OK) and failed (status 4xx or 5xx) responses.
up.layer.mode
Returns the mode of the targeted layer (e.g. “root” or “modal”).
up.layer.root?
Returns whether the targeted layer is the root layer.
up.layer.overlay?
Returns whether the targeted layer is an overlay (not the root layer).
up.layer.context
Returns the context object of the targeted layer. See documentation for up.context, which is an alias for up.layer.context.
up.layer.accept(value)
Accepts the current overlay.
Does nothing if the root layer is targeted.
Note that Rails expects every controller action to render or redirect. Your action should either call up.render_nothing or respond with text/html content matching the requested target.
up.layer.dismiss(value)
Dismisses the current overlay.
Does nothing if the root layer is targeted.
Note that Rails expects every controller action to render or redirect. Your action should either call up.render_nothing or respond with text/html content matching the requested target.
up.layer.emit(type, options)
Emits an event on the targeted layer.
up.fail_layer.mode
Returns the mode of the layer targeted for a failed response.
up.fail_layer.root?
Returns whether the layer targeted for a failed response is the root layer.
up.fail_layer.overlay?
Returns whether the layer targeted for a failed response is an overlay.
up.fail_layer.context
Returns the context object of the layer targeted for a failed response.
Expiring the client-side cache
The Unpoly frontend caches server responses for a few minutes, making requests to these URLs return instantly. Only GET requests are cached. The entire cache is expired after every non-GET request (like POST or PUT).
The server may override these defaults. For instance, the server can expire Unpoly’s client-side response cache, even for GET requests:
You may also expire a single URL or URL pattern:
up.cache.expire(‘/notes/*’)
You may also prevent cache expiration for an unsafe request:
Here is an longer example where the server uses careful cache management to avoid expiring too much of the client-side cache:
def NotesController < ApplicationController
def create @note = Note.create!(params[:note].permit(…)) if @note.save up.cache.expire(‘/notes/*’) # Only expire affected entries redirect_to(@note) else up.cache.expire(false) # Keep the cache fresh because we haven’t saved render ‘new’ end end … end
Evicting pages from the client-side cache
Instead of expiring pages from the cache you may also evict. The difference is that expired pages can still be rendered instantly and are then revalidated with the server. Evicted pages are erased from the cache.
You may also expire all entries matching an URL pattern:
To evict the entire client-side cache:
You may also evict a single URL or URL pattern:
up.cache.evict(‘/notes/*’)
Unpoly headers are preserved through redirects
unpoly-rails patches redirect_to so Unpoly-related request and response headers are preserved for the action you redirect to.
Accessing Unpoly request headers automatically sets a Vary response header
Accessing Unpoly-related request headers through helper methods like up.target will automatically add a Vary response header. This is to indicate that the request header influenced the response and the response should be cached separately for each request header value.
For example, a controller may access the request’s X-Up-Mode through the up.layer.mode helper:
def create # …
if up.layer.mode == ‘modal’ # Sets Vary header up.layer.accept else redirect_to :show end end
unpoly-rails will automatically add a Vary header to the response:
There are cases when reading an Unpoly request header does not necessarily influence the response, e.g. for logging. In that cases no Vary header should be set. To do so, call the helper method inside an up.no_vary block:
up.no_vary do Rails.logger.info("Unpoly mode is " + up.layer.mode.inspect) # No Vary header is set end
Note that accessing response.headers[] directly never sets a Vary header:
Rails.logger.info("Unpoly mode is " + response.headers[‘X-Up-Mode’]) # No Vary header is set
Automatic redirect detection
unpoly-rails installs a before_action into all controllers which echoes the request’s URL as a response header X-Up-Location and the request’s HTTP method as X-Up-Method.
Automatic method detection for initial page load
unpoly-rails sets an _up_method cookie that Unpoly needs to detect the request method for the initial page load.
If the initial page was loaded with a non-GET HTTP method, Unpoly will fall back to full page loads for all actions that require pushState.
The reason for this is that some browsers remember the method of the initial page load and don’t let the application change it, even with pushState. Thus, when the user reloads the page much later, an affected browser might request a POST, PUT, etc. instead of the correct method.
What you still need to do manually****Failed form submissions must return a non-200 status code
Unpoly lets you submit forms via AJAX by using the form[up-follow] selector or up.submit() function.
For Unpoly to be able to detect a failed form submission, the form must be re-rendered with a non-200 HTTP status code. We recommend to use either 400 (bad request) or 422 (unprocessable entity).
To do so in Rails, pass a :status option to render:
class UsersController < ApplicationController
def create user_params = params[:user].permit(:email, :password) @user = User.new(user_params) if @user.save? sign_in @user else render 'form’, status: :bad_request end end
end
Development****Before you make a PR
Before you create a pull request, please have some discussion about the proposed change by opening an issue on GitHub.
Running tests
- Install the Ruby version from .ruby-version (currently 2.3.8)
- Install Bundler by running gem install bundler
- Install dependencies by running bundle install
- Run bundle exec rspec
The tests run against a minimal Rails app that lives in spec/dummy.
Making a new release
Install the unpoly-rails and unpoly repositories into the same parent folder:
projects/
unpoly/
unpoly-rails/
During development unpoly-rails will use assets from the folder assets/unpoly-dev, which is symlinked against the dist folder of the ``unpoly` repo.
Before packaging the gem, a rake task will copy symlinked files assets/unpoly-dev/* to assets/unpoly/*. The latter is packaged into the gem and distributed.
projects/
unpoly/
dist/
unpoly.js
unpoly.css
unpoly-rails
assets/
unpoly-dev -> ../../unpoly/dist
unpoly.js -> ../../unpoly/dist/unpoly.js
unpoly.css -> ../../unpoly/dist/unpoly.css
unpoly
unpoly.js
unpoly.css
Making a new release of unpoly-rails involves the following steps:
- Make a new build of unpoly (npm run build)
- Make a new release of the unpoly npm package
- Bump the version in lib/unpoly/rails/version.rb to match that in Unpoly’s package.json
- Commit and push the changes
- Run rake gem:release
Related news
There is a possible Denial of Service (DoS) vulnerability in the unpoly-rails gem that implements the [Unpoly server protocol](https://unpoly.com/up.protocol) for Rails applications. ### Impact This issues affects Rails applications that operate as an upstream of a load balancer's that uses [passive health checks](https://docs.nginx.com/nginx/admin-guide/load-balancer/http-health-check/#passive-health-checks). The [unpoly-rails](https://github.com/unpoly/unpoly-rails/) gem echoes the request URL as an `X-Up-Location` response header. By making a request with exceedingly long URLs (paths or query string), an attacker can cause unpoly-rails to write a exceedingly large response header. If the response header is too large to be parsed by a load balancer downstream of the Rails application, it may cause the load balancer to remove the upstream from a load balancing group. This causes that application instance to become unavailable until a configured timeout is reached or until an activ...