searxng 0.2.0 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 312181892379c3ed0181f46a10f535e96803969d7f1b224b87e07fe7fe2d68d4
4
- data.tar.gz: e206771ab2279dac1329a08dc1195d254e5992c4532c7e2e5afd95a67b267991
3
+ metadata.gz: 0752ce85481fa3a6a5846b4326effa0251ff69e77a32912dc046f06e35542832
4
+ data.tar.gz: 1700a1ad55569d5cbb7137c6b3c6cde6583c8b6198508975e6b66030dfd2bc39
5
5
  SHA512:
6
- metadata.gz: ce7b9acd2231550ddd32fd27956b1ef366f044a769f7b0034a6f5c5ade98efed0ac96a34856d1015456cc46132b024afb723726117dd3a9df7b82b26282d5ad7
7
- data.tar.gz: 5636c48dd7221654b182793931ffb097fe017939fe8fc41781819deda7ed627a921edf508243f16943fa70ade77ee04b59b3ea6ed6be76de63eb34becbe2e13c
6
+ metadata.gz: e85f85f3363f0468cef0d8dd6b5318f2d2eba8ef7268e6c607fbbe318a5140347e395400b747c33716f4ee73a2fa99e6af54398e7e20e42dbfb2e1a76f60593a
7
+ data.tar.gz: ff609005b3e6993106c5c29855801675e48b25221456d7f4561f43390a31af1002d98a6abc718d407cd3c87bcede1ff8bed6b587cf166c6fdd5dddfb699aa3a8
data/CHANGELOG.md CHANGED
@@ -1,7 +1,26 @@
1
- # Changelog
1
+ # CHANGELOG
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.1 (2026-10-29)
6
+
7
+ - Fix cleartext HTTP proxy fetches to dial the `NetworkPolicy`-pinned IP and set `Host` to the original hostname.
8
+ - Fix unbounded `web_url_read` response buffering with a shared byte ceiling across HTTP, FTP, SFTP, SMB, and Gemini.
9
+ - Fix process-wide `@@url_cache` growth with TTL purge, entry cap, byte budget, and a mutex.
10
+ - Fix Gemini TLS verification by loading the default CA store via `SSLContext#set_params`.
11
+ - Fix SMB credentials leaking through `smbclient` argv; pass the password via `PASSWD`.
12
+ - Fix website HTTP errors mislabeled as SearXNG search failures; include redirect `Location` when present.
13
+ - Fix invalid `time_range` / `safesearch` values being silently dropped; raise `ConfigurationError`.
14
+ - Fix IPFS gateway path traversal via unsafe CID or `..` path segments.
15
+ - Fix `timeoutMs` of `0` disabling deadlines; clamp to `1000..30000`.
16
+ - Fix release gate ignoring lint/test failures (`pipefail`) and dirty staged/untracked trees.
17
+ - Fix release confirmation treating EOF as publish; require an explicit Enter for the default.
18
+ - Require a `CHANGELOG.md` version heading matching `VERSION` before gem push.
19
+ - Fix `searxng serve` swallowing startup errors into an unbounded `StringIO`; reopen stderr to `File::NULL` and print configuration failures.
20
+ - Add `NetworkPolicy` coverage for local NAT64 (`64:ff9b:1::/48`), IPv4-compatible embeds, documentation ranges, and bracketed IPv6 hostnames via `uri.hostname`.
21
+ - Document Ruby `>= 3.4` and that `.onion` / `.i2p` are not supported by `web_url_read`.
22
+ - Redact URL userinfo from fetch error URIs; default `maxLength` to `20000`; honor `SEARXNG_USER_AGENT` for URL reads.
23
+
5
24
  ## 0.2.0
6
25
 
7
26
  - MCP: Added `web_url_read` tool (URL fetch + Markdown conversion with pagination/section options).
data/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # searxng.rb
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/searxng.svg?v=0.2.0)](https://badge.fury.io/rb/searxng) [![Test Status](https://github.com/amkisko/searxng.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/searxng.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/searxng.rb/graph/badge.svg)](https://codecov.io/gh/amkisko/searxng.rb)
3
+ [![Gem Version](https://badge.fury.io/rb/searxng.svg?v=0.2.1)](https://badge.fury.io/rb/searxng) [![Test Status](https://github.com/amkisko/searxng.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/searxng.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/searxng.rb/graph/badge.svg)](https://app.codecov.io/github/amkisko/searxng.rb)
4
4
 
5
5
  Ruby gem providing a SearXNG HTTP client, CLI (search), and MCP (Model Context Protocol) server for web search. Integrates with MCP-compatible clients like Codex, Cursor, Claude, and other MCP-enabled tools.
6
6
 
7
7
  ## Requirements
8
8
 
9
- - **Ruby 3.1 or higher** (Ruby 3.0 and earlier are not supported). For managing Ruby versions, [rbenv](https://github.com/rbenv/rbenv) or [mise](https://mise.jdx.dev/) are recommended; system Ruby may be sufficient if it meets the version requirement.
9
+ - **Ruby 3.4 or higher**. For managing Ruby versions, [rbenv](https://github.com/rbenv/rbenv) or [mise](https://mise.jdx.dev/) are recommended; system Ruby may be sufficient if it meets the version requirement.
10
10
 
11
11
  ## Quick Start
12
12
 
@@ -51,9 +51,11 @@ end
51
51
  - **SEARXNG_CA_FILE** / **SEARXNG_CA_PATH** (optional): Custom CA certificate file or directory for HTTPS. You can also pass `ca_file:`, `ca_path:`, or `verify_mode:` to `Searxng::Client.new`.
52
52
  - **SEARXNG_USER_AGENT** (optional): Custom User-Agent string. The client sends a default identifying the gem; if your instance returns 403 Forbidden (e.g. bot detection), set a custom User-Agent or pass `user_agent:` to `Searxng::Client.new`.
53
53
  - **HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY** (optional): Proxy configuration for HTTP(S) requests.
54
- - **FTP_USER / FTP_PASSWORD** (optional): FTP credentials used when URL doesn’t include auth.
55
- - **SFTP_USER / SFTP_PASSWORD** (optional): SFTP credentials used when URL doesn’t include auth.
56
- - **SMB_USER / SMB_PASSWORD / SMB_DOMAIN** (optional): SMB credentials/domain used when URL doesn’t include auth.
54
+ - **SEARXNG_WEB_URL_SCHEMES** (optional): Comma-separated protocols enabled for `web_url_read`. The secure default is `http,https,ipfs`; opt in to any required subset of `ftp,sftp,smb,gemini`.
55
+ - **SEARXNG_WEB_URL_CREDENTIAL_HOSTS** (optional): Comma-separated host allowlist permitted to receive credentials from the FTP, SFTP, or SMB environment variables. Credentials embedded directly in a URL remain scoped to that URL.
56
+ - **FTP_USER / FTP_PASSWORD** (optional): FTP credentials used only for hosts in `SEARXNG_WEB_URL_CREDENTIAL_HOSTS`.
57
+ - **SFTP_USER / SFTP_PASSWORD** (optional): SFTP credentials used only for hosts in `SEARXNG_WEB_URL_CREDENTIAL_HOSTS`.
58
+ - **SMB_USER / SMB_PASSWORD / SMB_DOMAIN** (optional): SMB credentials/domain used only for hosts in `SEARXNG_WEB_URL_CREDENTIAL_HOSTS`.
57
59
  - **IPFS_GATEWAY** (optional): Gateway used for `ipfs://` URLs (default: `https://ipfs.io`).
58
60
  - **GEMINI_INSECURE=1** (optional): disables TLS certificate verification for Gemini fetches in `web_url_read`.
59
61
 
@@ -68,7 +70,7 @@ The MCP server provides the following tools:
68
70
  2. **web_url_read** - Fetch a URL and return Markdown content
69
71
  - Parameters: `url` (required), `startChar`, `maxLength`, `section`, `paragraphRange`, `readHeadings`, `timeoutMs` (all optional).
70
72
  - Uses `teplo_core` for HTML/XML to Markdown conversion (with workspace fallback to `textplorer/core-ruby` in development).
71
- - Supports `http`, `https`, `ftp`, `sftp`, `smb`, `gemini`, and `ipfs` URLs (`ipfs://` is resolved via `IPFS_GATEWAY`).
73
+ - Supports `http`, `https`, and `ipfs` by default. `ftp`, `sftp`, `smb`, and `gemini` require explicit opt-in through `SEARXNG_WEB_URL_SCHEMES`.
72
74
 
73
75
  The MCP server also exposes resources:
74
76
 
@@ -166,12 +168,12 @@ Useful for testing the `searxng_web_search` tool before integrating with Cursor
166
168
  ## Protocol Support Notes
167
169
 
168
170
  - `http` / `https`: fully supported.
169
- - `ftp`: supported by `web_url_read` (requires `net-ftp` runtime gem).
170
- - `sftp`: supported by `web_url_read` (requires `net-sftp` runtime gem).
171
- - `smb`: supported by `web_url_read` via `smbclient` available in `PATH`.
172
- - `gemini`: supported by `web_url_read` (Gemini fetch + Gemtext to Markdown conversion).
171
+ - `ftp`: opt-in through `SEARXNG_WEB_URL_SCHEMES` (requires the `net-ftp` runtime gem).
172
+ - `sftp`: opt-in through `SEARXNG_WEB_URL_SCHEMES` (requires the `net-sftp` runtime gem).
173
+ - `smb`: opt-in through `SEARXNG_WEB_URL_SCHEMES` and uses `smbclient` available in `PATH`.
174
+ - `gemini`: opt-in through `SEARXNG_WEB_URL_SCHEMES` (Gemini fetch + Gemtext to Markdown conversion).
173
175
  - `ipfs`: supported by `web_url_read` through gateway resolution (`ipfs://` -> `IPFS_GATEWAY`).
174
- - `tor` / `i2p`: supported through `.onion` / `.i2p` hosts over HTTP(S), typically with proxy configuration.
176
+ - `.onion` / `.i2p`: not supported by `web_url_read` (destination policy resolves hosts locally before any proxy).
175
177
 
176
178
  ## Examples
177
179
 
@@ -217,6 +219,19 @@ For more information, see [CONTRIBUTING.md](CONTRIBUTING.md).
217
219
 
218
220
  If you discover a security vulnerability, please report it responsibly. See [SECURITY.md](SECURITY.md) for details.
219
221
 
222
+ ## Links
223
+
224
+ - [GitHub](https://github.com/amkisko/searxng.rb)
225
+ - [GitLab](https://gitlab.com/amkisko/searxng.rb)
226
+ - [RubyGems](https://rubygems.org/gems/searxng)
227
+ - [Versions Atom](https://rubygems.org/gems/searxng/versions.atom) (feed id `10648244160064`)
228
+ - [libraries.io](https://libraries.io/rubygems/searxng)
229
+ - [Deps.dev](https://deps.dev/rubygems/searxng)
230
+ - [SonarCloud](https://sonarcloud.io/project/overview?id=amkisko_searxng.rb)
231
+ - [Snyk](https://snyk.io/test/github/amkisko/searxng.rb)
232
+ - [Codecov](https://app.codecov.io/github/amkisko/searxng.rb)
233
+ - [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/amkisko/searxng.rb)
234
+
220
235
  ## License
221
236
 
222
237
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/bin/searxng CHANGED
@@ -5,13 +5,16 @@ cmd = ARGV.shift
5
5
  case cmd
6
6
  when "serve"
7
7
  unless ENV["DEBUG"]
8
- require "stringio"
9
- $stderr = StringIO.new
10
- $stderr.set_encoding("UTF-8")
8
+ $stderr.reopen(File::NULL, "w")
11
9
  end
12
10
  $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
13
11
  require "searxng/server"
14
- Searxng::Server.start
12
+ begin
13
+ Searxng::Server.start
14
+ rescue Searxng::ConfigurationError => e
15
+ warn e.message
16
+ exit 1
17
+ end
15
18
  when "search"
16
19
  $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
17
20
  require "searxng"
@@ -24,9 +24,13 @@ module Searxng
24
24
  user_agent: nil,
25
25
  configure_http: nil
26
26
  )
27
- @base_url = (base_url || ENV["SEARXNG_URL"] || DEFAULT_BASE_URL).to_s.chomp("/")
28
- @user = user || ENV["SEARXNG_USER"] || ENV["AUTH_USERNAME"]
29
- @password = password || ENV["SEARXNG_PASSWORD"] || ENV["AUTH_PASSWORD"]
27
+ @base_url = if base_url.nil?
28
+ present_string(ENV["SEARXNG_URL"]) || DEFAULT_BASE_URL
29
+ else
30
+ base_url.to_s.strip
31
+ end.chomp("/")
32
+ @user = present_string(user) || present_string(ENV["SEARXNG_USER"]) || present_string(ENV["AUTH_USERNAME"])
33
+ @password = present_string(password) || present_string(ENV["SEARXNG_PASSWORD"]) || present_string(ENV["AUTH_PASSWORD"])
30
34
  @open_timeout = open_timeout
31
35
  @read_timeout = read_timeout
32
36
  @ca_file = ca_file || ENV["SEARXNG_CA_FILE"]
@@ -49,13 +53,30 @@ module Searxng
49
53
  def build_search_uri(query, pageno:, time_range:, language:, safesearch:)
50
54
  uri = URI.join("#{@base_url}/", "search")
51
55
  params = {"q" => query, "format" => "json", "pageno" => pageno.to_s}
52
- params["time_range"] = time_range if time_range && VALID_TIME_RANGES.include?(time_range.to_s)
56
+ if time_range
57
+ range = time_range.to_s
58
+ unless VALID_TIME_RANGES.include?(range)
59
+ raise ConfigurationError, "Invalid time_range: #{time_range.inspect} (allowed: #{VALID_TIME_RANGES.join(", ")})"
60
+ end
61
+ params["time_range"] = range
62
+ end
53
63
  params["language"] = language if language && language != "all"
54
- params["safesearch"] = safesearch.to_s if safesearch && VALID_SAFESEARCH.include?(safesearch.to_i)
64
+ unless safesearch.nil?
65
+ value = Integer(safesearch, exception: false)
66
+ unless value && VALID_SAFESEARCH.include?(value)
67
+ raise ConfigurationError, "Invalid safesearch: #{safesearch.inspect} (allowed: #{VALID_SAFESEARCH.join(", ")})"
68
+ end
69
+ params["safesearch"] = value.to_s
70
+ end
55
71
  uri.query = URI.encode_www_form(params)
56
72
  uri
57
73
  end
58
74
 
75
+ def present_string(value)
76
+ text = value&.to_s&.strip
77
+ text unless text.nil? || text.empty?
78
+ end
79
+
59
80
  def perform_request(uri)
60
81
  http = build_http(uri)
61
82
  @configure_http&.call(http, uri)
@@ -136,8 +157,8 @@ module Searxng
136
157
  return nil if raw.nil? || raw.strip.empty?
137
158
 
138
159
  URI(raw.include?("://") ? raw : "http://#{raw}")
139
- rescue
140
- nil
160
+ rescue URI::InvalidURIError, ArgumentError
161
+ raise ConfigurationError, "Proxy URL is invalid: #{raw.inspect}"
141
162
  end
142
163
 
143
164
  def no_proxy_match?(host)
@@ -70,5 +70,13 @@ module Searxng
70
70
  "SearXNG returned #{status_code} #{status_message}".strip
71
71
  end
72
72
  end
73
+
74
+ def fetch_http_error(status_code, status_message, location: nil)
75
+ code = status_code.to_i
76
+ base = "Website Error (#{code}): #{status_message}".strip
77
+ return "#{base} Location: #{location}" if location && !location.to_s.strip.empty?
78
+
79
+ base
80
+ end
73
81
  end
74
82
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "socket"
5
+ require "uri"
6
+
7
+ module Searxng
8
+ class NetworkPolicy
9
+ FORBIDDEN_RANGES = %w[
10
+ 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8
11
+ 169.254.0.0/16 172.16.0.0/12 192.0.0.0/24 192.0.2.0/24
12
+ 192.88.99.0/24 192.168.0.0/16 198.18.0.0/15 198.51.100.0/24
13
+ 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4
14
+ ::/128 ::1/128 ::/96 64:ff9b::/96 64:ff9b:1::/48 100::/64
15
+ 2001::/23 2001:db8::/32 2002::/16 3fff::/20
16
+ fc00::/7 fe80::/10 fec0::/10 ff00::/8
17
+ ].map { |range| IPAddr.new(range) }.freeze
18
+
19
+ def self.validate!(url, allowed_schemes:, allow_userinfo: false)
20
+ uri = URI.parse(url.to_s)
21
+ reject! unless allowed_schemes.include?(uri.scheme&.downcase)
22
+ reject! if hostname_of(uri).to_s.empty? || (uri.userinfo && !allow_userinfo)
23
+
24
+ [uri, allowed_ip_address(uri)]
25
+ rescue URI::InvalidURIError
26
+ reject!
27
+ end
28
+
29
+ def self.allowed_ip_address(uri)
30
+ host = hostname_of(uri)
31
+ addresses = Addrinfo.getaddrinfo(host, uri.port, nil, :STREAM)
32
+ .map(&:ip_address)
33
+ .uniq
34
+ reject! if addresses.empty? || addresses.any? { |address| forbidden?(address) }
35
+
36
+ addresses.first
37
+ rescue SocketError, SystemCallError
38
+ reject!
39
+ end
40
+ private_class_method :allowed_ip_address
41
+
42
+ def self.hostname_of(uri)
43
+ uri.respond_to?(:hostname) ? uri.hostname : uri.host.to_s.delete_prefix("[").delete_suffix("]")
44
+ end
45
+ private_class_method :hostname_of
46
+
47
+ def self.forbidden?(address)
48
+ parsed = normalize_address(address)
49
+ return true if FORBIDDEN_RANGES.any? { |range| range.include?(parsed) }
50
+ return forbidden?(parsed.native.to_s) if ipv4_compatible?(parsed)
51
+
52
+ false
53
+ rescue IPAddr::InvalidAddressError
54
+ true
55
+ end
56
+ private_class_method :forbidden?
57
+
58
+ def self.normalize_address(address)
59
+ parsed = IPAddr.new(address.split("%", 2).first)
60
+ parsed.ipv4_mapped? ? parsed.native : parsed
61
+ end
62
+ private_class_method :normalize_address
63
+
64
+ def self.ipv4_compatible?(parsed)
65
+ parsed.ipv6? && parsed.respond_to?(:ipv4_compat?) && parsed.ipv4_compat?
66
+ end
67
+ private_class_method :ipv4_compatible?
68
+
69
+ def self.reject!
70
+ raise ConfigurationError, "URL destination is not allowed"
71
+ end
72
+ private_class_method :reject!
73
+ end
74
+ end
@@ -8,6 +8,7 @@ require "socket"
8
8
  require "time"
9
9
  require "timeout"
10
10
  require "uri"
11
+ require "searxng/network_policy"
11
12
 
12
13
  FastMcp = MCP unless defined?(FastMcp)
13
14
 
@@ -176,52 +177,72 @@ module Searxng
176
177
  annotations(readOnlyHint: true, openWorldHint: true) if respond_to?(:annotations)
177
178
 
178
179
  CACHE_TTL_SECONDS = 300
180
+ CACHE_MAX_ENTRIES = 32
181
+ CACHE_MAX_BYTES = 8 * 1024 * 1024
182
+ MAX_FETCH_BYTES = 2 * 1024 * 1024
183
+ DEFAULT_MAX_LENGTH = 20_000
184
+ MIN_TIMEOUT_MS = 1_000
185
+ MAX_TIMEOUT_MS = 30_000
186
+ DEFAULT_URL_SCHEMES = %w[http https ipfs].freeze
187
+ SUPPORTED_URL_SCHEMES = %w[http https ftp sftp smb gemini ipfs].freeze
179
188
  @@url_cache = {} # rubocop:disable Style/ClassVars
189
+ @@url_cache_bytes = 0 # rubocop:disable Style/ClassVars
190
+ @@url_cache_mutex = Mutex.new # rubocop:disable Style/ClassVars
180
191
 
181
192
  arguments do
182
193
  required(:url).filled(:string).description("URL to fetch and read")
183
194
  optional(:startChar).filled(:integer).description("Starting character position (default: 0)")
184
- optional(:maxLength).filled(:integer).description("Maximum characters to return")
195
+ optional(:maxLength).filled(:integer).description("Maximum characters to return (default: 20000)")
185
196
  optional(:section).filled(:string).description("Extract by heading text")
186
197
  optional(:paragraphRange).filled(:string).description("Paragraph range, e.g. '1-5', '3', '10-'")
187
198
  optional(:readHeadings).filled(:bool).description("Return only headings when true")
188
- optional(:timeoutMs).filled(:integer).description("Request timeout in ms (default: 10000)")
199
+ optional(:timeoutMs).filled(:integer).description("Request timeout in ms (default: 10000, clamped 1000..30000)")
189
200
  end
190
201
 
191
202
  def call(url:, **kwargs)
192
203
  start_char = kwargs.fetch(:startChar, 0)
193
- max_length = kwargs[:maxLength]
204
+ max_length = kwargs.fetch(:maxLength, DEFAULT_MAX_LENGTH)
194
205
  section = kwargs[:section]
195
206
  paragraph_range = kwargs[:paragraphRange]
196
207
  read_headings = kwargs.fetch(:readHeadings, false)
197
- timeout_ms = kwargs.fetch(:timeoutMs, 10_000)
208
+ timeout_ms = clamp_timeout_ms(kwargs.fetch(:timeoutMs, 10_000))
198
209
 
199
210
  normalized = normalize_url(url)
200
211
  uri = URI.parse(normalized)
212
+ ensure_scheme_enabled!(uri.scheme)
213
+ unless uri.scheme == "ipfs"
214
+ _validated_uri, ip_address = NetworkPolicy.validate!(
215
+ normalized,
216
+ allowed_schemes: [uri.scheme],
217
+ allow_userinfo: !%w[http https].include?(uri.scheme)
218
+ )
219
+ end
201
220
  markdown = case uri.scheme
202
221
  when "http", "https"
203
- html = fetch_html(normalized, timeout_ms: timeout_ms.to_i)
204
- convert_to_markdown(html, normalized)
222
+ html = fetch_html(normalized, timeout_ms: timeout_ms)
223
+ convert_to_markdown(html, redact_uri_string(normalized))
205
224
  when "ftp"
206
- content = fetch_ftp(uri, timeout_ms: timeout_ms.to_i)
207
- convert_fetched_content(content, normalized)
225
+ content = fetch_ftp(uri, ip_address: ip_address, timeout_ms: timeout_ms)
226
+ convert_fetched_content(content, redact_uri_string(normalized))
208
227
  when "sftp"
209
- content = fetch_sftp(uri, timeout_ms: timeout_ms.to_i)
210
- convert_fetched_content(content, normalized)
228
+ content = fetch_sftp(uri, ip_address: ip_address, timeout_ms: timeout_ms)
229
+ convert_fetched_content(content, redact_uri_string(normalized))
211
230
  when "smb"
212
- content = fetch_smb(uri, timeout_ms: timeout_ms.to_i)
213
- convert_fetched_content(content, normalized)
231
+ content = fetch_smb(uri, ip_address: ip_address, timeout_ms: timeout_ms)
232
+ convert_fetched_content(content, redact_uri_string(normalized))
214
233
  when "gemini"
215
- gemtext = fetch_gemini(uri, timeout_ms: timeout_ms.to_i)
234
+ gemtext = fetch_gemini(uri, ip_address: ip_address, timeout_ms: timeout_ms)
216
235
  gemtext_to_markdown(gemtext, uri)
217
236
  when "ipfs"
218
237
  gateway_url = resolve_ipfs_url(uri)
219
- html = fetch_html(gateway_url, timeout_ms: timeout_ms.to_i)
238
+ html = fetch_html(gateway_url, timeout_ms: timeout_ms)
220
239
  convert_to_markdown(html, gateway_url)
221
240
  else
222
241
  raise ConfigurationError, %(Unsupported URL scheme "#{uri.scheme}". Supported schemes: http, https, ftp, sftp, smb, gemini, ipfs.)
223
242
  end
224
243
  apply_options(markdown, start_char: start_char, max_length: max_length, section: section, paragraph_range: paragraph_range, read_headings: read_headings)
244
+ rescue Searxng::Error => e
245
+ "Error: #{e.message}"
225
246
  end
226
247
 
227
248
  private
@@ -245,35 +266,70 @@ module Searxng
245
266
  raise ConfigurationError, %(URL Format Error: Invalid URL "#{url}")
246
267
  end
247
268
 
269
+ def ensure_scheme_enabled!(scheme)
270
+ return if enabled_url_schemes.include?(scheme)
271
+
272
+ raise ConfigurationError, %(URL scheme "#{scheme}" is not enabled)
273
+ end
274
+
275
+ def enabled_url_schemes
276
+ configured = ENV["SEARXNG_WEB_URL_SCHEMES"]
277
+ return DEFAULT_URL_SCHEMES unless configured
278
+
279
+ configured.split(",").map { |scheme| scheme.strip.downcase } & SUPPORTED_URL_SCHEMES
280
+ end
281
+
282
+ def clamp_timeout_ms(value)
283
+ value.to_i.clamp(MIN_TIMEOUT_MS, MAX_TIMEOUT_MS)
284
+ end
285
+
286
+ def redact_uri_string(url)
287
+ uri = url.is_a?(URI) ? url.dup : URI.parse(url.to_s)
288
+ uri.user = nil
289
+ uri.password = nil
290
+ uri.to_s
291
+ rescue URI::InvalidURIError
292
+ "(invalid URL)"
293
+ end
294
+
248
295
  def fetch_html(url, timeout_ms:)
249
296
  now = Time.now.to_i
250
- cached = @@url_cache[url]
251
- if cached && (now - cached[:at] <= CACHE_TTL_SECONDS)
252
- return cached[:html]
253
- end
297
+ cached = cache_get(url, now: now)
298
+ return cached if cached
254
299
 
255
- uri = URI.parse(url)
300
+ uri, ip_address = NetworkPolicy.validate!(url, allowed_schemes: %w[http https])
256
301
  client = get_client
257
- http = client.send(:build_http, uri)
258
- request = Net::HTTP::Get.new(uri)
259
- request["User-Agent"] = "searxng-ruby/#{Searxng::VERSION} web_url_read"
260
- response = nil
261
- Timeout.timeout(timeout_ms / 1000.0) { response = http.request(request) }
262
- unless response.is_a?(Net::HTTPSuccess)
263
- raise APIError.new(
264
- ErrorMessages.api_error(response.code.to_i, response.message),
265
- status_code: response.code.to_i,
266
- response_data: response.body,
267
- uri: url
268
- )
302
+ http = build_fetch_http(client, uri, ip_address, timeout_ms: timeout_ms)
303
+ request = Net::HTTP::Get.new(uri.request_uri)
304
+ request["Host"] = uri.hostname || uri.host
305
+ request["User-Agent"] = ENV["SEARXNG_USER_AGENT"].to_s.strip.empty? ? "searxng-ruby/#{Searxng::VERSION} web_url_read" : ENV["SEARXNG_USER_AGENT"]
306
+ request["Accept-Encoding"] = "identity"
307
+ safe_url = redact_uri_string(url)
308
+ body = nil
309
+ status_code = nil
310
+ status_message = nil
311
+ location = nil
312
+ Timeout.timeout(timeout_ms / 1000.0) do
313
+ http.request(request) do |response|
314
+ status_code = response.code.to_i
315
+ status_message = response.message
316
+ location = response["location"]
317
+ unless response.is_a?(Net::HTTPSuccess)
318
+ read_limited_body(response)
319
+ raise APIError.new(
320
+ ErrorMessages.fetch_http_error(status_code, status_message, location: location),
321
+ status_code: status_code,
322
+ uri: safe_url
323
+ )
324
+ end
325
+ body = read_limited_body(response)
326
+ end
269
327
  end
270
-
271
- body = response.body.to_s
272
- if body.strip.empty?
273
- raise APIError.new("Content Error: Website returned empty content.", status_code: response.code.to_i, uri: url)
328
+ if body.to_s.strip.empty?
329
+ raise APIError.new("Content Error: Website returned empty content.", status_code: status_code, uri: safe_url)
274
330
  end
275
331
 
276
- @@url_cache[url] = {at: now, html: body}
332
+ cache_put(url, body, at: now)
277
333
  body
278
334
  rescue Timeout::Error
279
335
  raise NetworkError, "Timeout Error: #{URI.parse(url).host} took longer than #{timeout_ms}ms to respond"
@@ -281,6 +337,76 @@ module Searxng
281
337
  raise NetworkError, ErrorMessages.network_error(e, uri: URI.parse(url), target: "website")
282
338
  end
283
339
 
340
+ def build_fetch_http(client, uri, ip_address, timeout_ms:)
341
+ proxy_uri = client.send(:proxy_uri_for, uri)
342
+ seconds = [timeout_ms / 1000.0, 1.0].max
343
+ if proxy_uri && uri.scheme == "http"
344
+ http = Net::HTTP::Proxy(proxy_uri.host, proxy_uri.port, proxy_uri.user, proxy_uri.password)
345
+ .new(ip_address, uri.port)
346
+ http.open_timeout = seconds
347
+ http.read_timeout = seconds
348
+ http.max_retries = 0 if http.respond_to?(:max_retries=)
349
+ return http
350
+ end
351
+
352
+ http = client.send(:build_http, uri)
353
+ http.ipaddr = ip_address
354
+ http.open_timeout = seconds
355
+ http.read_timeout = seconds
356
+ http.max_retries = 0 if http.respond_to?(:max_retries=)
357
+ http
358
+ end
359
+
360
+ def read_limited_body(response)
361
+ body = +""
362
+ response.read_body do |chunk|
363
+ body << chunk
364
+ if body.bytesize > MAX_FETCH_BYTES
365
+ raise APIError.new("Content Error: response exceeds #{MAX_FETCH_BYTES} bytes", uri: nil)
366
+ end
367
+ end
368
+ body
369
+ end
370
+
371
+ def cache_get(url, now:)
372
+ @@url_cache_mutex.synchronize do
373
+ purge_expired_cache!(now)
374
+ entry = @@url_cache[url]
375
+ (entry && (now - entry[:at] <= CACHE_TTL_SECONDS)) ? entry[:html] : nil
376
+ end
377
+ end
378
+
379
+ def cache_put(url, html, at:)
380
+ @@url_cache_mutex.synchronize do
381
+ purge_expired_cache!(at)
382
+ while (@@url_cache.size >= CACHE_MAX_ENTRIES || (@@url_cache_bytes + html.bytesize) > CACHE_MAX_BYTES) && !@@url_cache.empty?
383
+ evict_oldest_cache_entry!
384
+ end
385
+ return if html.bytesize > CACHE_MAX_BYTES
386
+
387
+ previous = @@url_cache[url]
388
+ @@url_cache_bytes -= previous[:html].bytesize if previous
389
+ @@url_cache[url] = {at: at, html: html}
390
+ @@url_cache_bytes += html.bytesize
391
+ end
392
+ end
393
+
394
+ def purge_expired_cache!(now)
395
+ @@url_cache.delete_if do |_key, entry|
396
+ expired = now - entry[:at] > CACHE_TTL_SECONDS
397
+ @@url_cache_bytes -= entry[:html].bytesize if expired
398
+ expired
399
+ end
400
+ end
401
+
402
+ def evict_oldest_cache_entry!
403
+ oldest_key, oldest = @@url_cache.min_by { |_key, entry| entry[:at] }
404
+ return unless oldest_key
405
+
406
+ @@url_cache_bytes -= oldest[:html].bytesize
407
+ @@url_cache.delete(oldest_key)
408
+ end
409
+
284
410
  def convert_to_markdown(html, base_url)
285
411
  load_teplo_core!
286
412
  ast = TeploCore.parse_html(html)
@@ -290,6 +416,8 @@ module Searxng
290
416
  else
291
417
  markdown
292
418
  end
419
+ rescue Searxng::Error
420
+ raise
293
421
  rescue => e
294
422
  raise APIError.new("Conversion Error: Cannot convert HTML to Markdown (#{base_url}) - #{e.message}", uri: base_url)
295
423
  end
@@ -302,29 +430,40 @@ module Searxng
302
430
  looks_like_markup ? convert_to_markdown(text, base_url) : text
303
431
  end
304
432
 
305
- def fetch_ftp(uri, timeout_ms:)
433
+ def fetch_ftp(uri, timeout_ms:, ip_address: nil)
306
434
  begin
307
435
  require "net/ftp"
308
436
  rescue LoadError
309
437
  raise ConfigurationError, "FTP support requires the 'net-ftp' gem."
310
438
  end
311
439
 
440
+ ip_address ||= validated_ip_address(uri, allow_userinfo: true)
312
441
  body = +""
442
+ ftp = nil
443
+ safe_uri = redact_uri_string(uri)
313
444
  Timeout.timeout(timeout_ms / 1000.0) do
314
445
  ftp = Net::FTP.new
446
+ ftp.passive = true
447
+ ftp.use_pasv_ip = false if ftp.respond_to?(:use_pasv_ip=)
315
448
  begin
316
- ftp.connect(uri.host, uri.port || 21)
449
+ ftp.connect(ip_address, uri.port || 21)
317
450
  ftp.read_timeout = [timeout_ms / 1000.0, 1.0].max
318
451
  ftp.open_timeout = [timeout_ms / 1000.0, 1.0].max
319
- user = uri.user || ENV["FTP_USER"] || "anonymous"
320
- pass = uri.password || ENV["FTP_PASSWORD"] || "anonymous@"
452
+ user = uri.user || credential_for(uri.host, "FTP_USER") || "anonymous"
453
+ pass = uri.password || credential_for(uri.host, "FTP_PASSWORD") || "anonymous@"
321
454
  ftp.login(user, pass)
322
- path = uri.path.to_s
323
- raise APIError.new("FTP path is required", uri: uri.to_s) if path.empty? || path == "/"
324
-
325
- ftp.retrbinary("RETR #{path}", 16_384) { |chunk| body << chunk }
455
+ path = URI.decode_www_form_component(uri.path.to_s)
456
+ raise APIError.new("FTP path is required", uri: safe_uri) if path.empty? || path == "/"
457
+ raise ConfigurationError, "FTP path contains unsupported characters" if path.match?(/[\r\n\x00]/)
458
+
459
+ ftp.retrbinary("RETR #{path}", 16_384) do |chunk|
460
+ body << chunk
461
+ if body.bytesize > MAX_FETCH_BYTES
462
+ raise APIError.new("Content Error: response exceeds #{MAX_FETCH_BYTES} bytes", uri: safe_uri)
463
+ end
464
+ end
326
465
  ensure
327
- ftp.close unless ftp.closed?
466
+ ftp&.close unless ftp.nil? || ftp.closed?
328
467
  end
329
468
  end
330
469
  body
@@ -332,65 +471,78 @@ module Searxng
332
471
  raise NetworkError, "Timeout Error: #{uri.host} took longer than #{timeout_ms}ms to respond"
333
472
  rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE, EOFError => e
334
473
  raise NetworkError, ErrorMessages.network_error(e, uri: uri, target: "FTP server")
474
+ rescue Searxng::Error
475
+ raise
335
476
  rescue => e
336
477
  if e.class.name.start_with?("Net::FTP")
337
- raise APIError.new("FTP Error: #{e.message}", uri: uri.to_s)
478
+ raise APIError.new("FTP Error: #{e.message}", uri: redact_uri_string(uri))
338
479
  end
339
480
  raise e
340
481
  end
341
482
 
342
- def fetch_sftp(uri, timeout_ms:)
483
+ def fetch_sftp(uri, timeout_ms:, ip_address: nil)
343
484
  begin
344
485
  require "net/sftp"
345
486
  rescue LoadError
346
487
  raise ConfigurationError, "SFTP support requires the 'net-sftp' gem."
347
488
  end
348
489
 
349
- user = uri.user || ENV["SFTP_USER"]
350
- password = uri.password || ENV["SFTP_PASSWORD"]
490
+ ip_address ||= validated_ip_address(uri, allow_userinfo: true)
491
+ user = uri.user || credential_for(uri.host, "SFTP_USER")
492
+ password = uri.password || credential_for(uri.host, "SFTP_PASSWORD")
351
493
  raise ConfigurationError, "SFTP requires username (sftp://user@host/path or SFTP_USER)." unless user
352
494
 
353
- path = uri.path.to_s
354
- raise APIError.new("SFTP path is required", uri: uri.to_s) if path.empty? || path == "/"
495
+ path = URI.decode_www_form_component(uri.path.to_s)
496
+ safe_uri = redact_uri_string(uri)
497
+ raise APIError.new("SFTP path is required", uri: safe_uri) if path.empty? || path == "/"
498
+ raise ConfigurationError, "SFTP path contains unsupported characters" if path.match?(/[\r\n\x00]/)
355
499
 
356
500
  result = nil
357
501
  Timeout.timeout(timeout_ms / 1000.0) do
358
- Net::SFTP.start(uri.host, user, password: password, port: uri.port || 22, non_interactive: true, verify_host_key: :never) do |sftp|
359
- result = sftp.download!(path)
502
+ options = {password: password, port: uri.port || 22, non_interactive: true, verify_host_key: :always}
503
+ options[:host_key_alias] = uri.host if uri.host
504
+ Net::SFTP.start(ip_address, user, **options) do |sftp|
505
+ result = sftp.download!(path).to_s
360
506
  end
361
507
  end
508
+ if result.to_s.bytesize > MAX_FETCH_BYTES
509
+ raise APIError.new("Content Error: response exceeds #{MAX_FETCH_BYTES} bytes", uri: safe_uri)
510
+ end
362
511
  result.to_s
363
512
  rescue Timeout::Error
364
513
  raise NetworkError, "Timeout Error: #{uri.host} took longer than #{timeout_ms}ms to respond"
365
514
  rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE, EOFError => e
366
515
  raise NetworkError, ErrorMessages.network_error(e, uri: uri, target: "SFTP server")
516
+ rescue Searxng::Error
517
+ raise
367
518
  rescue => e
368
519
  raise e if e.is_a?(ConfigurationError)
369
520
  if e.class.name.include?("StatusException")
370
521
  description = e.respond_to?(:description) ? e.description : e.message
371
- raise APIError.new("SFTP Error: #{description}", uri: uri.to_s)
522
+ raise APIError.new("SFTP Error: #{description}", uri: safe_uri)
372
523
  end
373
524
  if e.class.name.include?("AuthenticationFailed")
374
- raise APIError.new("SFTP authentication failed", uri: uri.to_s)
525
+ raise APIError.new("SFTP authentication failed", uri: safe_uri)
375
526
  end
376
- raise APIError.new("SFTP Error: #{e.message}", uri: uri.to_s)
527
+ raise APIError.new("SFTP Error: #{e.message}", uri: safe_uri)
377
528
  end
378
529
 
379
- def fetch_smb(uri, timeout_ms:)
530
+ def fetch_smb(uri, timeout_ms:, ip_address: nil)
531
+ ip_address ||= validated_ip_address(uri, allow_userinfo: true)
380
532
  share, remote_path = parse_smb_path(uri)
381
- user = uri.user || ENV["SMB_USER"]
382
- pass = uri.password || ENV["SMB_PASSWORD"]
383
- domain = ENV["SMB_DOMAIN"]
533
+ user = uri.user || credential_for(uri.host, "SMB_USER")
534
+ pass = uri.password || credential_for(uri.host, "SMB_PASSWORD")
535
+ domain = credential_for(uri.host, "SMB_DOMAIN")
384
536
 
385
- auth = if user
386
- full_user = (domain && !domain.empty?) ? "#{domain}\\#{user}" : user
387
- "#{full_user}%#{pass}"
537
+ full_user = if user
538
+ (domain && !domain.empty?) ? "#{domain}\\#{user}" : user
388
539
  end
389
540
 
390
- escaped_remote = remote_path.gsub('"', '\"')
391
- cmd = ["smbclient", "//#{uri.host}/#{share}", "-c", %(get "#{escaped_remote}" -)]
392
- if auth
393
- cmd << "-U" << auth
541
+ cmd = ["smbclient", "//#{ip_address}/#{share}", "-c", %(get "#{remote_path}" -), "-t", [(timeout_ms / 1000.0).ceil, 1].max.to_s]
542
+ env = {}
543
+ if full_user
544
+ cmd << "-U" << full_user
545
+ env["PASSWD"] = pass.to_s
394
546
  else
395
547
  cmd << "-N"
396
548
  end
@@ -398,52 +550,86 @@ module Searxng
398
550
  stdout = +""
399
551
  stderr = +""
400
552
  status = nil
553
+ wait_thr = nil
401
554
  Timeout.timeout(timeout_ms / 1000.0) do
402
- stdout, stderr, status = Open3.capture3(*cmd)
555
+ Open3.popen3(env, *cmd) do |stdin, out, err, thr|
556
+ stdin.close
557
+ wait_thr = thr
558
+ stdout = out.read.to_s
559
+ stderr = err.read.to_s
560
+ status = thr.value
561
+ end
562
+ end
563
+ if stdout.bytesize > MAX_FETCH_BYTES
564
+ raise APIError.new("Content Error: response exceeds #{MAX_FETCH_BYTES} bytes", uri: redact_uri_string(uri))
403
565
  end
404
566
 
405
- raise APIError.new("SMB Error: #{stderr.strip.empty? ? "request failed" : stderr.strip}", uri: uri.to_s) unless status.success?
567
+ raise APIError.new("SMB Error: #{stderr.strip.empty? ? "request failed" : stderr.strip[0, 500]}", uri: redact_uri_string(uri)) unless status&.success?
406
568
 
407
569
  stdout
408
570
  rescue Errno::ENOENT
409
571
  raise ConfigurationError, "SMB support requires 'smbclient' to be installed and available in PATH."
410
572
  rescue Timeout::Error
573
+ if wait_thr&.alive?
574
+ begin
575
+ Process.kill("TERM", wait_thr.pid)
576
+ rescue Errno::ESRCH
577
+ end
578
+ end
411
579
  raise NetworkError, "Timeout Error: #{uri.host} took longer than #{timeout_ms}ms to respond"
412
580
  rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE, EOFError => e
413
581
  raise NetworkError, ErrorMessages.network_error(e, uri: uri, target: "SMB server")
414
582
  end
415
583
 
416
584
  def parse_smb_path(uri)
417
- segments = uri.path.to_s.split("/").reject(&:empty?)
585
+ raw_path = uri.path.to_s
586
+ segments = raw_path.split("/").reject(&:empty?).map { |segment| URI.decode_www_form_component(segment) }
418
587
  share = segments.shift
419
588
  remote = segments.join("/")
420
- raise APIError.new("SMB path must include share and file (smb://host/share/path/file)", uri: uri.to_s) if share.to_s.empty? || remote.empty?
589
+ raise APIError.new("SMB path must include share and file (smb://host/share/path/file)", uri: redact_uri_string(uri)) if share.to_s.empty? || remote.empty?
590
+ if share.match?(/[\x00-\x1f\x7f;"\\]/) || remote.match?(/[\x00-\x1f\x7f;"\\]/)
591
+ raise ConfigurationError, "SMB path contains unsupported characters"
592
+ end
421
593
 
422
594
  [share, remote]
423
595
  end
424
596
 
425
- def fetch_gemini(uri, timeout_ms:)
597
+ def fetch_gemini(uri, timeout_ms:, ip_address: nil)
598
+ ip_address ||= validated_ip_address(uri)
426
599
  response_header = nil
427
600
  body = +""
601
+ tcp = nil
602
+ ssl = nil
603
+ safe_uri = redact_uri_string(uri)
428
604
  Timeout.timeout(timeout_ms / 1000.0) do
429
- tcp = TCPSocket.new(uri.host, uri.port || 1965)
605
+ tcp = TCPSocket.new(ip_address, uri.port || 1965)
430
606
  begin
431
607
  ctx = OpenSSL::SSL::SSLContext.new
432
- ctx.verify_mode = (ENV["GEMINI_INSECURE"] == "1") ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
608
+ if ENV["GEMINI_INSECURE"] == "1"
609
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
610
+ else
611
+ ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
612
+ end
433
613
  ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
434
614
  ssl.hostname = uri.host if ssl.respond_to?(:hostname=)
435
615
  ssl.connect
616
+ ssl.post_connection_check(uri.host) unless ENV["GEMINI_INSECURE"] == "1"
436
617
  ssl.write("#{uri}\r\n")
437
- response_header = ssl.gets("\r\n")
438
- body = ssl.read.to_s
439
- ssl.close
618
+ response_header = ssl.gets("\r\n", 2048)
619
+ while body.bytesize <= MAX_FETCH_BYTES
620
+ chunk = ssl.read(16_384)
621
+ break if chunk.nil? || chunk.empty?
622
+ body << chunk
623
+ end
624
+ raise APIError.new("Content Error: response exceeds #{MAX_FETCH_BYTES} bytes", uri: safe_uri) if body.bytesize > MAX_FETCH_BYTES
440
625
  ensure
441
- tcp.close unless tcp.closed?
626
+ ssl&.close
627
+ tcp&.close unless tcp.nil? || tcp.closed?
442
628
  end
443
629
  end
444
630
 
445
631
  unless response_header
446
- raise APIError.new("Gemini response error: empty response header", uri: uri.to_s)
632
+ raise APIError.new("Gemini response error: empty response header", uri: safe_uri)
447
633
  end
448
634
 
449
635
  status, meta = response_header.strip.split(/\s+/, 2)
@@ -452,15 +638,15 @@ module Searxng
452
638
  when 20..29
453
639
  body
454
640
  when 30..39
455
- raise APIError.new("Gemini redirect (#{status_code}): #{meta}", status_code: status_code, uri: uri.to_s)
641
+ raise APIError.new("Gemini redirect (#{status_code}): #{meta}", status_code: status_code, uri: safe_uri)
456
642
  when 40..49
457
- raise APIError.new("Gemini temporary failure (#{status_code}): #{meta}", status_code: status_code, uri: uri.to_s)
643
+ raise APIError.new("Gemini temporary failure (#{status_code}): #{meta}", status_code: status_code, uri: safe_uri)
458
644
  when 50..59
459
- raise APIError.new("Gemini permanent failure (#{status_code}): #{meta}", status_code: status_code, uri: uri.to_s)
645
+ raise APIError.new("Gemini permanent failure (#{status_code}): #{meta}", status_code: status_code, uri: safe_uri)
460
646
  when 60..69
461
- raise APIError.new("Gemini certificate required (#{status_code}): #{meta}", status_code: status_code, uri: uri.to_s)
647
+ raise APIError.new("Gemini certificate required (#{status_code}): #{meta}", status_code: status_code, uri: safe_uri)
462
648
  else
463
- raise APIError.new("Gemini response error (#{status_code}): #{meta}", status_code: status_code, uri: uri.to_s)
649
+ raise APIError.new("Gemini response error (#{status_code}): #{meta}", status_code: status_code, uri: safe_uri)
464
650
  end
465
651
  rescue Timeout::Error
466
652
  raise NetworkError, "Timeout Error: #{uri.host} took longer than #{timeout_ms}ms to respond"
@@ -498,7 +684,7 @@ module Searxng
498
684
  return target if target_uri.scheme
499
685
 
500
686
  URI.join(base_uri.to_s, target).to_s
501
- rescue URI::InvalidURIError
687
+ rescue URI::Error
502
688
  target
503
689
  end
504
690
 
@@ -511,13 +697,40 @@ module Searxng
511
697
  end
512
698
 
513
699
  cid = uri.host.to_s
700
+ unless cid.match?(/\A[a-zA-Z0-9_-]+\z/)
701
+ raise ConfigurationError, "IPFS CID is invalid"
702
+ end
703
+
514
704
  path = uri.path.to_s
705
+ segments = path.split("/").reject(&:empty?)
706
+ segments.each do |segment|
707
+ decoded = URI.decode_www_form_component(segment)
708
+ if decoded == "." || decoded == ".." || decoded.include?("\0")
709
+ raise ConfigurationError, "IPFS path is not allowed"
710
+ end
711
+ end
712
+
515
713
  query = uri.query ? "?#{uri.query}" : ""
516
714
  "#{gateway}/ipfs/#{cid}#{path}#{query}"
517
715
  rescue URI::InvalidURIError
518
716
  raise ConfigurationError, "IPFS_GATEWAY has invalid format: #{gateway.inspect}"
519
717
  end
520
718
 
719
+ def credential_for(host, variable)
720
+ allowed_hosts = ENV["SEARXNG_WEB_URL_CREDENTIAL_HOSTS"].to_s
721
+ .split(",")
722
+ .map { |candidate| candidate.strip.downcase }
723
+ ENV[variable] if allowed_hosts.include?(host.downcase)
724
+ end
725
+
726
+ def validated_ip_address(uri, allow_userinfo: false)
727
+ NetworkPolicy.validate!(
728
+ uri.to_s,
729
+ allowed_schemes: [uri.scheme],
730
+ allow_userinfo: allow_userinfo
731
+ )[1]
732
+ end
733
+
521
734
  def load_teplo_core!
522
735
  return if defined?(TeploCore)
523
736
 
@@ -527,7 +740,7 @@ module Searxng
527
740
  if File.exist?("#{local}.rb")
528
741
  require local
529
742
  else
530
- raise ConfigurationError, "web_url_read requires teplo_core. Install the gem or provide textplorer/core-ruby in the expected workspace location."
743
+ raise ConfigurationError, "web_url_read requires the teplo_core gem for HTML to Markdown conversion."
531
744
  end
532
745
  end
533
746
 
@@ -537,12 +750,16 @@ module Searxng
537
750
 
538
751
  if section && !section.strip.empty?
539
752
  section_text = extract_section(result, section)
540
- result = section_text.empty? ? %(Section "#{section}" not found in the content.) : section_text
753
+ return %(Section "#{section}" not found in the content.) if section_text.empty?
754
+
755
+ result = section_text
541
756
  end
542
757
 
543
758
  if paragraph_range && !paragraph_range.strip.empty?
544
759
  paragraph_text = extract_paragraph_range(result, paragraph_range)
545
- result = paragraph_text.empty? ? %(Paragraph range "#{paragraph_range}" is invalid or out of bounds.) : paragraph_text
760
+ return %(Paragraph range "#{paragraph_range}" is invalid or out of bounds.) if paragraph_text.empty?
761
+
762
+ result = paragraph_text
546
763
  end
547
764
 
548
765
  start = [start_char.to_i, 0].max
@@ -624,7 +841,7 @@ module Searxng
624
841
  environment: {
625
842
  searxng_url: safe_url(searxng_url),
626
843
  has_auth: !!(user && password),
627
- has_proxy: !!(ENV["HTTP_PROXY"] || ENV["HTTPS_PROXY"] || ENV["http_proxy"] || ENV["https_proxy"]),
844
+ has_proxy: !!(ENV["HTTP_PROXY"] || ENV["HTTPS_PROXY"] || ENV["http_proxy"] || ENV["https_proxy"] || ENV["ALL_PROXY"] || ENV["all_proxy"]),
628
845
  has_no_proxy: !!(ENV["NO_PROXY"] || ENV["no_proxy"])
629
846
  },
630
847
  capabilities: {
@@ -672,21 +889,23 @@ module Searxng
672
889
  - `HTTP_PROXY` / `HTTPS_PROXY`
673
890
  - `ALL_PROXY` (for shared proxy config)
674
891
  - `NO_PROXY`
675
- - `FTP_USER` / `FTP_PASSWORD` (optional FTP credentials)
676
- - `SFTP_USER` / `SFTP_PASSWORD` (optional SFTP credentials)
677
- - `SMB_USER` / `SMB_PASSWORD` / `SMB_DOMAIN` (optional SMB credentials)
892
+ - `SEARXNG_WEB_URL_SCHEMES` (comma-separated opt-in protocols; default: `http,https,ipfs`)
893
+ - `SEARXNG_WEB_URL_CREDENTIAL_HOSTS` (comma-separated hosts allowed to receive environment credentials)
894
+ - `FTP_USER` / `FTP_PASSWORD` (optional credentials for allowlisted hosts)
895
+ - `SFTP_USER` / `SFTP_PASSWORD` (optional credentials for allowlisted hosts)
896
+ - `SMB_USER` / `SMB_PASSWORD` / `SMB_DOMAIN` (optional credentials for allowlisted hosts)
678
897
  - `IPFS_GATEWAY` (default: `https://ipfs.io`)
679
898
  - `GEMINI_INSECURE=1` (optional, disables TLS verification for Gemini)
680
899
 
681
900
  ## Protocol Notes
682
901
  - `http` / `https`: fully supported (`searxng_web_search` + `web_url_read`)
683
- - `ftp`: supported in `web_url_read` (file retrieval via FTP)
684
- - `sftp`: supported in `web_url_read` (requires `net-sftp` gem)
685
- - `smb`: supported in `web_url_read` via `smbclient` command
686
- - `gemini`: supported in `web_url_read` (Gemini fetch + Gemtext conversion)
902
+ - `ftp`: opt-in through `SEARXNG_WEB_URL_SCHEMES` (file retrieval via FTP)
903
+ - `sftp`: opt-in through `SEARXNG_WEB_URL_SCHEMES` (requires `net-sftp` gem)
904
+ - `smb`: opt-in through `SEARXNG_WEB_URL_SCHEMES` via `smbclient`
905
+ - `gemini`: opt-in through `SEARXNG_WEB_URL_SCHEMES` (Gemini fetch + Gemtext conversion)
687
906
  - `ipfs`: supported via HTTP gateway mapping (`ipfs://...` -> `IPFS_GATEWAY`)
688
- - `tor` / `i2p`: supported via `.onion` / `.i2p` hosts over HTTP(S) with proxy configuration
689
- - `socks5`: configure via proxy environment and run through a local bridge/proxy compatible with your client setup
907
+ - `.onion` / `.i2p`: not supported by `web_url_read` (local DNS and destination policy run before any proxy)
908
+ - `socks5`: may work for SearXNG search via proxy env; not a bypass for `web_url_read` destination policy
690
909
 
691
910
  ## Examples
692
911
  - Search: `{"query":"latest ruby news","time_range":"day"}`
@@ -1,3 +1,3 @@
1
1
  module Searxng
2
- VERSION = "0.2.0"
2
+ VERSION = "0.2.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: searxng
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -71,6 +71,20 @@ dependencies:
71
71
  - - "~>"
72
72
  - !ruby/object:Gem::Version
73
73
  version: '3.13'
74
+ - !ruby/object:Gem::Dependency
75
+ name: polyrun
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: 2.2.0
81
+ type: :development
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 2.2.0
74
88
  - !ruby/object:Gem::Dependency
75
89
  name: webmock
76
90
  requirement: !ruby/object:Gem::Requirement
@@ -297,6 +311,7 @@ files:
297
311
  - lib/searxng.rb
298
312
  - lib/searxng/client.rb
299
313
  - lib/searxng/errors.rb
314
+ - lib/searxng/network_policy.rb
300
315
  - lib/searxng/server.rb
301
316
  - lib/searxng/version.rb
302
317
  - sig/searxng.rbs
@@ -316,14 +331,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
316
331
  requirements:
317
332
  - - ">="
318
333
  - !ruby/object:Gem::Version
319
- version: '3.1'
334
+ version: '3.4'
320
335
  required_rubygems_version: !ruby/object:Gem::Requirement
321
336
  requirements:
322
337
  - - ">="
323
338
  - !ruby/object:Gem::Version
324
339
  version: '0'
325
340
  requirements: []
326
- rubygems_version: 4.0.3
341
+ rubygems_version: 4.0.6
327
342
  specification_version: 4
328
343
  summary: SearXNG Ruby client and MCP server
329
344
  test_files: []