Skip to content

Commit db3e3e9

Browse files
ericproulxclaudedblock
authored
Add Grape::Mountable marker to identify a Grape app (#2793)
* Add Grape::Mountable marker to identify a Grape app Grape distinguished a mounted Grape app from a bare Rack app by duck-typing on an incidental internal method (`respond_to?(:inheritable_setting)`). That is brittle: Grape::API and Grape::API::Instance share no common ancestor and don't even respond to the same methods (mount_instance vs inheritable_setting/endpoints), so there was no single predicate and the checks keyed on a settings method that has nothing to do with mountability. Introduce a Grape::Mountable marker extended by both Grape::API and Grape::API::Instance, giving one explicit `is_a?(Grape::Mountable)` predicate. Route the identity checks through it: Endpoint#bare_rack_app? (used for forward_match) and the mount branch in DSL::Routing. Capability checks that go on to call a stage-specific method are left as respond_to? on purpose (a Mountable does not respond to every such method): `respond_to?(:mount_instance)` still gates the remountable-class stage, `respond_to?(:endpoints)` still guards reading endpoints, and the settings inheritance check still guards calling inheritable_setting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Harden mounted-app auth surface and error HTML escaping Two security-relevant fixes surfaced by a review of Grape::API: - Grape::Middleware::Error#rack_response only escaped the error message when the response content-type was exactly 'text/html', so a parameterized value such as 'text/html; charset=utf-8' skipped escaping and could reflect an unescaped message into an HTML response. Compare the media type only. - A bare Rack app mounted with `mount` is called directly and never goes through the endpoint middleware stack, so an API's authentication middleware does not wrap it and the mount is reachable unauthenticated. Mounted Grape APIs are unaffected. Warn at compile time when a bare Rack app is mounted under configured authentication so the bypass isn't silent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Daniel (dB.) Doubrovkine <dblock@dblock.org>
1 parent d713797 commit db3e3e9

11 files changed

Lines changed: 180 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* [#2782](https://github.com/ruby-grape/grape/pull/2782): Remove the dead `format` keyword from `Grape::Router::Pattern` - [@ericproulx](https://github.com/ericproulx).
1313
* [#2783](https://github.com/ruby-grape/grape/pull/2783): Read a route's `version`, `anchor` and `requirements` from its pattern instead of storing them again on the route - [@ericproulx](https://github.com/ericproulx).
1414
* [#2784](https://github.com/ruby-grape/grape/pull/2784): Move HEAD route creation into `Grape::Router::Route#to_head` - [@ericproulx](https://github.com/ericproulx).
15+
* [#2793](https://github.com/ruby-grape/grape/pull/2793): Add a `Grape::Mountable` marker to identify a Grape app instead of duck-typing on `respond_to?(:inheritable_setting)` - [@ericproulx](https://github.com/ericproulx).
1516
* [#2792](https://github.com/ruby-grape/grape/pull/2792): Move `Grape::Path` to `Grape::Router::Pattern::Path` and add a `Grape::Router::Pattern.build` factory (`Grape::Path` kept as a deprecated constant) - [@ericproulx](https://github.com/ericproulx).
1617
* [#2785](https://github.com/ruby-grape/grape/pull/2785): Make route `params` a first-class endpoint input and split `Grape::Router::Route#params` into `#params` (declared definitions) and `#params_for(input)` (extracted values) - [@ericproulx](https://github.com/ericproulx).
1718
* [#2786](https://github.com/ruby-grape/grape/pull/2786): Make route `requirements` and `anchor` explicit keyword arguments and first-class endpoint inputs instead of opaque `route_options` keys - [@ericproulx](https://github.com/ericproulx).
@@ -20,6 +21,7 @@
2021
#### Fixes
2122

2223
* [#2767](https://github.com/ruby-grape/grape/pull/2767): Update rubocop to 1.88.0 and rubocop-rspec to 3.10.2 - [@ericproulx](https://github.com/ericproulx).
24+
* [#2789](https://github.com/ruby-grape/grape/pull/2789): Escape error messages served with a parameterized `text/html` content-type (e.g. `text/html; charset=utf-8`) and warn when a bare Rack app is mounted under authentication - [@ericproulx](https://github.com/ericproulx).
2325
* [#2790](https://github.com/ruby-grape/grape/pull/2790): Stop mutating `Grape::Router`'s internal maps at request time for HTTP methods with no routes (data race on JRuby/TruffleRuby and unbounded growth) - [@ericproulx](https://github.com/ericproulx).
2426
* [#2791](https://github.com/ruby-grape/grape/pull/2791): Build HEAD/OPTIONS/405 helper routes from a copy of the inheritable settings instead of temporarily mutating the shared class-level settings during compilation - [@ericproulx](https://github.com/ericproulx).
2527
* Your contribution here.

lib/grape/api.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ module Grape
44
# The API class is the primary entry point for creating Grape APIs. Users
55
# should subclass this class in order to build an API.
66
class API
7+
# Marks this and every subclass as a mountable Grape app (see Grape::Mountable).
8+
extend Grape::Mountable
9+
710
# Class methods that we want to call on the API rather than on the API object
811
NON_OVERRIDABLE = %i[base= base_instance? call change! configuration compile! inherit_settings recognize_path reset! routes top_level_setting= top_level_setting].freeze
912

lib/grape/api/instance.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ class API
55
# The API Instance class, is the engine behind Grape::API. Each class that inherits
66
# from this will represent a different API instance
77
class Instance
8+
# Marks this and every mounted instance as a mountable Grape app (see Grape::Mountable).
9+
extend Grape::Mountable
810
extend Grape::DSL::Settings
911
extend Grape::DSL::Desc
1012
extend Grape::DSL::Validations

lib/grape/content_types.rb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,19 @@ def content_types_for(from_settings)
2222
def mime_types_for(from_settings)
2323
return MIME_TYPES if from_settings == Grape::ContentTypes::DEFAULTS
2424

25-
from_settings.invert.transform_keys! { |k| k.include?(';') ? k.split(';', 2).first : k }
25+
from_settings.invert.transform_keys! { |k| media_type(k) }
26+
end
27+
28+
# The media type of a content-type header: the part before any `;`
29+
# parameters, with surrounding whitespace removed
30+
# (e.g. `'text/html'` for `'text/html; charset=utf-8'`). Returns nil for a
31+
# nil content type. Skips the split (and its allocation) when there are no
32+
# parameters, which is the common case.
33+
def media_type(content_type)
34+
return if content_type.nil?
35+
36+
base = content_type.include?(';') ? content_type.split(';', 2).first : content_type
37+
base.strip
2638
end
2739
end
2840
end

lib/grape/dsl/routing.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,9 @@ def mount(mounts, *opts)
133133
end
134134
in_setting = inheritable_setting
135135

136-
if app.respond_to?(:inheritable_setting, true)
136+
# Past the mount_instance branch above, a Grape app here is an already
137+
# instantiated Grape::API::Instance (vs. a bare Rack app).
138+
if app.is_a?(Grape::Mountable)
137139
mount_path = Grape::Util::PathNormalizer.call(path)
138140
app.top_level_setting.namespace_stackable[:mount_path] = mount_path
139141

lib/grape/endpoint.rb

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ def instrument_run_filters(filters, type, &)
276276

277277
def compile!
278278
@app = config.app || build_stack
279+
warn_unauthenticated_mounted_app
279280
@helpers = build_helpers
280281
stackable = inheritable_setting.namespace_stackable
281282
@befores = stackable[:befores]
@@ -290,7 +291,7 @@ def to_routes
290291
route_options = config.route_options
291292
params = config.params
292293
path_settings = prepare_default_path_settings
293-
forward_match = config.app && !config.app.respond_to?(:inheritable_setting)
294+
forward_match = bare_rack_app?
294295
version = prepare_version(inheritable_setting.namespace_inheritable[:version])
295296
prefix = inheritable_setting.namespace_inheritable[:root_prefix]
296297
requirements = prepare_routes_requirements(config.requirements)
@@ -313,6 +314,13 @@ def to_routes
313314
end
314315
end
315316

317+
# True when a bare Rack app (anything that isn't a Grape app) is mounted at
318+
# this endpoint. Such an app is called directly and matched by path prefix
319+
# rather than an anchored route.
320+
def bare_rack_app?
321+
config.app && !config.app.is_a?(Grape::Mountable)
322+
end
323+
316324
def prepare_default_path_settings
317325
namespace_stackable_hash = inheritable_setting.namespace_stackable.to_hash
318326
namespace_inheritable_hash = inheritable_setting.namespace_inheritable.to_hash
@@ -391,6 +399,19 @@ def build_helpers
391399
Module.new { helpers.each { |mod_to_include| include mod_to_include } }
392400
end
393401

402+
# A bare Rack app mounted with +mount+ is called directly (see +compile!+):
403+
# it does not go through +build_stack+, so the API's authentication
404+
# middleware never runs and the mount is reachable unauthenticated. Mounted
405+
# Grape APIs are unaffected because they rebuild their own stack from the
406+
# inherited settings. Warn so this bypass isn't silent.
407+
def warn_unauthenticated_mounted_app
408+
return unless bare_rack_app?
409+
return unless inheritable_setting.namespace_inheritable[:auth]
410+
411+
warn "Grape: #{config.app} is mounted under an API that declares authentication, but authentication " \
412+
'middleware does not wrap mounted Rack applications. Requests to this mount are not authenticated by Grape.'
413+
end
414+
394415
def build_response_cookies
395416
return unless request.cookies?
396417

lib/grape/middleware/error.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,21 @@ def call!(env)
5858
private
5959

6060
def rack_response(status, headers, message)
61-
message = Rack::Utils.escape_html(message) if headers[Rack::CONTENT_TYPE] == 'text/html'
61+
message = Rack::Utils.escape_html(message) if html_content_type?(headers[Rack::CONTENT_TYPE])
6262
Rack::Response.new(Array.wrap(message), Rack::Utils.status_code(status), Grape::Util::Header.new.merge(headers))
6363
end
6464

65+
# Escaping must key off the media type only, case-insensitively. Comparing
66+
# the raw header against 'text/html' would let a parameterized value such
67+
# as 'text/html; charset=utf-8' (or a differently-cased 'Text/HTML', which
68+
# browsers still treat as HTML) skip escaping and reflect an unescaped
69+
# message into an HTML response. Such a header can be set from several
70+
# places (a registered content type, or a custom Content-Type passed to
71+
# error!/rescue_from), but they all render here.
72+
def html_content_type?(content_type)
73+
Grape::ContentTypes.media_type(content_type).to_s.casecmp?('text/html')
74+
end
75+
6576
def format_message(error)
6677
current_format = env[Grape::Env::API_FORMAT] || format
6778
formatter = Grape::ErrorFormatter.formatter_for(current_format, error_formatters, default_error_formatter)

lib/grape/mountable.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# frozen_string_literal: true
2+
3+
module Grape
4+
# Marker module for a mountable Grape application. Both {Grape::API} (the
5+
# remountable user-facing class) and {Grape::API::Instance} (the compiled
6+
# engine) extend it, so a mounted Grape app can be told apart from a bare
7+
# Rack app with `is_a?(Grape::Mountable)` — a single, explicit predicate
8+
# rather than duck-typing on an incidental internal method such as
9+
# `inheritable_setting`.
10+
#
11+
# `Grape::API` and `Grape::API::Instance` are not related by inheritance and
12+
# do not even respond to the same methods (the former to `mount_instance`,
13+
# the latter to `inheritable_setting`/`endpoints`), so there is no common
14+
# ancestor to key an `is_a?` check on without this marker.
15+
#
16+
# It answers identity only ("is this a Grape app?"). Capability checks that
17+
# go on to call a stage-specific method — e.g. `respond_to?(:endpoints)`
18+
# before reading `endpoints` — must stay as they are, since a `Mountable`
19+
# does not necessarily respond to every such method.
20+
module Mountable
21+
end
22+
end

spec/grape/api_spec.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3600,6 +3600,27 @@ def self.call(object, _env)
36003600
end
36013601
end
36023602

3603+
context 'with a bare rack app mounted under authentication' do
3604+
it 'warns that the mount is not covered by the auth middleware' do
3605+
subject.http_basic { |_u, _p| false }
3606+
subject.mount mounted_app => '/mounty'
3607+
expect { get '/mounty' }.to output(/not authenticated by Grape/).to_stderr
3608+
expect(last_response.body).to eq('MOUNTED')
3609+
end
3610+
3611+
it 'does not warn for a bare rack app mounted without authentication' do
3612+
subject.mount mounted_app => '/mounty'
3613+
expect { get '/mounty' }.not_to output.to_stderr
3614+
end
3615+
3616+
it 'does not warn for a mounted Grape API under authentication' do
3617+
inner = Class.new(described_class) { get('/inner') { 'inner' } }
3618+
subject.http_basic { |_u, _p| false }
3619+
subject.mount inner => '/sub'
3620+
expect { get '/sub/inner' }.not_to output.to_stderr
3621+
end
3622+
end
3623+
36033624
describe 'the mounted endpoint' do
36043625
it 'exposes the mounted app through #mounted_app' do
36053626
subject.mount mounted_app => '/mounty'
@@ -4454,6 +4475,40 @@ def before
44544475
end
44554476
end
44564477

4478+
context 'when an HTML error content-type carries a charset parameter' do
4479+
it 'still escapes the reflected message' do
4480+
subject.content_type :html, 'text/html; charset=utf-8'
4481+
subject.format :html
4482+
subject.formatter :html, ->(object, _env) { object.to_s }
4483+
subject.rescue_from(:all) { |e| error!(e.message, 400) }
4484+
subject.get('/echo') { raise params[:q] }
4485+
get '/echo', q: '<script>alert(1)</script>'
4486+
expect(last_response.status).to eq(400)
4487+
expect(last_response.body).to eq(Rack::Utils.escape_html('<script>alert(1)</script>'))
4488+
end
4489+
4490+
it 'escapes when the charset content-type is set by error! rather than registered' do
4491+
subject.format :json
4492+
subject.rescue_from(:all) { |e| error!(e.message, 400, 'Content-Type' => 'text/html; charset=utf-8') }
4493+
subject.get('/echo') { raise params[:q] }
4494+
get '/echo', q: '<script>alert(1)</script>'
4495+
expect(last_response.status).to eq(400)
4496+
expect(last_response.headers['content-type']).to eq('text/html; charset=utf-8')
4497+
expect(last_response.body).not_to include('<script>')
4498+
expect(last_response.body).to include('&lt;script&gt;')
4499+
end
4500+
4501+
it 'escapes regardless of the media type casing' do
4502+
subject.format :json
4503+
subject.rescue_from(:all) { |e| error!(e.message, 400, 'Content-Type' => 'Text/HTML; charset=utf-8') }
4504+
subject.get('/echo') { raise params[:q] }
4505+
get '/echo', q: '<script>alert(1)</script>'
4506+
expect(last_response.status).to eq(400)
4507+
expect(last_response.body).not_to include('<script>')
4508+
expect(last_response.body).to include('&lt;script&gt;')
4509+
end
4510+
end
4511+
44574512
context 'with non-UTF-8 characters in specified format' do
44584513
it 'converts the characters' do
44594514
subject.format :json

spec/grape/content_types_spec.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,32 @@
7575
it { is_expected.to eq('application/xml' => :xml) }
7676
end
7777
end
78+
79+
describe '.media_type' do
80+
subject { described_class.media_type(content_type) }
81+
82+
context 'with a bare media type' do
83+
let(:content_type) { 'text/html' }
84+
85+
it { is_expected.to eq('text/html') }
86+
end
87+
88+
context 'with parameters' do
89+
let(:content_type) { 'text/html; charset=utf-8' }
90+
91+
it { is_expected.to eq('text/html') }
92+
end
93+
94+
context 'with surrounding whitespace before the parameter separator' do
95+
let(:content_type) { 'text/html ; charset=utf-8' }
96+
97+
it { is_expected.to eq('text/html') }
98+
end
99+
100+
context 'when nil' do
101+
let(:content_type) { nil }
102+
103+
it { is_expected.to be_nil }
104+
end
105+
end
78106
end

0 commit comments

Comments
 (0)