hostily-captcha 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8647978872b4be39152495b93f3d573eaf0a05fef1f56edb6c921339a90d8c3b
4
+ data.tar.gz: cc9099a1f4326fcff2cbfd6b1acfa4d37c5fe190ee4a46952d8c185ffeeb0b3c
5
+ SHA512:
6
+ metadata.gz: 171ff9e945d6fc22013145bddf1e3a542bb7c02fa2af3c1598cf7d6c84dca46bef8a950661caefb0882838ffe9f6b709daee206109115bdcc362eb616169b2ac
7
+ data.tar.gz: 8db216a736510f450334cd9afeb3f2773da9bd0a628e2799d48c57df23e0afe30d5b9edaa03dc31eba6ac1b84e0ec03cbfb642fedd1ef5e2499853781c5ef067
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 connect - Media & Teledienst (hostily)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # hostily-captcha (Ruby)
2
+
3
+ **Deutsch**
4
+
5
+ Kleiner Client für [hostily Captcha](https://captcha.hostily.de) mit Rails-Helfern. Ruby ≥ 3.1, nur `Net::HTTP` (keine fremden Gems).
6
+
7
+ ```ruby
8
+ gem "hostily-captcha"
9
+ ```
10
+
11
+ **Ohne Rails**
12
+
13
+ ```ruby
14
+ require "hostily/captcha"
15
+
16
+ captcha = Hostily::Captcha::Client.new(ENV.fetch("HOSTILY_CAPTCHA_SECRET")) # Secret nur auf dem Server!
17
+ begin
18
+ result = captcha.verify_form(params, remoteip: request.ip)
19
+ rescue Hostily::Captcha::UnavailableError
20
+ # Dienst nicht erreichbar: blockieren oder durchlassen - Ihre Entscheidung
21
+ end
22
+ puts result.gruende, result.punkte unless result.success?
23
+ ```
24
+
25
+ **Rails** (`config/initializers/hostily_captcha.rb`)
26
+
27
+ ```ruby
28
+ Hostily::Captcha.configure do |c|
29
+ c.site_key = "…" # öffentlich
30
+ c.secret = ENV["HOSTILY_CAPTCHA_SECRET"] # nur auf dem Server
31
+ # c.endpoint = "https://captcha.hostily.de" # Vorgabe
32
+ # c.modus = "sichtbar" | "unsichtbar"; c.sprache = "de" | "en" (Vorgabe: Seitensprache)
33
+ # c.fail_open = true # Dienst down: durchlassen (Vorgabe); false = blockieren
34
+ end
35
+ ```
36
+
37
+ ```erb
38
+ <%= form_with url: contact_path do |f| %>
39
+ …
40
+ <%= hostily_captcha_tag %> <%# Widget + Skript; Optionen: site_key:, modus:, sprache:, api:, script: false %>
41
+ <% end %>
42
+ ```
43
+
44
+ ```ruby
45
+ def create
46
+ return render(:new, status: :unprocessable_entity) unless verify_hostily_captcha
47
+ …
48
+ end
49
+ ```
50
+
51
+ `verify_hostily_captcha(fail_open: nil, send_content: true)` liefert `true` (weiter) oder `false`. Ohne Site-Key und Secret wird
52
+ nichts geprüft. Ist der Dienst nicht erreichbar oder das Secret falsch, gilt `fail_open` und die Meldung steht im Rails-Log (nie das
53
+ Secret). `hostily_captcha_result` gibt das letzte Ergebnis zurück. Hinter einem Proxy liefert `request.remote_ip` die echte Adresse.
54
+
55
+ * `token_from(form)` / `content(form)`: Letzteres entfernt Passwortfelder, Secrets, CSRF-Token, Kartencodes und die Token-Felder,
56
+ auch in verschachtelten Hashes (`ActionController::Parameters` wird unterstützt). Passwörter werden nie mitgeschickt.
57
+ * `Result`: `success?`, `gruende`, `punkte`, `error_codes`, `hostname`, `configuration_error?`.
58
+ * `timeout:` in Sekunden (Vorgabe 5). Ohne Token wird der Dienst nicht gefragt (`token_fehlt`). Ein Token gilt nur für eine Prüfung.
59
+ * Getestet mit Ruby 3.1 und 3.4; Rails-Helfer mit actionpack (aktuell). Tests: `rake test`.
60
+
61
+ **English**
62
+
63
+ Small client for [hostily Captcha](https://captcha.hostily.de) with Rails helpers. Ruby ≥ 3.1, `Net::HTTP` only (no third-party gems).
64
+
65
+ * Without Rails: `Hostily::Captcha::Client.new(secret).verify_form(params, remoteip: ip)`; `UnavailableError` means the service is
66
+ unreachable / timed out / HTTP 5xx or 429 / unreadable answer - you decide to block or let pass.
67
+ * Rails: configure in an initializer (see above), put `<%= hostily_captcha_tag %>` into the form, call `verify_hostily_captcha` in the
68
+ controller (`true` = go on, `false` = reject). Nothing is checked without site key and secret; on an outage or a wrong secret
69
+ `fail_open` decides (default: let pass) and a line goes to the Rails log (never the secret).
70
+ * `content(form)` drops password fields, secrets, CSRF tokens, card codes and the token fields (also nested). Passwords are never sent.
71
+ * `Result`: `success?`, `gruende`, `punkte`, `error_codes`, `hostname`, `configuration_error?`. Default timeout 5 s.
72
+ A token is valid for one check only. Tests: `rake test`.
73
+
74
+ MIT License.
@@ -0,0 +1,113 @@
1
+ require "json"
2
+ require "openssl"
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Hostily
7
+ module Captcha
8
+ DEFAULT_ENDPOINT = "https://captcha.hostily.de".freeze
9
+ TOKEN_FIELDS = %w[hostily-captcha-token g-recaptcha-response].freeze
10
+ SENSITIVE = /pass(word|wort|wd|phrase|code)|(^|[_\-\[])pass($|[_\-\]])|pwd|secret|csrf|xsrf|token|cvc|cvv/i
11
+
12
+ # The service could not be reached or answered unusably (network, timeout, HTTP 5xx/429, no JSON).
13
+ # The caller decides: let the request pass (fail open) or block it (fail closed).
14
+ class UnavailableError < StandardError; end
15
+
16
+ Result = Struct.new(:success, :gruende, :punkte, :error_codes, :hostname, :challenge_ts, keyword_init: true) do
17
+ def self.from_hash(h)
18
+ new(success: h["success"] == true, gruende: Array(h["gruende"]).map(&:to_s), punkte: h["punkte"],
19
+ error_codes: Array(h["error-codes"]).map(&:to_s), hostname: h["hostname"].to_s, challenge_ts: h["challenge_ts"])
20
+ end
21
+
22
+ def success? = success
23
+
24
+ # Secret missing or wrong: a configuration problem, not a spam verdict.
25
+ def configuration_error?
26
+ !(error_codes & %w[missing-input-secret invalid-input-secret]).empty?
27
+ end
28
+ end
29
+
30
+ # Token from a params-like object (Hash, ActionController::Parameters, ...).
31
+ def self.token_from(form)
32
+ TOKEN_FIELDS.each do |name|
33
+ v = form[name]
34
+ v = form[name.to_sym] if v.nil? && form.respond_to?(:key?) && form.key?(name.to_sym)
35
+ return v.strip if v.is_a?(String) && !v.strip.empty?
36
+ end
37
+ nil
38
+ end
39
+
40
+ # Form content for rule/honeypot checks. Token fields and anything that looks like a password, secret,
41
+ # CSRF token or card code are removed (also in nested hashes).
42
+ def self.content(form, exclude = [])
43
+ form = form.to_unsafe_h if form.respond_to?(:to_unsafe_h)
44
+ out = {}
45
+ form.each do |name, value|
46
+ key = name.to_s
47
+ next if TOKEN_FIELDS.include?(key) || exclude.include?(key) || SENSITIVE.match?(key)
48
+
49
+ value = value.to_unsafe_h if value.respond_to?(:to_unsafe_h)
50
+ out[key] =
51
+ case value
52
+ when Hash then content(value, exclude)
53
+ when Array then value.map { |v| v.is_a?(Hash) ? content(v, exclude) : v.to_s }
54
+ when String, Numeric, true, false, nil then value.to_s
55
+ else next
56
+ end
57
+ end
58
+ out
59
+ end
60
+
61
+ class Client
62
+ attr_reader :endpoint, :timeout
63
+
64
+ def initialize(secret, endpoint: DEFAULT_ENDPOINT, timeout: 5.0)
65
+ @secret = secret
66
+ @endpoint = endpoint.to_s.sub(%r{/+\z}, "")
67
+ @timeout = timeout
68
+ end
69
+
70
+ # Raises UnavailableError if the service cannot be reached.
71
+ def verify(token, remoteip: nil, formular: nil)
72
+ if token.nil? || token.to_s.strip.empty?
73
+ return Result.new(success: false, gruende: ["token_fehlt"], punkte: nil, error_codes: ["missing-input-response"], hostname: "", challenge_ts: nil)
74
+ end
75
+
76
+ body = { "secret" => @secret, "response" => token.to_s.strip }
77
+ body["remoteip"] = remoteip if remoteip && !remoteip.to_s.empty?
78
+ body["formular"] = formular unless formular.nil?
79
+ status, raw = post(JSON.generate(body))
80
+ data = begin
81
+ JSON.parse(raw)
82
+ rescue JSON::ParserError, TypeError
83
+ nil
84
+ end
85
+ if status >= 500 || status == 429 || !data.is_a?(Hash) || !data.key?("success")
86
+ raise UnavailableError, "hostily Captcha answered unusably (HTTP #{status})"
87
+ end
88
+
89
+ Result.from_hash(data)
90
+ end
91
+
92
+ def verify_form(form, remoteip: nil, send_content: true)
93
+ verify(Captcha.token_from(form), remoteip: remoteip, formular: send_content ? Captcha.content(form) : nil)
94
+ end
95
+
96
+ private
97
+
98
+ def post(json)
99
+ uri = URI.parse("#{@endpoint}/api/v1/pruefen")
100
+ http = Net::HTTP.new(uri.host, uri.port)
101
+ http.use_ssl = uri.scheme == "https"
102
+ http.open_timeout = http.read_timeout = http.write_timeout = @timeout
103
+ req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json", "Accept" => "application/json",
104
+ "User-Agent" => "hostily-captcha-ruby/#{VERSION}")
105
+ req.body = json
106
+ res = http.request(req)
107
+ [res.code.to_i, res.body.to_s]
108
+ rescue SystemCallError, SocketError, IOError, Timeout::Error, OpenSSL::SSL::SSLError, URI::InvalidURIError, EOFError, Net::ProtocolError => e
109
+ raise UnavailableError, "hostily Captcha not reachable: #{e.message}"
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,72 @@
1
+ require "cgi"
2
+
3
+ module Hostily
4
+ module Captcha
5
+ # View helper: <%= hostily_captcha_tag %> inside the <form>. Settings come from Hostily::Captcha.configuration.
6
+ module ViewHelper
7
+ # Widget element (and, unless script: false, the widget script). Options: site_key, modus, sprache, api.
8
+ def hostily_captcha_tag(site_key: nil, modus: nil, sprache: nil, api: nil, script: true)
9
+ c = Hostily::Captcha.configuration
10
+ key = site_key || c.site_key
11
+ return hostily_captcha_html("") if key.to_s.empty? # not configured: nothing to show
12
+
13
+ base = (api || c.endpoint).to_s.sub(%r{/+\z}, "")
14
+ attrs = { "data-site-key" => key, "data-api" => "#{base}/api/", "data-modus" => modus || c.modus }
15
+ sp = sprache || c.sprache
16
+ attrs["data-sprache"] = sp if sp
17
+ html = +""
18
+ html << %(<script src="#{CGI.escapeHTML(base)}/widget/hostily-captcha.js" defer></script>\n) if script
19
+ html << "<hostily-captcha" + attrs.map { |k, v| %( #{k}="#{CGI.escapeHTML(v.to_s)}") }.join + "></hostily-captcha>"
20
+ hostily_captcha_html(html)
21
+ end
22
+
23
+ private
24
+
25
+ def hostily_captcha_html(str)
26
+ str.respond_to?(:html_safe) ? str.html_safe : str
27
+ end
28
+ end
29
+
30
+ # Controller method: verify_hostily_captcha -> true (pass) / false (reject). Rejections are logged.
31
+ module ControllerMethods
32
+ # fail_open: nil takes Hostily::Captcha.configuration.fail_open. Without secret + site key nothing is checked.
33
+ def verify_hostily_captcha(fail_open: nil, send_content: true)
34
+ c = Hostily::Captcha.configuration
35
+ return true if c.secret.to_s.empty? || c.site_key.to_s.empty?
36
+
37
+ fail_open = c.fail_open if fail_open.nil?
38
+ result = Hostily::Captcha.client.verify_form(params, remoteip: request.remote_ip, send_content: send_content)
39
+ if result.configuration_error?
40
+ hostily_captcha_log(:error, "secret missing or wrong (#{result.error_codes.join(',')})")
41
+ return fail_open
42
+ end
43
+ @hostily_captcha_result = result
44
+ result.success?
45
+ rescue Hostily::Captcha::UnavailableError => e
46
+ hostily_captcha_log(:warn, e.message)
47
+ fail_open
48
+ end
49
+
50
+ # Result of the last check (nil if the service was down, not configured or the secret was wrong).
51
+ def hostily_captcha_result
52
+ @hostily_captcha_result
53
+ end
54
+
55
+ private
56
+
57
+ def hostily_captcha_log(level, message)
58
+ l = respond_to?(:logger, true) ? logger : nil
59
+ l&.public_send(level, "[hostily-captcha] #{message}") # never the secret
60
+ end
61
+ end
62
+
63
+ if defined?(::Rails::Railtie)
64
+ class Railtie < ::Rails::Railtie
65
+ initializer "hostily_captcha.helpers" do
66
+ ActiveSupport.on_load(:action_view) { include Hostily::Captcha::ViewHelper }
67
+ ActiveSupport.on_load(:action_controller) { include Hostily::Captcha::ControllerMethods }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,5 @@
1
+ module Hostily
2
+ module Captcha
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,35 @@
1
+ require "hostily/captcha/version"
2
+ require "hostily/captcha/client"
3
+ require "hostily/captcha/rails"
4
+
5
+ module Hostily
6
+ module Captcha
7
+ class Configuration
8
+ attr_accessor :secret, :site_key, :endpoint, :timeout, :modus, :sprache, :fail_open
9
+
10
+ def initialize
11
+ @endpoint = DEFAULT_ENDPOINT
12
+ @timeout = 5.0
13
+ @modus = "sichtbar"
14
+ @sprache = nil # nil: the widget decides (page language)
15
+ @fail_open = true # service down: let the request pass (false: block)
16
+ end
17
+ end
18
+
19
+ class << self
20
+ def configuration
21
+ @configuration ||= Configuration.new
22
+ end
23
+
24
+ def configure
25
+ yield configuration
26
+ end
27
+
28
+ # Client from the global configuration.
29
+ def client
30
+ c = configuration
31
+ Client.new(c.secret.to_s, endpoint: c.endpoint, timeout: c.timeout)
32
+ end
33
+ end
34
+ end
35
+ end
metadata ADDED
@@ -0,0 +1,50 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hostily-captcha
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - connect - Media & Teledienst
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-30 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Verify the token of a submitted form against hostily Captcha. Net::HTTP
14
+ only; optional Rails view helper and controller method.
15
+ email:
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - LICENSE
21
+ - README.md
22
+ - lib/hostily/captcha.rb
23
+ - lib/hostily/captcha/client.rb
24
+ - lib/hostily/captcha/rails.rb
25
+ - lib/hostily/captcha/version.rb
26
+ homepage: https://captcha.hostily.de
27
+ licenses:
28
+ - MIT
29
+ metadata:
30
+ source_code_uri: https://github.com/connect-gruppe/hostily-captcha-ruby
31
+ post_install_message:
32
+ rdoc_options: []
33
+ require_paths:
34
+ - lib
35
+ required_ruby_version: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '3.1'
40
+ required_rubygems_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '0'
45
+ requirements: []
46
+ rubygems_version: 3.5.22
47
+ signing_key:
48
+ specification_version: 4
49
+ summary: Server-side client for hostily Captcha (captcha.hostily.de), with Rails helpers
50
+ test_files: []