html2rss 0.22.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +11 -108
- data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
- data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
- data/lib/html2rss/article.rb +213 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
- data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
- data/lib/html2rss/auto_source/discovery.rb +14 -0
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
- data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
- data/lib/html2rss/auto_source/scraper/html.rb +22 -33
- data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
- data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
- data/lib/html2rss/auto_source/scraper.rb +10 -3
- data/lib/html2rss/auto_source.rb +30 -35
- data/lib/html2rss/channel.rb +198 -0
- data/lib/html2rss/cli.rb +5 -6
- data/lib/html2rss/config/auto_source_contract.rb +51 -0
- data/lib/html2rss/config/request_controls.rb +136 -0
- data/lib/html2rss/config/request_headers.rb +1 -1
- data/lib/html2rss/config/schema.rb +65 -114
- data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
- data/lib/html2rss/config/validator.rb +6 -6
- data/lib/html2rss/config.rb +176 -19
- data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
- data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
- data/lib/html2rss/feed_builder/json_feed.rb +81 -0
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
- data/lib/html2rss/feed_builder/rss.rb +116 -0
- data/lib/html2rss/feed_builder.rb +15 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
- data/lib/html2rss/feed_pipeline.rb +83 -89
- data/lib/html2rss/feed_result.rb +92 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
- data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
- data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
- data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
- data/lib/html2rss/html/article_extractor.rb +134 -0
- data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
- data/lib/html2rss/html/navigator.rb +146 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
- data/lib/html2rss/html/rendering/description_builder.rb +89 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
- data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
- data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
- data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
- data/lib/html2rss/html/rendering.rb +25 -0
- data/lib/html2rss/html.rb +8 -0
- data/lib/html2rss/request_service/blocked_surface.rb +67 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +33 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +97 -18
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
- data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
- data/lib/html2rss/request_service/network_guard.rb +125 -0
- data/lib/html2rss/request_service/policy.rb +10 -82
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
- data/lib/html2rss/request_service/puppet_commander.rb +22 -191
- data/lib/html2rss/request_service/response.rb +9 -1
- data/lib/html2rss/request_service/response_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +123 -5
- data/lib/html2rss/request_service.rb +7 -2
- data/lib/html2rss/request_session/pager/base.rb +83 -0
- data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
- data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
- data/lib/html2rss/request_session/pager/offset.rb +48 -0
- data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
- data/lib/html2rss/request_session/pager/url_template.rb +48 -0
- data/lib/html2rss/request_session/pager.rb +75 -0
- data/lib/html2rss/request_session.rb +29 -16
- data/lib/html2rss/selectors/item_scope.rb +30 -0
- data/lib/html2rss/selectors/post_processors/base.rb +1 -1
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
- data/lib/html2rss/selectors/post_processors/template.rb +7 -3
- data/lib/html2rss/selectors.rb +84 -64
- data/lib/html2rss/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +34 -22
- data/schema/html2rss-config.schema.json +109 -9
- metadata +71 -43
- data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
- data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
- data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
- data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
- data/lib/html2rss/blocked_surface.rb +0 -65
- data/lib/html2rss/category_extractor.rb +0 -116
- data/lib/html2rss/config/class_methods.rb +0 -183
- data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
- data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
- data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
- data/lib/html2rss/html_extractor/id_generator.rb +0 -67
- data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
- data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
- data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
- data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
- data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
- data/lib/html2rss/html_extractor.rb +0 -190
- data/lib/html2rss/html_navigator.rb +0 -71
- data/lib/html2rss/json_feed_builder/item.rb +0 -94
- data/lib/html2rss/json_feed_builder.rb +0 -59
- data/lib/html2rss/rendering/audio_renderer.rb +0 -36
- data/lib/html2rss/rendering/description_builder.rb +0 -87
- data/lib/html2rss/rendering/image_renderer.rb +0 -41
- data/lib/html2rss/rendering/media_renderer.rb +0 -37
- data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
- data/lib/html2rss/rendering/video_renderer.rb +0 -36
- data/lib/html2rss/rendering.rb +0 -23
- data/lib/html2rss/request_controls.rb +0 -133
- data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
- data/lib/html2rss/request_session/runtime_input.rb +0 -71
- data/lib/html2rss/request_session/runtime_policy.rb +0 -84
- data/lib/html2rss/rss_builder/article.rb +0 -208
- data/lib/html2rss/rss_builder/channel.rb +0 -118
- data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
- data/lib/html2rss/rss_builder.rb +0 -101
|
@@ -3,40 +3,78 @@
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
class RequestService
|
|
5
5
|
##
|
|
6
|
-
# Tracks
|
|
6
|
+
# Tracks request slots, interaction budget, and wall-clock deadline for one feed build.
|
|
7
|
+
#
|
|
8
|
+
# HTTP fetches consume request slots; Browserless preload clicks/scrolls/waits consume
|
|
9
|
+
# interaction budget. Deadline remains on `#remaining_timeout_seconds`.
|
|
7
10
|
class Budget
|
|
8
11
|
##
|
|
9
|
-
|
|
10
|
-
# @param
|
|
11
|
-
# @param total_timeout_seconds [Integer, nil]
|
|
12
|
-
def initialize(max_requests:, total_timeout_seconds: nil)
|
|
13
|
-
|
|
14
|
-
raise ArgumentError, 'max_requests must be positive'
|
|
15
|
-
end
|
|
12
|
+
# @param max_requests [Integer] maximum HTTP request slots
|
|
13
|
+
# @param max_interactions [Integer] maximum preload interaction slots (default 0)
|
|
14
|
+
# @param total_timeout_seconds [Integer, nil] wall-clock timeout for the feed build
|
|
15
|
+
def initialize(max_requests:, max_interactions: 0, total_timeout_seconds: nil)
|
|
16
|
+
validate_slot_limits!(max_requests:, max_interactions:)
|
|
16
17
|
|
|
17
|
-
@
|
|
18
|
+
@remaining_requests = max_requests
|
|
19
|
+
@remaining_interactions = max_interactions
|
|
18
20
|
@start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
19
21
|
@total_timeout_seconds = total_timeout_seconds
|
|
20
22
|
@mutex = Mutex.new
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
##
|
|
24
|
-
# Consumes one request
|
|
26
|
+
# Consumes one HTTP request slot.
|
|
25
27
|
#
|
|
26
|
-
# @return [Integer] remaining request
|
|
27
|
-
# @raise [RequestBudgetExceeded] if no
|
|
28
|
+
# @return [Integer] remaining request slots after consumption
|
|
29
|
+
# @raise [RequestBudgetExceeded] if no request slots remain
|
|
28
30
|
def consume!
|
|
31
|
+
consume_request!
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
##
|
|
35
|
+
# Consumes one HTTP request slot.
|
|
36
|
+
#
|
|
37
|
+
# @return [Integer] remaining request slots after consumption
|
|
38
|
+
# @raise [RequestBudgetExceeded] if no request slots remain
|
|
39
|
+
def consume_request!
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
raise RequestBudgetExceeded, 'Request budget exhausted' if @remaining_requests.zero?
|
|
42
|
+
|
|
43
|
+
@remaining_requests -= 1
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
##
|
|
48
|
+
# Consumes one Browserless preload interaction slot.
|
|
49
|
+
#
|
|
50
|
+
# Preload must not steal pagination HTTP request slots.
|
|
51
|
+
#
|
|
52
|
+
# @return [Integer] remaining interaction slots after consumption
|
|
53
|
+
# @raise [InteractionBudgetExceeded] if no interaction slots remain
|
|
54
|
+
def consume_interaction!
|
|
29
55
|
@mutex.synchronize do
|
|
30
|
-
raise
|
|
56
|
+
raise InteractionBudgetExceeded, 'Interaction budget exhausted' if @remaining_interactions.zero?
|
|
31
57
|
|
|
32
|
-
@
|
|
58
|
+
@remaining_interactions -= 1
|
|
33
59
|
end
|
|
34
60
|
end
|
|
35
61
|
|
|
36
62
|
##
|
|
37
|
-
# @return [Integer]
|
|
63
|
+
# @return [Integer] HTTP request slots still available
|
|
38
64
|
def remaining
|
|
39
|
-
|
|
65
|
+
remaining_requests
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
##
|
|
69
|
+
# @return [Integer] HTTP request slots still available
|
|
70
|
+
def remaining_requests
|
|
71
|
+
@mutex.synchronize { @remaining_requests }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
##
|
|
75
|
+
# @return [Integer] preload interaction slots still available
|
|
76
|
+
def remaining_interactions
|
|
77
|
+
@mutex.synchronize { @remaining_interactions }
|
|
40
78
|
end
|
|
41
79
|
|
|
42
80
|
##
|
|
@@ -44,10 +82,51 @@ module Html2rss
|
|
|
44
82
|
def remaining_timeout_seconds
|
|
45
83
|
return unless @total_timeout_seconds
|
|
46
84
|
|
|
47
|
-
|
|
48
|
-
remaining = @total_timeout_seconds - elapsed
|
|
85
|
+
remaining = @total_timeout_seconds - elapsed_seconds
|
|
49
86
|
[remaining, 0.0].max
|
|
50
87
|
end
|
|
88
|
+
|
|
89
|
+
##
|
|
90
|
+
# @return [Float] seconds since this budget was created
|
|
91
|
+
def elapsed_seconds
|
|
92
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
##
|
|
96
|
+
# Resolves wall-clock seconds for the next adapter attempt.
|
|
97
|
+
#
|
|
98
|
+
# Prefers the tracked deadline remainder; falls back to the policy total when
|
|
99
|
+
# this Budget was constructed without a timeout (common in unit tests).
|
|
100
|
+
#
|
|
101
|
+
# @param fallback [Numeric] policy total timeout when remaining is untracked
|
|
102
|
+
# @return [Float] seconds available for this attempt
|
|
103
|
+
# @raise [RequestTimedOut] when no time remains
|
|
104
|
+
def effective_timeout_seconds(fallback:)
|
|
105
|
+
timeout = remaining_timeout_seconds || fallback
|
|
106
|
+
raise RequestTimedOut, 'Request timed out' if timeout <= 0
|
|
107
|
+
|
|
108
|
+
timeout.to_f
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
##
|
|
112
|
+
# @param fallback [Numeric] policy total timeout when remaining is untracked
|
|
113
|
+
# @return [Integer] milliseconds available for this attempt
|
|
114
|
+
# @raise [RequestTimedOut] when no time remains
|
|
115
|
+
def effective_timeout_ms(fallback:)
|
|
116
|
+
(effective_timeout_seconds(fallback:) * 1000).to_i
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def validate_slot_limits!(max_requests:, max_interactions:)
|
|
122
|
+
unless max_requests.is_a?(Integer) && max_requests.positive?
|
|
123
|
+
raise ArgumentError,
|
|
124
|
+
'max_requests must be positive'
|
|
125
|
+
end
|
|
126
|
+
return if max_interactions.is_a?(Integer) && !max_interactions.negative?
|
|
127
|
+
|
|
128
|
+
raise ArgumentError, 'max_interactions must be a non-negative integer'
|
|
129
|
+
end
|
|
51
130
|
end
|
|
52
131
|
end
|
|
53
132
|
end
|
|
@@ -77,9 +77,7 @@ module Html2rss
|
|
|
77
77
|
raise ArgumentError, 'policy must not be nil' if @policy.nil?
|
|
78
78
|
|
|
79
79
|
@origin_url = normalized_origin_url(request_options[:origin_url])
|
|
80
|
-
@budget = request_options.fetch(:budget)
|
|
81
|
-
Budget.new(max_requests: policy.max_requests, total_timeout_seconds: policy.total_timeout_seconds)
|
|
82
|
-
end
|
|
80
|
+
@budget = request_options.fetch(:budget) { raise ArgumentError, 'budget is required' }
|
|
83
81
|
raise ArgumentError, 'budget must not be nil' if @budget.nil?
|
|
84
82
|
end
|
|
85
83
|
|
|
@@ -27,33 +27,24 @@ module Html2rss
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
##
|
|
30
|
-
#
|
|
30
|
+
# @return [ResponseGuard]
|
|
31
|
+
attr_reader :response_guard
|
|
32
|
+
|
|
33
|
+
# Executes the request with runtime policy enforcement, returning the normalized response.
|
|
31
34
|
#
|
|
32
|
-
# @return [Response] normalized
|
|
33
|
-
|
|
34
|
-
# SSRF protection here is pre-connection only (DNS resolution via Policy).
|
|
35
|
-
# A DNS rebinding attack between resolution and connect cannot be caught at this layer.
|
|
36
|
-
def execute
|
|
37
|
-
check_timeout!
|
|
35
|
+
# @return [Response] normalized response
|
|
36
|
+
def perform_execute
|
|
38
37
|
deadline = request_deadline
|
|
39
|
-
response_guard
|
|
40
|
-
response_guard
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
raise RequestTimedOut, error.message
|
|
38
|
+
@response_guard = ResponseGuard.new(policy: ctx.policy)
|
|
39
|
+
raw_response = faraday_request(response_guard, deadline:, streaming_buffer: true)
|
|
40
|
+
raw_response = retry_without_streaming(response_guard, deadline:) if retry_without_streaming?(raw_response)
|
|
41
|
+
build_response(raw_response)
|
|
44
42
|
end
|
|
45
43
|
|
|
46
44
|
private
|
|
47
45
|
|
|
48
46
|
def request_deadline
|
|
49
|
-
monotonic_now +
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def perform_request(deadline:)
|
|
53
|
-
response_guard = ResponseGuard.new(policy: ctx.policy)
|
|
54
|
-
response = faraday_request(response_guard, deadline:, streaming_buffer: true)
|
|
55
|
-
response = retry_without_streaming(response_guard, deadline:) if retry_without_streaming?(response)
|
|
56
|
-
[response_guard, response]
|
|
47
|
+
monotonic_now + ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
|
|
57
48
|
end
|
|
58
49
|
|
|
59
50
|
def build_response(response)
|
|
@@ -61,13 +52,8 @@ module Html2rss
|
|
|
61
52
|
status: response.status)
|
|
62
53
|
end
|
|
63
54
|
|
|
64
|
-
def validate_request!(consume_budget: true)
|
|
65
|
-
ctx.budget.consume! if consume_budget
|
|
66
|
-
ctx.policy.validate_request!(url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
55
|
def faraday_request(response_guard, deadline:, streaming_buffer:, consume_budget: true)
|
|
70
|
-
|
|
56
|
+
preflight!(consume_budget:)
|
|
71
57
|
|
|
72
58
|
client.get do |req|
|
|
73
59
|
apply_timeouts(req, deadline:)
|
|
@@ -3,15 +3,23 @@
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
class RequestService
|
|
5
5
|
##
|
|
6
|
-
# Strategy to read a local HTML file.
|
|
6
|
+
# Strategy to read a local HTML file (CLI `--input`).
|
|
7
|
+
#
|
|
8
|
+
# Intentionally skips remote budget/policy preflight and ResponseGuard
|
|
9
|
+
# postflight: this adapter never opens a network connection. SSRF controls
|
|
10
|
+
# remain on remote strategies; do not use this adapter for remote URLs.
|
|
7
11
|
class LocalFileStrategy < Strategy
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def skip_preflight? = true
|
|
15
|
+
|
|
16
|
+
def skip_postflight? = true
|
|
17
|
+
|
|
8
18
|
##
|
|
9
|
-
# Executes the local file read.
|
|
10
|
-
#
|
|
11
19
|
# @return [Response] the mock response wrapped around the file contents
|
|
12
20
|
# @raise [ArgumentError] if the local file path is missing
|
|
13
21
|
# @raise [Errno::ENOENT] if the file does not exist
|
|
14
|
-
def
|
|
22
|
+
def fetch
|
|
15
23
|
file_path = ctx.request[:local_file_path]
|
|
16
24
|
raise ArgumentError, 'Local file path is required for local_file strategy' unless file_path
|
|
17
25
|
raise Errno::ENOENT, "File not found: #{file_path}" unless File.exist?(file_path)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ipaddr'
|
|
4
|
+
require 'resolv'
|
|
5
|
+
require 'socket'
|
|
6
|
+
|
|
7
|
+
module Html2rss
|
|
8
|
+
class RequestService
|
|
9
|
+
##
|
|
10
|
+
# Enforces public-network reachability for request targets and remote IPs.
|
|
11
|
+
#
|
|
12
|
+
# Owns DNS resolution, host denylist, and blocked IP ranges. Callers go through
|
|
13
|
+
# {Policy} so SSRF/origin rules stay single-homed on the public façade.
|
|
14
|
+
class NetworkGuard
|
|
15
|
+
# Hostnames treated as local/private surfaces.
|
|
16
|
+
LOCAL_HOSTS = %w[localhost localhost.localdomain metadata.google.internal].to_set.freeze
|
|
17
|
+
# IP ranges blocked when private networks are disabled.
|
|
18
|
+
BLOCKED_IP_RANGES = [
|
|
19
|
+
IPAddr.new('0.0.0.0/8'),
|
|
20
|
+
IPAddr.new('10.0.0.0/8'),
|
|
21
|
+
IPAddr.new('127.0.0.0/8'),
|
|
22
|
+
IPAddr.new('169.254.0.0/16'),
|
|
23
|
+
IPAddr.new('172.16.0.0/12'),
|
|
24
|
+
IPAddr.new('192.168.0.0/16'),
|
|
25
|
+
IPAddr.new('224.0.0.0/4'),
|
|
26
|
+
IPAddr.new('::/128'),
|
|
27
|
+
IPAddr.new('::1/128'),
|
|
28
|
+
IPAddr.new('fe80::/10'),
|
|
29
|
+
IPAddr.new('fc00::/7'),
|
|
30
|
+
IPAddr.new('ff00::/8')
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
##
|
|
34
|
+
# @param allow_private_networks [Boolean] whether private network targets are allowed
|
|
35
|
+
# @param resolver [#each_address, #getaddrinfo] DNS resolver used for hostname classification
|
|
36
|
+
def initialize(allow_private_networks:, resolver: Socket)
|
|
37
|
+
@allow_private_networks = allow_private_networks ? true : false
|
|
38
|
+
@resolver = resolver
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# Rejects URLs whose host is denylisted or resolves to a blocked address.
|
|
44
|
+
#
|
|
45
|
+
# @param url [Html2rss::Url] destination URL
|
|
46
|
+
# @return [void]
|
|
47
|
+
# @raise [PrivateNetworkDenied] if the target resolves to a private address
|
|
48
|
+
def enforce_public_network!(url)
|
|
49
|
+
host = url.host
|
|
50
|
+
return if allow_private_networks?
|
|
51
|
+
return unless blocked_host?(host) || resolved_ip_addresses(host).any? { |address| blocked_ip?(address) }
|
|
52
|
+
|
|
53
|
+
raise PrivateNetworkDenied, "Private network target denied for #{url}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# Validates the resolved remote IP for a completed request.
|
|
58
|
+
#
|
|
59
|
+
# @param ip [String, nil] remote IP address reported by the client
|
|
60
|
+
# @param url [Html2rss::Url] URL associated with the response
|
|
61
|
+
# @return [void]
|
|
62
|
+
# @raise [PrivateNetworkDenied] if the response came from a blocked address
|
|
63
|
+
def validate_remote_ip!(ip:, url:)
|
|
64
|
+
return if allow_private_networks?
|
|
65
|
+
return if ip.nil? || ip.empty?
|
|
66
|
+
|
|
67
|
+
parsed_ip = parse_ip(ip)
|
|
68
|
+
raise PrivateNetworkDenied, "Remote IP could not be validated for #{url}" unless parsed_ip
|
|
69
|
+
return unless blocked_ip?(parsed_ip)
|
|
70
|
+
|
|
71
|
+
raise PrivateNetworkDenied, "Private network target denied for #{url}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
attr_reader :resolver
|
|
77
|
+
|
|
78
|
+
def allow_private_networks?
|
|
79
|
+
@allow_private_networks
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def blocked_host?(host)
|
|
83
|
+
LOCAL_HOSTS.include?(host.to_s.downcase)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def resolved_ip_addresses(host)
|
|
87
|
+
literal = parse_ip(host)
|
|
88
|
+
return [literal] if literal
|
|
89
|
+
|
|
90
|
+
if resolver.respond_to?(:each_address)
|
|
91
|
+
addresses_from_each_address(host)
|
|
92
|
+
else
|
|
93
|
+
addresses_from_getaddrinfo(host)
|
|
94
|
+
end
|
|
95
|
+
rescue Resolv::ResolvError, SocketError, SystemCallError
|
|
96
|
+
[]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def addresses_from_each_address(host)
|
|
100
|
+
[].tap do |addresses|
|
|
101
|
+
resolver.each_address(host) do |address|
|
|
102
|
+
parsed = parse_ip(address)
|
|
103
|
+
addresses << parsed if parsed
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def addresses_from_getaddrinfo(host)
|
|
109
|
+
resolver.getaddrinfo(host, nil).filter_map do |entry|
|
|
110
|
+
parse_ip(entry[3])
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse_ip(value)
|
|
115
|
+
IPAddr.new(value)
|
|
116
|
+
rescue IPAddr::AddressFamilyError, IPAddr::InvalidAddressError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def blocked_ip?(address)
|
|
121
|
+
BLOCKED_IP_RANGES.any? { |range| range.include?(address) }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -1,32 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'ipaddr'
|
|
4
|
-
require 'resolv'
|
|
5
3
|
require 'socket'
|
|
6
4
|
|
|
7
5
|
module Html2rss
|
|
8
6
|
class RequestService
|
|
9
7
|
##
|
|
10
|
-
#
|
|
11
|
-
|
|
8
|
+
# Enforcer for the runtime request envelope of a single feed build.
|
|
9
|
+
#
|
|
10
|
+
# Public façade for request limits, same-origin/follow-up rules, and
|
|
11
|
+
# network reachability. DNS/host/IP enforcement lives on {NetworkGuard}.
|
|
12
|
+
class Policy
|
|
13
|
+
# Hard ceiling for configured max_requests during a feed build.
|
|
12
14
|
MAX_REQUESTS_CEILING = 10
|
|
13
|
-
# Hostnames treated as local/private surfaces.
|
|
14
|
-
LOCAL_HOSTS = %w[localhost localhost.localdomain metadata.google.internal].to_set.freeze
|
|
15
|
-
# IP ranges blocked when private networks are disabled.
|
|
16
|
-
BLOCKED_IP_RANGES = [
|
|
17
|
-
IPAddr.new('0.0.0.0/8'),
|
|
18
|
-
IPAddr.new('10.0.0.0/8'),
|
|
19
|
-
IPAddr.new('127.0.0.0/8'),
|
|
20
|
-
IPAddr.new('169.254.0.0/16'),
|
|
21
|
-
IPAddr.new('172.16.0.0/12'),
|
|
22
|
-
IPAddr.new('192.168.0.0/16'),
|
|
23
|
-
IPAddr.new('224.0.0.0/4'),
|
|
24
|
-
IPAddr.new('::/128'),
|
|
25
|
-
IPAddr.new('::1/128'),
|
|
26
|
-
IPAddr.new('fe80::/10'),
|
|
27
|
-
IPAddr.new('fc00::/7'),
|
|
28
|
-
IPAddr.new('ff00::/8')
|
|
29
|
-
].freeze
|
|
30
15
|
|
|
31
16
|
# Default policy values used when request controls are not explicitly set.
|
|
32
17
|
DEFAULTS = {
|
|
@@ -71,7 +56,7 @@ module Html2rss
|
|
|
71
56
|
@max_requests = [validate_positive_integer!(:max_requests, max_requests), MAX_REQUESTS_CEILING].min
|
|
72
57
|
@allow_private_networks = allow_private_networks ? true : false
|
|
73
58
|
@allow_cross_origin_followups = allow_cross_origin_followups ? true : false
|
|
74
|
-
@
|
|
59
|
+
@network_guard = NetworkGuard.new(allow_private_networks: @allow_private_networks, resolver:)
|
|
75
60
|
freeze
|
|
76
61
|
end
|
|
77
62
|
|
|
@@ -116,7 +101,7 @@ module Html2rss
|
|
|
116
101
|
# @raise [PrivateNetworkDenied] if the target resolves to a private address
|
|
117
102
|
def validate_request!(url:, origin_url:, relation:)
|
|
118
103
|
enforce_same_origin!(url, origin_url, relation)
|
|
119
|
-
enforce_public_network!(url)
|
|
104
|
+
network_guard.enforce_public_network!(url)
|
|
120
105
|
end
|
|
121
106
|
|
|
122
107
|
##
|
|
@@ -144,19 +129,12 @@ module Html2rss
|
|
|
144
129
|
# @return [void]
|
|
145
130
|
# @raise [PrivateNetworkDenied] if the response came from a blocked address
|
|
146
131
|
def validate_remote_ip!(ip:, url:)
|
|
147
|
-
|
|
148
|
-
return if ip.nil? || ip.empty?
|
|
149
|
-
|
|
150
|
-
parsed_ip = parse_ip(ip)
|
|
151
|
-
raise PrivateNetworkDenied, "Remote IP could not be validated for #{url}" unless parsed_ip
|
|
152
|
-
return unless blocked_ip?(parsed_ip)
|
|
153
|
-
|
|
154
|
-
raise PrivateNetworkDenied, "Private network target denied for #{url}"
|
|
132
|
+
network_guard.validate_remote_ip!(ip:, url:)
|
|
155
133
|
end
|
|
156
134
|
|
|
157
135
|
private
|
|
158
136
|
|
|
159
|
-
attr_reader :
|
|
137
|
+
attr_reader :network_guard
|
|
160
138
|
|
|
161
139
|
def validate_positive_integer!(name, value)
|
|
162
140
|
raise ArgumentError, "#{name} must be positive" unless value.is_a?(Integer) && value.positive?
|
|
@@ -194,56 +172,6 @@ module Html2rss
|
|
|
194
172
|
|
|
195
173
|
url.scheme == 'https' ? 443 : 80
|
|
196
174
|
end
|
|
197
|
-
|
|
198
|
-
def enforce_public_network!(url)
|
|
199
|
-
host = url.host
|
|
200
|
-
return if allow_private_networks?
|
|
201
|
-
return unless blocked_host?(host) || resolved_ip_addresses(host).any? { |address| blocked_ip?(address) }
|
|
202
|
-
|
|
203
|
-
raise PrivateNetworkDenied, "Private network target denied for #{url}"
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
def blocked_host?(host)
|
|
207
|
-
LOCAL_HOSTS.include?(host.to_s.downcase)
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
def resolved_ip_addresses(host)
|
|
211
|
-
literal = parse_ip(host)
|
|
212
|
-
return [literal] if literal
|
|
213
|
-
|
|
214
|
-
if resolver.respond_to?(:each_address)
|
|
215
|
-
addresses_from_each_address(host)
|
|
216
|
-
else
|
|
217
|
-
addresses_from_getaddrinfo(host)
|
|
218
|
-
end
|
|
219
|
-
rescue Resolv::ResolvError, SocketError, SystemCallError
|
|
220
|
-
[]
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def addresses_from_each_address(host)
|
|
224
|
-
[].tap do |addresses|
|
|
225
|
-
resolver.each_address(host) do |address|
|
|
226
|
-
parsed = parse_ip(address)
|
|
227
|
-
addresses << parsed if parsed
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
def addresses_from_getaddrinfo(host)
|
|
233
|
-
resolver.getaddrinfo(host, nil).filter_map do |entry|
|
|
234
|
-
parse_ip(entry[3])
|
|
235
|
-
end
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def parse_ip(value)
|
|
239
|
-
IPAddr.new(value)
|
|
240
|
-
rescue IPAddr::AddressFamilyError, IPAddr::InvalidAddressError
|
|
241
|
-
nil
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
def blocked_ip?(address)
|
|
245
|
-
BLOCKED_IP_RANGES.any? { |range| range.include?(address) }
|
|
246
|
-
end
|
|
247
175
|
end
|
|
248
176
|
|
|
249
177
|
# Shared immutable policy instance used for default request execution.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class RequestService
|
|
5
|
+
class PuppetCommander
|
|
6
|
+
##
|
|
7
|
+
# Owns Puppeteer request interception, deferred navigation errors, main-frame
|
|
8
|
+
# filtering, and redirect-chain validation via {Policy}.
|
|
9
|
+
#
|
|
10
|
+
# Does not re-own Policy rules — only calls +validate_*!+ on +ctx.policy+.
|
|
11
|
+
class NavigationGuards
|
|
12
|
+
class << self
|
|
13
|
+
##
|
|
14
|
+
# Resolves the final response URL for Response building and IP validation.
|
|
15
|
+
#
|
|
16
|
+
# @param navigation_response [Puppeteer::HTTPResponse, nil]
|
|
17
|
+
# @param fallback_url [String, Html2rss::Url]
|
|
18
|
+
# @return [Html2rss::Url]
|
|
19
|
+
def response_url(navigation_response, fallback_url)
|
|
20
|
+
raw_url = navigation_response&.url || fallback_url.to_s
|
|
21
|
+
Html2rss::Url.from_absolute(raw_url)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
##
|
|
26
|
+
# @param ctx [Context] request context providing policy and origin
|
|
27
|
+
# @param skip_request_resources [Set<String>] resource types to abort
|
|
28
|
+
def initialize(ctx:, skip_request_resources:)
|
|
29
|
+
@ctx = ctx
|
|
30
|
+
@skip_request_resources = skip_request_resources
|
|
31
|
+
@navigation_error = nil
|
|
32
|
+
@latest_navigation_response = nil
|
|
33
|
+
@main_frame = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# Captures the main frame and wires request/response interceptors.
|
|
38
|
+
#
|
|
39
|
+
# @param page [Puppeteer::Page] browser page
|
|
40
|
+
# @return [void]
|
|
41
|
+
def install!(page)
|
|
42
|
+
@main_frame = page.main_frame if page.respond_to?(:main_frame)
|
|
43
|
+
page.request_interception = true
|
|
44
|
+
page.on('request') { |request| handle_request(request) }
|
|
45
|
+
page.on('response') { |response| handle_response(response) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
##
|
|
49
|
+
# Clears deferred error and latest navigation response before goto.
|
|
50
|
+
#
|
|
51
|
+
# @return [void]
|
|
52
|
+
def begin_navigation!
|
|
53
|
+
@navigation_error = nil
|
|
54
|
+
@latest_navigation_response = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
##
|
|
58
|
+
# Re-raises a deferred navigation error when one was captured.
|
|
59
|
+
#
|
|
60
|
+
# @return [void]
|
|
61
|
+
# @raise [Html2rss::Error] when a navigation request or response validation failed
|
|
62
|
+
def raise_deferred_error!
|
|
63
|
+
raise @navigation_error if @navigation_error
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
##
|
|
67
|
+
# @return [Puppeteer::HTTPResponse, nil] latest main-frame navigation response
|
|
68
|
+
attr_reader :latest_navigation_response
|
|
69
|
+
|
|
70
|
+
##
|
|
71
|
+
# Validates the remote IP of a navigation response via Policy.
|
|
72
|
+
#
|
|
73
|
+
# @param navigation_response [Puppeteer::HTTPResponse, nil]
|
|
74
|
+
# @return [void]
|
|
75
|
+
def validate_final!(navigation_response)
|
|
76
|
+
final_url = self.class.response_url(navigation_response, ctx.url)
|
|
77
|
+
ctx.policy.validate_remote_ip!(ip: remote_ip(navigation_response), url: final_url)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
attr_reader :ctx, :skip_request_resources, :main_frame
|
|
83
|
+
|
|
84
|
+
def handle_request(request)
|
|
85
|
+
validate_request!(request)
|
|
86
|
+
|
|
87
|
+
skip_request_resources.member?(request.resource_type) ? request.abort : request.continue
|
|
88
|
+
rescue Html2rss::Error => error
|
|
89
|
+
store_navigation_error(error, navigation_request: request.navigation_request?)
|
|
90
|
+
request.abort
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def handle_response(response)
|
|
94
|
+
@latest_navigation_response = response if main_frame_navigation_response?(response)
|
|
95
|
+
validate_final!(response)
|
|
96
|
+
rescue Html2rss::Error => error
|
|
97
|
+
store_navigation_error(error, navigation_request: response.request.navigation_request?)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate_request!(request)
|
|
101
|
+
validate_navigation_redirect_chain!(request)
|
|
102
|
+
validate_navigation_target!(request)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def main_frame_navigation_response?(response)
|
|
106
|
+
request = response.request
|
|
107
|
+
return false unless request.navigation_request?
|
|
108
|
+
return true unless request.respond_to?(:frame)
|
|
109
|
+
|
|
110
|
+
frame = request.frame
|
|
111
|
+
return true if frame.nil?
|
|
112
|
+
return frame == main_frame unless main_frame.nil?
|
|
113
|
+
return true unless frame.respond_to?(:parent_frame)
|
|
114
|
+
|
|
115
|
+
frame.parent_frame.nil?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def remote_ip(navigation_response)
|
|
119
|
+
navigation_response.remote_address&.ip
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def request_chain(request)
|
|
123
|
+
(request.redirect_chain + [request]).map { |entry| request_url(entry) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def request_url(request)
|
|
127
|
+
Html2rss::Url.from_absolute(request.url)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def validate_navigation_redirect_chain!(request)
|
|
131
|
+
request_chain(request).each_cons(2) do |from_url, to_url|
|
|
132
|
+
ctx.policy.validate_redirect!(from_url:, to_url:, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def validate_navigation_target!(request)
|
|
137
|
+
ctx.policy.validate_request!(url: request_url(request), origin_url: ctx.origin_url, relation: ctx.relation)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def store_navigation_error(error, navigation_request:)
|
|
141
|
+
return unless navigation_request
|
|
142
|
+
|
|
143
|
+
@navigation_error = error if @navigation_error.nil?
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|