Skip to content

Commit ed109c5

Browse files
committed
Bind feed strategy in tokens
1 parent 170dac4 commit ed109c5

6 files changed

Lines changed: 79 additions & 33 deletions

File tree

app/api/v1/feeds.rb

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require 'time'
4+
require 'json'
45
require 'html2rss/url'
56

67
require_relative '../../account_manager'
@@ -28,14 +29,15 @@ def show(request, token)
2829
account = account_for(feed_token)
2930
ensure_access!(account, feed_token.url)
3031

31-
render_generated_feed(request, feed_token.url)
32+
strategy = resolve_token_strategy(feed_token)
33+
render_generated_feed(request, feed_token.url, strategy)
3234
end
3335

3436
def create(request)
3537
ensure_auto_source_enabled!
3638

3739
account = require_account(request)
38-
params = build_create_params(request, account)
40+
params = build_create_params(request_params(request), account)
3941

4042
feed_data = AutoSource.create_stable_feed(params[:name], params[:url], account, params[:strategy])
4143
raise InternalServerError, 'Failed to create feed' unless feed_data
@@ -55,16 +57,16 @@ def json_response(request, payload, status: 200)
5557
payload
5658
end
5759

58-
def build_create_params(request, account)
59-
url = request.params['url'].to_s.strip
60+
def build_create_params(params, account)
61+
url = params['url'].to_s.strip
6062
raise BadRequestError, 'URL parameter is required' if url.empty?
6163
raise BadRequestError, 'Invalid URL format' unless UrlValidator.valid_url?(url)
6264
raise ForbiddenError, 'URL not allowed for this account' unless UrlValidator.url_allowed?(account, url)
6365

6466
{
6567
url: url,
6668
name: extract_site_title(url),
67-
strategy: normalize_strategy(request.params['strategy'])
69+
strategy: normalize_strategy(params['strategy'])
6870
}
6971
end
7072

@@ -95,8 +97,7 @@ def ensure_access!(account, url)
9597
raise ForbiddenError, 'Access Denied' unless UrlValidator.url_allowed?(account, url)
9698
end
9799

98-
def render_generated_feed(request, url)
99-
strategy = normalize_strategy(request.params['strategy'])
100+
def render_generated_feed(request, url, strategy)
100101
feed_object = AutoSource.generate_feed_object(url, strategy)
101102
rendered_feed = FeedGenerator.process_feed_content(url, strategy, feed_object)
102103

@@ -158,6 +159,35 @@ def extract_ttl_from_feed_object(feed_object)
158159

159160
ttl_minutes * 60
160161
end
162+
163+
def resolve_token_strategy(feed_token)
164+
strategy = feed_token.strategy.to_s.strip
165+
strategy = default_strategy if strategy.empty?
166+
167+
raise BadRequestError, 'Unsupported strategy' unless supported_strategies.include?(strategy)
168+
169+
strategy
170+
end
171+
172+
def request_params(request)
173+
return request.params unless json_request?(request)
174+
175+
raw_body = request.body.read
176+
request.body.rewind
177+
return request.params if raw_body.strip.empty?
178+
179+
parsed = JSON.parse(raw_body)
180+
raise BadRequestError, 'Invalid JSON payload' unless parsed.is_a?(Hash)
181+
182+
request.params.merge(parsed)
183+
rescue JSON::ParserError
184+
raise BadRequestError, 'Invalid JSON payload'
185+
end
186+
187+
def json_request?(request)
188+
content_type = request.env['CONTENT_TYPE'].to_s
189+
content_type.include?('application/json')
190+
end
161191
end
162192
end
163193
end

app/auth.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ def authenticate(request)
2727
# @param url [String]
2828
# @param expires_in [Integer] seconds (default: 10 years)
2929
# @return [String] HMAC-signed compressed feed token
30-
def generate_feed_token(username, url, expires_in: Html2rss::Web::DEFAULT_EXPIRY)
30+
def generate_feed_token(username, url, strategy:, expires_in: Html2rss::Web::DEFAULT_EXPIRY)
3131
token = FeedToken.create_with_validation(
3232
username: username,
3333
url: url,
34+
strategy: strategy,
3435
expires_in: expires_in,
3536
secret_key: secret_key
3637
)

app/auto_source.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def create_stable_feed(name, url, token_data, strategy = 'ssrf_filter')
2525
return nil unless url_allowed_for_token?(token_data, url)
2626

2727
feed_id = generate_feed_id(token_data[:username], url, token_data[:token])
28-
feed_token = Auth.generate_feed_token(token_data[:username], url)
28+
feed_token = Auth.generate_feed_token(token_data[:username], url, strategy: strategy)
2929
return nil unless feed_token
3030

3131
identifiers = { feed_id: feed_id, feed_token: feed_token }

app/feed_token.rb

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ module Web
1313
REQUIRED_TOKEN_KEYS = %i[p s].freeze
1414
COMPRESSED_PAYLOAD_KEYS = %i[u l e].freeze
1515

16-
FeedToken = Data.define(:username, :url, :expires_at, :signature) do
17-
def self.create_with_validation(username:, url:, secret_key:, expires_in: DEFAULT_EXPIRY)
18-
return unless valid_inputs?(username, url, secret_key)
16+
FeedToken = Data.define(:username, :url, :expires_at, :signature, :strategy) do
17+
def self.create_with_validation(username:, url:, secret_key:, strategy:, expires_in: DEFAULT_EXPIRY)
18+
return unless valid_inputs?(username, url, secret_key, strategy)
1919

2020
expires_at = Time.now.to_i + expires_in.to_i
21-
payload = build_payload(username, url, expires_at)
21+
payload = build_payload(username, url, expires_at, strategy)
2222
signature = generate_signature(secret_key, payload)
2323

24-
new(username: username, url: url, expires_at: expires_at, signature: signature)
24+
new(username: username, url: url, expires_at: expires_at, signature: signature, strategy: strategy)
2525
end
2626

2727
def self.decode(encoded_token) # rubocop:disable Metrics/MethodLength
@@ -35,7 +35,8 @@ def self.decode(encoded_token) # rubocop:disable Metrics/MethodLength
3535
username: payload[:u],
3636
url: payload[:l],
3737
expires_at: payload[:e],
38-
signature: token_data[:s]
38+
signature: token_data[:s],
39+
strategy: payload[:t]
3940
)
4041
rescue JSON::ParserError, ArgumentError, Zlib::DataError, Zlib::BufError
4142
nil
@@ -74,11 +75,15 @@ def valid_signature?(secret_key)
7475
private
7576

7677
def payload_for_signature
77-
{ username: username, url: url, expires_at: expires_at }
78+
payload = { username: username, url: url, expires_at: expires_at }
79+
payload[:strategy] = strategy if strategy
80+
payload
7881
end
7982

8083
def build_token_data
81-
{ p: { u: username, l: url, e: expires_at }, s: signature }
84+
payload = { u: username, l: url, e: expires_at }
85+
payload[:t] = strategy if strategy
86+
{ p: payload, s: signature }
8287
end
8388

8489
def secure_compare(first, second) # rubocop:disable Naming/PredicateMethod
@@ -88,8 +93,8 @@ def secure_compare(first, second) # rubocop:disable Naming/PredicateMethod
8893
end
8994

9095
class << self
91-
def build_payload(username, url, expires_at)
92-
{ username: username, url: url, expires_at: expires_at }
96+
def build_payload(username, url, expires_at, strategy)
97+
{ username: username, url: url, expires_at: expires_at, strategy: strategy }
9398
end
9499

95100
def generate_signature(secret_key, payload)
@@ -112,8 +117,9 @@ def valid_token_data?(token_data)
112117
COMPRESSED_PAYLOAD_KEYS.all? { |key| payload[key] }
113118
end
114119

115-
def valid_inputs?(username, url, secret_key)
116-
valid_username?(username) && UrlValidator.valid_url?(url) && valid_secret_key?(secret_key)
120+
def valid_inputs?(username, url, secret_key, strategy)
121+
valid_username?(username) && UrlValidator.valid_url?(url) && valid_secret_key?(secret_key) &&
122+
valid_strategy?(strategy)
117123
end
118124

119125
def valid_username?(username)
@@ -123,6 +129,10 @@ def valid_username?(username)
123129
def valid_secret_key?(secret_key)
124130
secret_key.is_a?(String) && !secret_key.empty?
125131
end
132+
133+
def valid_strategy?(strategy)
134+
strategy.is_a?(String) && !strategy.empty? && strategy.length <= 50 && strategy.match?(/\A[a-z0-9_]+\z/)
135+
end
126136
end
127137
end
128138
end

spec/html2rss/web/api/v1_spec.rb

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def app = Html2rss::Web::App.freeze.app
6969
.create_with_validation(
7070
username: 'ghost',
7171
url: feed_url,
72+
strategy: 'ssrf_filter',
7273
secret_key: ENV.fetch('HTML2RSS_SECRET_KEY')
7374
)
7475
.encode
@@ -82,15 +83,18 @@ def app = Html2rss::Web::App.freeze.app
8283
expect(response_data.dig('error', 'message')).to eq('Account not found')
8384
end
8485

85-
it 'returns bad request when strategy is unsupported', :aggregate_failures do # rubocop:disable RSpec/ExampleLength
86-
token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url)
86+
it 'ignores query param strategy overrides', :aggregate_failures do # rubocop:disable RSpec/ExampleLength
87+
token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter')
88+
89+
allow(Html2rss::Web::AutoSource).to receive(:generate_feed_object)
90+
.and_return(instance_double('Html2rss::Feed', channel: instance_double('Html2rss::FeedChannel', ttl: 10)))
91+
allow(Html2rss::Web::FeedGenerator).to receive(:process_feed_content)
92+
.and_return('<rss version="2.0"></rss>')
8793

8894
get "/api/v1/feeds/#{token}", strategy: 'bad'
8995

90-
expect(last_response.status).to eq(400)
91-
response_data = JSON.parse(last_response.body)
92-
expect(response_data['success']).to be false
93-
expect(response_data.dig('error', 'message')).to eq('Unsupported strategy')
96+
expect(last_response.status).to eq(200)
97+
expect(last_response.content_type).to include('application/xml')
9498
end
9599
end
96100

spec/html2rss/web/app_integration_spec.rb

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535

3636
before do
3737
allow(Html2rss::Web::LocalConfig).to receive(:yaml).and_return(accounts_config)
38-
token_payload = instance_double(Html2rss::Web::FeedToken, url: feed_url, username: account[:username])
38+
token_payload = instance_double(Html2rss::Web::FeedToken, url: feed_url, username: account[:username],
39+
strategy: 'ssrf_filter')
3940
allow(Html2rss::Web::FeedToken).to receive_messages(
4041
decode: token_payload,
4142
validate_and_decode: token_payload
@@ -73,12 +74,11 @@
7374
expect(last_response.body).to eq('<rss version="2.0"></rss>')
7475
end
7576

76-
it 'returns bad request for unsupported strategy', :aggregate_failures do
77+
it 'ignores query param strategy overrides', :aggregate_failures do
7778
get "/api/v1/feeds/#{feed_token}", { 'strategy' => 'invalid' }
7879

79-
expect(last_response.status).to eq(400)
80-
expect(last_response.content_type).to include('application/json')
81-
expect(json_body).to include('error' => include('message' => 'Unsupported strategy'))
80+
expect(last_response.status).to eq(200)
81+
expect(last_response.content_type).to include('application/xml')
8282
end
8383
end
8484

@@ -124,7 +124,8 @@
124124
post '/api/v1/feeds', '{ invalid', json_headers
125125

126126
expect(last_response.status).to eq(400)
127-
expect(last_response.body).to be_empty
127+
expect(last_response.content_type).to include('application/json')
128+
expect(json_body).to include('error' => include('message' => 'Invalid JSON payload'))
128129
end
129130

130131
it 'returns bad request when URL is missing', :aggregate_failures do # rubocop:disable RSpec/ExampleLength

0 commit comments

Comments
 (0)