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 +4 -4
- data/CHANGELOG.md +20 -1
- data/README.md +26 -11
- data/bin/searxng +7 -4
- data/lib/searxng/client.rb +28 -7
- data/lib/searxng/errors.rb +8 -0
- data/lib/searxng/network_policy.rb +74 -0
- data/lib/searxng/server.rb +317 -98
- data/lib/searxng/version.rb +1 -1
- metadata +18 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0752ce85481fa3a6a5846b4326effa0251ff69e77a32912dc046f06e35542832
|
|
4
|
+
data.tar.gz: 1700a1ad55569d5cbb7137c6b3c6cde6583c8b6198508975e6b66030dfd2bc39
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e85f85f3363f0468cef0d8dd6b5318f2d2eba8ef7268e6c607fbbe318a5140347e395400b747c33716f4ee73a2fa99e6af54398e7e20e42dbfb2e1a76f60593a
|
|
7
|
+
data.tar.gz: ff609005b3e6993106c5c29855801675e48b25221456d7f4561f43390a31af1002d98a6abc718d407cd3c87bcede1ff8bed6b587cf166c6fdd5dddfb699aa3a8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
|
-
#
|
|
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
|
-
[](https://badge.fury.io/rb/searxng) [](https://github.com/amkisko/searxng.rb/actions/workflows/test.yml) [](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.
|
|
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
|
-
- **
|
|
55
|
-
- **
|
|
56
|
-
- **
|
|
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`,
|
|
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`:
|
|
170
|
-
- `sftp`:
|
|
171
|
-
- `smb`:
|
|
172
|
-
- `gemini`:
|
|
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
|
-
- `
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
data/lib/searxng/client.rb
CHANGED
|
@@ -24,9 +24,13 @@ module Searxng
|
|
|
24
24
|
user_agent: nil,
|
|
25
25
|
configure_http: nil
|
|
26
26
|
)
|
|
27
|
-
@base_url =
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/searxng/errors.rb
CHANGED
|
@@ -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
|
data/lib/searxng/server.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
251
|
-
|
|
252
|
-
return cached[:html]
|
|
253
|
-
end
|
|
297
|
+
cached = cache_get(url, now: now)
|
|
298
|
+
return cached if cached
|
|
254
299
|
|
|
255
|
-
uri =
|
|
300
|
+
uri, ip_address = NetworkPolicy.validate!(url, allowed_schemes: %w[http https])
|
|
256
301
|
client = get_client
|
|
257
|
-
http = client
|
|
258
|
-
request = Net::HTTP::Get.new(uri)
|
|
259
|
-
request["
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 ||
|
|
320
|
-
pass = uri.password ||
|
|
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:
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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:
|
|
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:
|
|
525
|
+
raise APIError.new("SFTP authentication failed", uri: safe_uri)
|
|
375
526
|
end
|
|
376
|
-
raise APIError.new("SFTP Error: #{e.message}", uri:
|
|
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 ||
|
|
382
|
-
pass = uri.password ||
|
|
383
|
-
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
|
-
|
|
386
|
-
|
|
387
|
-
"#{full_user}%#{pass}"
|
|
537
|
+
full_user = if user
|
|
538
|
+
(domain && !domain.empty?) ? "#{domain}\\#{user}" : user
|
|
388
539
|
end
|
|
389
540
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
if
|
|
393
|
-
cmd << "-U" <<
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
605
|
+
tcp = TCPSocket.new(ip_address, uri.port || 1965)
|
|
430
606
|
begin
|
|
431
607
|
ctx = OpenSSL::SSL::SSLContext.new
|
|
432
|
-
|
|
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
|
|
439
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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::
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- `
|
|
676
|
-
- `
|
|
677
|
-
- `
|
|
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`:
|
|
684
|
-
- `sftp`:
|
|
685
|
-
- `smb`:
|
|
686
|
-
- `gemini`:
|
|
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
|
-
- `
|
|
689
|
-
- `socks5`:
|
|
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"}`
|
data/lib/searxng/version.rb
CHANGED
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.
|
|
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.
|
|
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.
|
|
341
|
+
rubygems_version: 4.0.6
|
|
327
342
|
specification_version: 4
|
|
328
343
|
summary: SearXNG Ruby client and MCP server
|
|
329
344
|
test_files: []
|