Skip to content

Commit 097ae67

Browse files
committed
fix: harden feed loading and negotiated openapi docs
1 parent 12b40b2 commit 097ae67

6 files changed

Lines changed: 148 additions & 19 deletions

File tree

app/feeds/cache.rb

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,25 @@ class << self
2121
# @yieldreturn [Html2rss::Web::Feeds::Result]
2222
# @return [Html2rss::Web::Feeds::Result]
2323
def fetch(key, ttl_seconds:, cacheable: true)
24-
entry = read_entry(key)
25-
return entry.result if fresh?(entry)
24+
lock.synchronize do
25+
entry = read_entry(key)
26+
return entry.result if fresh?(entry)
2627

27-
result = yield
28-
return result unless cacheable_result?(cacheable, result)
28+
result = yield
29+
return result unless cacheable_result?(cacheable, result)
2930

30-
write_entry(key, ttl_seconds, result)
31-
result
31+
write_entry(key, ttl_seconds, result)
32+
result
33+
end
3234
end
3335

3436
# @param reason [String]
3537
# @return [nil]
3638
def clear!(reason: 'manual')
37-
@entries = {} # rubocop:disable ThreadSafety/ClassInstanceVariable
38-
SecurityLogger.log_cache_lifecycle('feeds_cache', 'clear', reason: reason)
39+
lock.synchronize do
40+
@entries = {}
41+
SecurityLogger.log_cache_lifecycle('feeds_cache', 'clear', reason: reason)
42+
end
3943
nil
4044
end
4145

@@ -76,6 +80,11 @@ def entries
7680
@entries ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
7781
end
7882

83+
# @return [Mutex]
84+
def lock
85+
@lock ||= Mutex.new # rubocop:disable ThreadSafety/ClassInstanceVariable
86+
end
87+
7988
# @param ttl_seconds [Integer]
8089
# @return [Integer]
8190
def normalize_ttl(ttl_seconds)

app/feeds/service.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22

3+
require_relative '../exceptions'
34
require_relative 'cache'
45
require_relative 'payload'
56
require_relative 'result'

app/http/feed_route_handler.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22

3+
require_relative '../exceptions'
34
require_relative '../feed_response_format'
45
require_relative '../feeds/json_renderer'
56
require_relative '../feeds/request_parser'

docs/api/v1/openapi.yaml

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,25 +192,56 @@ paths:
192192
responses:
193193
'200':
194194
content:
195+
application/feed+json:
196+
schema:
197+
type: string
195198
application/xml:
196199
schema:
197200
type: string
198201
description: renders feed for a valid token
199202
'401':
200203
content:
201204
application/feed+json:
205+
example:
206+
title: Error
207+
version: https://jsonfeed.org/version/1.1
208+
schema:
209+
type: string
210+
application/xml:
211+
example: |-
212+
<?xml version="1.0" encoding="UTF-8"?>
213+
<rss version="2.0"><channel><title>Error</title><description>Internal Server Error</description></channel></rss>
202214
schema:
203215
type: string
204216
description: returns JSON Feed-shaped errors when requested by json extension
205217
'403':
206218
content:
219+
application/feed+json:
220+
example:
221+
title: Error
222+
version: https://jsonfeed.org/version/1.1
223+
schema:
224+
type: string
207225
application/xml:
226+
example: |-
227+
<?xml version="1.0" encoding="UTF-8"?>
228+
<rss version="2.0"><channel><title>Error</title><description>Internal Server Error</description></channel></rss>
208229
schema:
209230
type: string
210-
description: returns forbidden when auto source is disabled
231+
description: returns JSON Feed-shaped forbidden errors when requested through
232+
Accept
211233
'500':
212234
content:
213235
application/feed+json:
236+
example:
237+
title: Error
238+
version: https://jsonfeed.org/version/1.1
239+
schema:
240+
type: string
241+
application/xml:
242+
example: |-
243+
<?xml version="1.0" encoding="UTF-8"?>
244+
<rss version="2.0"><channel><title>Error</title><description>Internal Server Error</description></channel></rss>
214245
schema:
215246
type: string
216247
description: returns non-cacheable json feed errors when service generation

spec/html2rss/web/api/v1_spec.rb

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,8 @@ def json_feed_headers_tuple
224224
summary: 'Render feed by token',
225225
operation_id: 'renderFeedByToken',
226226
tags: ['Feeds'],
227-
security: []
227+
security: [],
228+
example_mode: :multiple
228229
} do
229230
before do
230231
stub_const('Html2rss::FeedChannel', Class.new { attr_reader :ttl })
@@ -312,8 +313,36 @@ def json_feed_headers_tuple
312313
expect(last_response.body).to include(Html2rss::Web::Api::V1::Contract::MESSAGES[:auto_source_disabled])
313314
end
314315

316+
it 'returns JSON Feed-shaped forbidden errors when requested through Accept', :aggregate_failures do
317+
unique_url = "#{feed_url}/disabled-json"
318+
token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'ssrf_filter')
319+
320+
ClimateControl.modify(AUTO_SOURCE_ENABLED: 'false') do
321+
get "/api/v1/feeds/#{token}", {}, { 'HTTP_ACCEPT' => 'application/feed+json' }
322+
end
323+
324+
expect([last_response.status, last_response.headers['Content-Type'], json_feed_error]).to eq(
325+
[403, 'application/feed+json', { 'version' => 'https://jsonfeed.org/version/1.1', 'title' => 'Error' }]
326+
)
327+
end
328+
329+
it 'returns non-cacheable xml feed errors when service generation fails', :aggregate_failures do
330+
unique_url = "#{feed_url}/service-error-xml"
331+
token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'ssrf_filter')
332+
333+
allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(service_error_result)
334+
335+
get "/api/v1/feeds/#{token}.xml"
336+
337+
expect(last_response.status).to eq(500)
338+
expect(last_response.content_type).to include('application/xml')
339+
expect(last_response.headers['Cache-Control']).to include('no-store')
340+
expect(last_response.body).to include('Internal Server Error')
341+
end
342+
315343
it 'returns non-cacheable json feed errors when service generation fails', :aggregate_failures do
316-
token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter')
344+
unique_url = "#{feed_url}/service-error-json"
345+
token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'ssrf_filter')
317346

318347
status, content_type, cache_control, title = json_feed_service_error_tuple(token)
319348

spec/support/openapi.rb

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@
4747

4848
# Keep path keys relative to /api/v1 because servers include the versioned base path.
4949
RSpec::OpenAPI.post_process_hook = lambda do |_path, _records, spec|
50+
token_feed_error_statuses = %w[401 403 500].freeze
51+
5052
stringify = lambda do |value|
5153
case value
5254
when Hash
@@ -69,6 +71,51 @@
6971
end
7072
end
7173

74+
merge_responses = lambda do |existing_responses, new_responses|
75+
statuses = existing_responses.keys | new_responses.keys
76+
77+
statuses.each_with_object({}) do |status, merged_responses|
78+
current = existing_responses[status] || {}
79+
incoming = new_responses[status] || {}
80+
merged_response = current.merge(incoming)
81+
82+
current_content = current['content'] || {}
83+
incoming_content = incoming['content'] || {}
84+
if current_content.any? || incoming_content.any?
85+
content_types = current_content.keys | incoming_content.keys
86+
merged_response['content'] = content_types.to_h do |content_type|
87+
current_entry = current_content[content_type] || {}
88+
incoming_entry = incoming_content[content_type] || {}
89+
[content_type, current_entry.merge(incoming_entry)]
90+
end
91+
end
92+
93+
current_headers = current['headers'] || {}
94+
incoming_headers = incoming['headers'] || {}
95+
if current_headers.any? || incoming_headers.any?
96+
merged_response['headers'] = current_headers.merge(incoming_headers)
97+
end
98+
99+
merged_response['description'] ||= current['description'] || incoming['description']
100+
merged_responses[status] = merged_response
101+
end
102+
end
103+
104+
token_feed_error_examples = {
105+
'application/xml' => {
106+
'example' => <<~XML.strip
107+
<?xml version="1.0" encoding="UTF-8"?>
108+
<rss version="2.0"><channel><title>Error</title><description>Internal Server Error</description></channel></rss>
109+
XML
110+
},
111+
'application/feed+json' => {
112+
'example' => {
113+
'version' => 'https://jsonfeed.org/version/1.1',
114+
'title' => 'Error'
115+
}
116+
}
117+
}
118+
72119
path_map = spec['paths'] || spec[:paths]
73120
next unless path_map.is_a?(Hash)
74121

@@ -90,7 +137,7 @@
90137

91138
if existing
92139
merged = existing.merge(operation_doc)
93-
merged['responses'] = (existing['responses'] || {}).merge(operation_doc['responses'] || {})
140+
merged['responses'] = merge_responses.call(existing['responses'] || {}, operation_doc['responses'] || {})
94141
merged['parameters'] = [*(existing['parameters'] || []), *(operation_doc['parameters'] || [])]
95142
merged['parameters'].uniq! { |parameter| [parameter['name'], parameter['in']] }
96143
normalized_paths[normalized][verb] = deep_sort.call(merged)
@@ -106,14 +153,25 @@
106153
has_token_param = normalized_paths[normalized][verb]['parameters'].any? do |parameter|
107154
parameter['name'] == 'token' && parameter['in'] == 'path'
108155
end
109-
next if has_token_param
156+
unless has_token_param
157+
normalized_paths[normalized][verb]['parameters'] << {
158+
'name' => 'token',
159+
'in' => 'path',
160+
'required' => true,
161+
'schema' => { 'type' => 'string' }
162+
}
163+
end
110164

111-
normalized_paths[normalized][verb]['parameters'] << {
112-
'name' => 'token',
113-
'in' => 'path',
114-
'required' => true,
115-
'schema' => { 'type' => 'string' }
116-
}
165+
token_feed_error_statuses.each do |status|
166+
response = normalized_paths[normalized][verb].dig('responses', status)
167+
next unless response
168+
169+
response['content'] ||= {}
170+
token_feed_error_examples.each do |content_type, example|
171+
response['content'][content_type] ||= { 'schema' => { 'type' => 'string' } }
172+
response['content'][content_type].merge!(example)
173+
end
174+
end
117175
end
118176
end
119177

0 commit comments

Comments
 (0)