Skip to content

Commit 9a18941

Browse files
committed
Fix race condition, double join, LIKE injection, N+1 queries, and domain API surface in access rules
- Wrap create action in transaction with FOR UPDATE lock to prevent concurrent inserts from violating cf:any exclusivity constraints - Rescue Sequel::UniqueConstraintViolation to return 422 instead of 500 - Join routes table at most once when both route_guids and space_guids filters are requested, preventing ambiguous column references - Escape LIKE metacharacters (% and _) in selector_resource_guids filter - Replace deprecated routes__column syntax with Sequel[:routes][:column] - Remove per-row DB existence checks in AccessRulePresenter to eliminate N+1 queries; relationship GUIDs are now included directly from selector - Only include enforce_access_rules and access_rules_scope in domain responses when enforce_access_rules is true
1 parent abdcfda commit 9a18941

5 files changed

Lines changed: 142 additions & 40 deletions

File tree

app/controllers/v3/access_rules_controller.rb

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,28 @@ def create
4141

4242
route = find_and_authorize_route(message.route_guid)
4343
validate_route_domain(route)
44-
validate_selector_exclusivity(route, message.selector)
45-
46-
access_rule = VCAP::CloudController::RouteAccessRule.new(
47-
guid: SecureRandom.uuid,
48-
selector: message.selector,
49-
route_id: route.id,
50-
created_at: Time.now.utc,
51-
updated_at: Time.now.utc
52-
)
53-
access_rule.save
44+
45+
access_rule = VCAP::CloudController::RouteAccessRule.db.transaction do
46+
# Lock existing access rules for this route to prevent concurrent inserts
47+
# from violating cf:any exclusivity or uniqueness constraints
48+
VCAP::CloudController::RouteAccessRule.where(route_id: route.id).for_update.all
49+
50+
validate_selector_exclusivity(route, message.selector)
51+
52+
rule = VCAP::CloudController::RouteAccessRule.new(
53+
guid: SecureRandom.uuid,
54+
selector: message.selector,
55+
route_id: route.id,
56+
created_at: Time.now.utc,
57+
updated_at: Time.now.utc
58+
)
59+
rule.save
60+
rule
61+
end
5462

5563
render status: :created, json: Presenters::V3::AccessRulePresenter.new(access_rule)
64+
rescue Sequel::UniqueConstraintViolation
65+
unprocessable!("An access rule with selector '#{message.selector}' already exists for this route.")
5666
end
5767

5868
def update
@@ -126,18 +136,15 @@ def build_dataset(message)
126136

127137
dataset = dataset.where(route_id: readable_route_ids)
128138

129-
if message.requested?(:route_guids)
139+
# Join routes at most once when either route_guids or space_guids is requested
140+
if message.requested?(:route_guids) || message.requested?(:space_guids)
130141
dataset = dataset.
131142
join(:routes, id: :route_id).
132-
where(routes__guid: message.route_guids).
133143
select_all(:route_access_rules)
134-
end
135144

136-
if message.requested?(:space_guids)
137-
dataset = dataset.
138-
join(:routes, id: :route_id).
139-
where(routes__space_id: VCAP::CloudController::Space.where(guid: message.space_guids).select(:id)).
140-
select_all(:route_access_rules)
145+
dataset = dataset.where(Sequel[:routes][:guid] => message.route_guids) if message.requested?(:route_guids)
146+
147+
dataset = dataset.where(Sequel[:routes][:space_id] => VCAP::CloudController::Space.where(guid: message.space_guids).select(:id)) if message.requested?(:space_guids)
141148
end
142149

143150
dataset = dataset.where(guid: message.guids) if message.requested?(:guids)
@@ -146,8 +153,10 @@ def build_dataset(message)
146153
if message.requested?(:selector_resource_guids)
147154
# Text-match against selector string for resource GUIDs
148155
# Handles cf:app:<guid>, cf:space:<guid>, cf:org:<guid>
156+
# Escape LIKE metacharacters (% and _) in user-provided values
149157
conditions = message.selector_resource_guids.map do |guid|
150-
Sequel.like(:selector, "%#{guid}%")
158+
escaped_guid = guid.gsub('%', '\\%').gsub('_', '\\_')
159+
Sequel.like(:selector, "%#{escaped_guid}%")
151160
end
152161
dataset = dataset.where(Sequel.|(*conditions))
153162
end

app/presenters/v3/access_rule_presenter.rb

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -38,29 +38,16 @@ def build_relationships
3838
}
3939

4040
# Extract resource GUID from selector and populate read-only relationships
41-
# Only include the guid in data if the resource actually exists
41+
# The guid is included as-is without per-row existence checks to avoid N+1 queries.
42+
# Use ?include=selector_resource to get full resource details with batch loading.
4243
selector_match = access_rule.selector.match(/\Acf:(app|space|org):([0-9a-f-]+)\z/)
4344
if selector_match
4445
resource_type = selector_match[1]
4546
resource_guid = selector_match[2]
4647

47-
case resource_type
48-
when 'app'
49-
app_exists = VCAP::CloudController::AppModel.where(guid: resource_guid).any?
50-
relationships[:app] = { data: app_exists ? { guid: resource_guid } : nil }
51-
relationships[:space] = { data: nil }
52-
relationships[:organization] = { data: nil }
53-
when 'space'
54-
space_exists = VCAP::CloudController::Space.where(guid: resource_guid).any?
55-
relationships[:app] = { data: nil }
56-
relationships[:space] = { data: space_exists ? { guid: resource_guid } : nil }
57-
relationships[:organization] = { data: nil }
58-
when 'org'
59-
org_exists = VCAP::CloudController::Organization.where(guid: resource_guid).any?
60-
relationships[:app] = { data: nil }
61-
relationships[:space] = { data: nil }
62-
relationships[:organization] = { data: org_exists ? { guid: resource_guid } : nil }
63-
end
48+
relationships[:app] = { data: resource_type == 'app' ? { guid: resource_guid } : nil }
49+
relationships[:space] = { data: resource_type == 'space' ? { guid: resource_guid } : nil }
50+
relationships[:organization] = { data: resource_type == 'org' ? { guid: resource_guid } : nil }
6451
else
6552
# cf:any or malformed - all relationships are null
6653
relationships[:app] = { data: nil }

app/presenters/v3/domain_presenter.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,14 @@ def initialize(
2020
end
2121

2222
def to_hash
23-
{
23+
hash = {
2424
guid: domain.guid,
2525
created_at: domain.created_at,
2626
updated_at: domain.updated_at,
2727
name: domain.name,
2828
internal: domain.internal,
2929
router_group: hashified_router_group(domain.router_group_guid),
3030
supported_protocols: domain.protocols,
31-
enforce_access_rules: domain.enforce_access_rules || false,
32-
access_rules_scope: domain.access_rules_scope,
3331
relationships: {
3432
organization: {
3533
data: owning_org_guid
@@ -44,6 +42,13 @@ def to_hash
4442
},
4543
links: build_links
4644
}
45+
46+
if domain.enforce_access_rules
47+
hash[:enforce_access_rules] = true
48+
hash[:access_rules_scope] = domain.access_rules_scope
49+
end
50+
51+
hash
4752
end
4853

4954
private

spec/request/access_rules_spec.rb

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,24 @@ def expected_rule_json(rule)
206206
expect(last_response.body).to include('Selector')
207207
end
208208
end
209+
210+
context 'when a concurrent request creates the same selector (UniqueConstraintViolation)' do
211+
it 'returns 422 instead of 500' do
212+
# Simulate a race condition where the DB unique constraint catches the duplicate
213+
# after validation passes but before the insert commits
214+
allow_any_instance_of(VCAP::CloudController::RouteAccessRule).to receive(:save).and_raise(
215+
Sequel::UniqueConstraintViolation.new('UNIQUE constraint failed: route_access_rules.route_id, route_access_rules.selector')
216+
)
217+
218+
post '/v3/access_rules', {
219+
selector: "cf:app:#{valid_uuid}",
220+
relationships: { route: { data: { guid: mtls_route.guid } } }
221+
}.to_json, admin_header
222+
223+
expect(last_response.status).to eq(422)
224+
expect(last_response.body).to include('already exists')
225+
end
226+
end
209227
end
210228

211229
describe 'GET /v3/access_rules/:guid' do
@@ -345,6 +363,52 @@ def expected_rule_json(rule)
345363
end
346364
end
347365

366+
describe 'filtering by both route_guids and space_guids' do
367+
let(:other_org) { VCAP::CloudController::Organization.make }
368+
let(:other_space) { VCAP::CloudController::Space.make(organization: other_org) }
369+
let(:other_mtls_domain) do
370+
VCAP::CloudController::PrivateDomain.make(
371+
owning_organization: other_org,
372+
enforce_access_rules: true,
373+
access_rules_scope: 'space'
374+
)
375+
end
376+
let(:other_route) { VCAP::CloudController::Route.make(space: other_space, domain: other_mtls_domain) }
377+
let!(:rule_in_other_space) do
378+
VCAP::CloudController::RouteAccessRule.create(
379+
guid: SecureRandom.uuid,
380+
selector: 'cf:any',
381+
route_id: other_route.id
382+
)
383+
end
384+
385+
before do
386+
other_org.add_user(user)
387+
other_space.add_developer(user)
388+
end
389+
390+
it 'returns results matching both route_guids and space_guids without ambiguous column errors' do
391+
get "/v3/access_rules?route_guids=#{mtls_route.guid}&space_guids=#{space.guid}", nil, admin_header
392+
393+
expect(last_response.status).to eq(200)
394+
parsed = Oj.load(last_response.body)
395+
guids = parsed['resources'].pluck('guid')
396+
expect(guids).to include(rule1.guid)
397+
expect(guids).not_to include(rule_in_other_space.guid)
398+
end
399+
end
400+
401+
describe 'filtering by selector_resource_guids' do
402+
it 'does not match unintended rows when guid contains LIKE wildcards' do
403+
get '/v3/access_rules?selector_resource_guids=%25', nil, admin_header
404+
405+
expect(last_response.status).to eq(200)
406+
parsed = Oj.load(last_response.body)
407+
# Should not match all rows via SQL wildcard; % is escaped
408+
expect(parsed['resources'].length).to eq(0)
409+
end
410+
end
411+
348412
context 'with include=selector_resource' do
349413
let!(:frontend_app) { VCAP::CloudController::AppModel.make(space: space, name: 'frontend-app') }
350414
let!(:other_space) { VCAP::CloudController::Space.make(organization: org, name: 'other-space') }

spec/unit/presenters/v3/domain_presenter_spec.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,43 @@ module VCAP::CloudController::Presenters::V3
238238
end
239239
end
240240

241+
context 'when the domain has enforce_access_rules enabled' do
242+
let(:org) { VCAP::CloudController::Organization.make }
243+
let(:domain) do
244+
VCAP::CloudController::PrivateDomain.make(
245+
name: 'mtls.domain.com',
246+
owning_organization: org,
247+
enforce_access_rules: true,
248+
access_rules_scope: 'space'
249+
)
250+
end
251+
252+
it 'includes enforce_access_rules and access_rules_scope in the output' do
253+
expect(subject[:enforce_access_rules]).to be(true)
254+
expect(subject[:access_rules_scope]).to eq('space')
255+
end
256+
end
257+
258+
context 'when the domain does not have enforce_access_rules enabled' do
259+
let(:domain) do
260+
VCAP::CloudController::SharedDomain.make(
261+
name: 'regular.domain.com'
262+
)
263+
end
264+
265+
let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) }
266+
267+
before do
268+
allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client)
269+
allow(routing_api_client).to receive_messages(enabled?: true, router_group: nil)
270+
end
271+
272+
it 'does not include enforce_access_rules or access_rules_scope in the output' do
273+
expect(subject).not_to have_key(:enforce_access_rules)
274+
expect(subject).not_to have_key(:access_rules_scope)
275+
end
276+
end
277+
241278
context 'and the routing API is disabled' do
242279
before do
243280
allow(routing_api_client).to receive(:enabled?).and_return false

0 commit comments

Comments
 (0)