openehr-rails 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47f141ace236ed8735976a2ea1dcf01d2e05275d9ae82f18448659198f0f6c29
4
- data.tar.gz: 9f75fe85bf8a55fbf723d3d61e100ae5d270fdc35413e9acd79fe0fd8c9f2f4b
3
+ metadata.gz: 8420a812bd6c4b3f95a403f4804fa58d660a1aa6d736d106bc0dc57a8b63e1ee
4
+ data.tar.gz: df4f5629f4915f03c4a07b944a168a1c53d08da043e07cbc6cf6d705d036f017
5
5
  SHA512:
6
- metadata.gz: 0fe633b32659bc45213734d878c3d1e958fdb1dda8f7c71f93fc299ac6004d78c525f5d44b44b00b5746241d366d1bcdaca1e702c6189720de974e9d300fdf5e
7
- data.tar.gz: bda15de876019effa3d914cb969748d3912dcd8af682dba34faf8acb08844243e9083aea2fb4c62ad3aa6385c778be82250913d57dbd4d77a2000d016834f909
6
+ metadata.gz: 0e39f211e2243b0ac74aff1408136652ba0b4f829a8bc28a92a842ba3e1636955d4ea9fcdfe6ee0e0351b9f3c7c5bd763139478574a5d5acf19c8f8fb9f62a5a
7
+ data.tar.gz: 4a8e6beb226a33825df8cd1d762b546cbaf4ca39877d2bda72ab8e81978a58fe57ceefa75ef1d372c609d495fb6d35adc65016592078d10f746be0f7ca041ed7
data/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-08-13
9
+
10
+ ### Added
11
+ - `OpenehrRails.authenticate_with`: a pluggable authentication hook, run
12
+ as a `before_action` ahead of every engine action (admin UI, AQL
13
+ console, patient timeline, the `/v1` openEHR REST API, and the `/fhir`
14
+ facade). Host apps wire in their own mechanism (Devise, HTTP token,
15
+ anything) with the controller as context. Per-surface differentiation
16
+ via `openehr_access_scope` (`:admin`/`:rest_api`/`:fhir`).
17
+ - `OpenehrRails.allow_unauthenticated_access`: explicit opt-out for
18
+ intentionally-open deployments.
19
+
20
+ ### Changed (behavior change -- read before upgrading)
21
+ - **The engine now denies all requests with `403 Forbidden` outside the
22
+ `development` and `test` environments unless `authenticate_with` or
23
+ `allow_unauthenticated_access` is configured.** Previously every
24
+ engine route (including the full openEHR REST API and the FHIR
25
+ facade's create endpoint) was open with no access control whatsoever.
26
+ Apps running 0.3.0 in production must configure one of the two before
27
+ upgrading, or the engine will start responding 403 to everything.
28
+ - `RemoteFetcher` now also rejects loopback/private/link-local (including
29
+ cloud metadata) addresses, both on the initial URL and on every
30
+ redirect hop, closing an SSRF gap (previously only the URL scheme was
31
+ validated).
32
+ - Every generic `rescue_from StandardError` handler across the engine's
33
+ controllers now logs the exception (class, message, and a short
34
+ backtrace) via `Rails.logger` before responding -- previously errors
35
+ were rendered to the client and left no server-side trace at all.
36
+
8
37
  ## [0.3.0] - 2026-08-13
9
38
 
10
39
  ### Added
data/README.md CHANGED
@@ -65,6 +65,61 @@ enabled in the development environment only. Override with:
65
65
  OpenehrRails.enable_runtime_scaffolding = true # or false
66
66
  ```
67
67
 
68
+ ### Authentication
69
+
70
+ Everything the engine serves — the template admin UI, the AQL console,
71
+ the patient timeline, the openEHR REST API (`/openehr/v1`) and the FHIR
72
+ facade (`/openehr/fhir`) — handles clinical data, so outside the
73
+ `development` and `test` environments the engine is **closed by
74
+ default**: every request gets `403 Forbidden` until you configure an
75
+ authentication hook.
76
+
77
+ The hook runs as a `before_action` inside the engine controller handling
78
+ the request (`instance_exec`'d, so it can use `request`, `render`,
79
+ `redirect_to`, and any helper your app mixes into `ActionController::Base`).
80
+ Deny by rendering or redirecting — a hook that raises would be caught
81
+ and reported as a misleading error by some engine controllers'
82
+ `rescue_from StandardError`.
83
+
84
+ ```ruby
85
+ # config/initializers/openehr.rb
86
+
87
+ # Devise:
88
+ OpenehrRails.authenticate_with = -> { authenticate_user! }
89
+
90
+ # Bearer token:
91
+ OpenehrRails.authenticate_with = lambda do
92
+ authenticate_or_request_with_http_token do |token, _options|
93
+ ActiveSupport::SecurityUtils.secure_compare(
94
+ token, Rails.application.credentials.openehr_api_token.to_s
95
+ )
96
+ end
97
+ end
98
+ ```
99
+
100
+ To use a different mechanism per surface, branch on `openehr_access_scope`
101
+ (`:admin` — template UI / AQL console / timeline, `:rest_api` — `/v1`,
102
+ `:fhir` — the FHIR facade):
103
+
104
+ ```ruby
105
+ OpenehrRails.authenticate_with = lambda do
106
+ case openehr_access_scope
107
+ when :admin then authenticate_user!
108
+ else authenticate_or_request_with_http_token { |t, _| valid_api_token?(t) }
109
+ end
110
+ end
111
+ ```
112
+
113
+ To intentionally run without authentication (e.g. behind a reverse
114
+ proxy that already authenticates, or a network-isolated internal app):
115
+
116
+ ```ruby
117
+ OpenehrRails.allow_unauthenticated_access = true
118
+ ```
119
+
120
+ Note: the JSON API controllers (`/v1`, `/fhir`) skip CSRF protection, so
121
+ prefer token authentication over session cookies for those.
122
+
68
123
  ### HL7 FHIR R5 facade
69
124
 
70
125
  The engine also serves a FHIR R5 API under `<mount>/fhir`
@@ -3,5 +3,42 @@
3
3
  module OpenehrRails
4
4
  class ApplicationController < ActionController::Base
5
5
  layout 'openehr_rails/application'
6
+
7
+ before_action :authenticate_openehr_access!
8
+
9
+ # Which engine surface this request targets, so a single
10
+ # OpenehrRails.authenticate_with hook can differentiate if desired:
11
+ # :admin - template management UI, AQL console, patient timeline
12
+ # :rest_api - openEHR REST API under /v1
13
+ # :fhir - HL7 FHIR R5 facade under /fhir
14
+ def openehr_access_scope
15
+ :admin
16
+ end
17
+
18
+ private
19
+
20
+ def authenticate_openehr_access!
21
+ hook = OpenehrRails.authenticate_with
22
+ return instance_exec(&hook) if hook
23
+ return if OpenehrRails.unauthenticated_access_allowed?
24
+
25
+ deny_openehr_access
26
+ end
27
+
28
+ def log_openehr_error(error)
29
+ Rails.logger&.error(
30
+ "[OpenehrRails] #{error.class}: #{error.message}\n#{Array(error.backtrace).first(10).join("\n")}"
31
+ )
32
+ end
33
+
34
+ def deny_openehr_access
35
+ message = 'openEHR engine access denied: no authentication configured. ' \
36
+ 'Set OpenehrRails.authenticate_with in config/initializers/openehr.rb.'
37
+ if request.format.json?
38
+ render json: { error: message }, status: :forbidden
39
+ else
40
+ render plain: message, status: :forbidden
41
+ end
42
+ end
6
43
  end
7
44
  end
@@ -15,6 +15,10 @@ module OpenehrRails
15
15
 
16
16
  FHIR_CONTENT_TYPE = 'application/fhir+json'
17
17
 
18
+ def openehr_access_scope
19
+ :fhir
20
+ end
21
+
18
22
  def metadata
19
23
  render_fhir OpenehrRails::Fhir::CapabilityStatement.build(base_url: request.base_url)
20
24
  end
@@ -125,6 +129,7 @@ module OpenehrRails
125
129
  end
126
130
 
127
131
  def render_operation_outcome(error)
132
+ log_openehr_error(error)
128
133
  status = case error
129
134
  when OpenehrRails::Fhir::Deserializer::UnmappedResource then :unprocessable_entity
130
135
  when ActiveRecord::RecordInvalid then :unprocessable_entity
@@ -20,6 +20,10 @@ module OpenehrRails
20
20
  rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
21
21
  rescue_from PreconditionFailed, with: :render_precondition_failed
22
22
 
23
+ def openehr_access_scope
24
+ :rest_api
25
+ end
26
+
23
27
  # POST /v1/ehr/:ehr_id/composition
24
28
  def create
25
29
  ehr = find_ehr!
@@ -110,6 +114,7 @@ module OpenehrRails
110
114
  end
111
115
 
112
116
  def render_error(error)
117
+ log_openehr_error(error)
113
118
  render json: { error: error.message }, status: :bad_request
114
119
  end
115
120
  end
@@ -11,6 +11,10 @@ module OpenehrRails
11
11
  rescue_from ActiveRecord::RecordInvalid, with: :render_invalid
12
12
  rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
13
13
 
14
+ def openehr_access_scope
15
+ :rest_api
16
+ end
17
+
14
18
  # POST /v1/ehr (server-generated ehr_id)
15
19
  def create
16
20
  ehr = OpenehrRails::Rm::Ehr.create!(ehr_attributes.merge(ehr_id: SecureRandom.uuid))
@@ -75,6 +79,7 @@ module OpenehrRails
75
79
  end
76
80
 
77
81
  def render_error(error)
82
+ log_openehr_error(error)
78
83
  render json: { error: error.message }, status: :bad_request
79
84
  end
80
85
  end
@@ -11,6 +11,10 @@ module OpenehrRails
11
11
  rescue_from StandardError, with: :render_error
12
12
  rescue_from OpenehrRails::Aql::Error, with: :render_aql_error
13
13
 
14
+ def openehr_access_scope
15
+ action_name == 'execute' ? :rest_api : :admin
16
+ end
17
+
14
18
  # GET /query -- HTML console. Executes via fetch against
15
19
  # POST /v1/query/aql below, so there is exactly one execution path.
16
20
  def show; end
@@ -50,6 +54,7 @@ module OpenehrRails
50
54
  end
51
55
 
52
56
  def render_error(error)
57
+ log_openehr_error(error)
53
58
  render json: { error: error.message }, status: :bad_request
54
59
  end
55
60
  end
@@ -30,6 +30,7 @@ module OpenehrRails
30
30
  redirect_to root_path, notice: "Generated UI at /#{result.route_path}"
31
31
  end
32
32
  rescue StandardError => e
33
+ log_openehr_error(e)
33
34
  redirect_to root_path, alert: "Generation failed: #{e.message}"
34
35
  end
35
36
 
@@ -20,3 +20,34 @@ require 'openehr_rails'
20
20
  # OpenehrRails.default_category = %w[433 event] # [code, value]
21
21
  # OpenehrRails.default_composer_name = 'unknown'
22
22
  # OpenehrRails.default_encoding = 'UTF-8'
23
+
24
+ # Authentication (REQUIRED before deploying outside development/test):
25
+ # the engine (admin UI, AQL console, /v1 REST API, /fhir facade) serves
26
+ # clinical data, so it denies every request with 403 in any environment
27
+ # other than development/test until you configure a hook here. The hook
28
+ # runs as a before_action inside the engine controller handling the
29
+ # request (instance_exec'd) -- deny by rendering/redirecting, not
30
+ # raising, since some engine controllers rescue_from StandardError.
31
+ #
32
+ # Devise:
33
+ # OpenehrRails.authenticate_with = -> { authenticate_user! }
34
+ #
35
+ # Bearer token:
36
+ # OpenehrRails.authenticate_with = lambda do
37
+ # authenticate_or_request_with_http_token do |token, _options|
38
+ # ActiveSupport::SecurityUtils.secure_compare(
39
+ # token, Rails.application.credentials.openehr_api_token.to_s
40
+ # )
41
+ # end
42
+ # end
43
+ #
44
+ # Different mechanism per surface (openehr_access_scope is :admin,
45
+ # :rest_api or :fhir):
46
+ # OpenehrRails.authenticate_with = lambda do
47
+ # openehr_access_scope == :admin ? authenticate_user! : authenticate_or_request_with_http_token { |t, _| valid_api_token?(t) }
48
+ # end
49
+ #
50
+ # Explicit opt-out for intentionally-open deployments (e.g. behind a
51
+ # reverse proxy that already authenticates, network-isolated internal
52
+ # apps) -- NOT recommended for anything reachable outside your network:
53
+ # OpenehrRails.allow_unauthenticated_access = true
@@ -1,5 +1,5 @@
1
1
  module OpenEHR
2
2
  module Rails
3
- VERSION = '0.3.0'
3
+ VERSION = '0.4.0'
4
4
  end
5
5
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'net/http'
4
4
  require 'uri'
5
+ require 'ipaddr'
6
+ require 'resolv'
5
7
  require 'openehr_rails/opt'
6
8
 
7
9
  module OpenehrRails
@@ -20,6 +22,21 @@ module OpenehrRails
20
22
  READ_TIMEOUT = 20
21
23
  ALLOWED_SCHEMES = %w[http https].freeze
22
24
 
25
+ # Loopback, private, and link-local ranges (the latter includes the
26
+ # 169.254.169.254 cloud-metadata endpoint AWS/GCP/Azure all use).
27
+ # Checked against every hop (initial URL and every redirect target,
28
+ # since parse_uri runs on each via #get), not just the first.
29
+ BLOCKED_IP_RANGES = [
30
+ IPAddr.new('127.0.0.0/8'),
31
+ IPAddr.new('10.0.0.0/8'),
32
+ IPAddr.new('172.16.0.0/12'),
33
+ IPAddr.new('192.168.0.0/16'),
34
+ IPAddr.new('169.254.0.0/16'),
35
+ IPAddr.new('::1/128'),
36
+ IPAddr.new('fc00::/7'),
37
+ IPAddr.new('fe80::/10')
38
+ ].freeze
39
+
23
40
  def self.fetch(url)
24
41
  new(url).fetch
25
42
  end
@@ -58,10 +75,30 @@ module OpenehrRails
58
75
  unless ALLOWED_SCHEMES.include?(uri.scheme)
59
76
  raise FetchError, "unsupported URL scheme: #{uri.scheme.inspect} (must be http/https)"
60
77
  end
78
+ if blocked_host?(uri.host)
79
+ raise FetchError, "refusing to fetch #{url}: host resolves to a private/internal/link-local address"
80
+ end
61
81
 
62
82
  uri
63
83
  end
64
84
 
85
+ # True if `host` (a literal IP, a bracketed IPv6 literal, or a
86
+ # hostname to resolve) is, or resolves to, a loopback/private/
87
+ # link-local address. Fails open on an unresolvable hostname --
88
+ # Net::HTTP will then fail on its own DNS lookup, converted to a
89
+ # FetchError by #get's rescue clause.
90
+ def blocked_host?(host)
91
+ literal = literal_ip(host)
92
+ addresses = literal ? [literal] : Resolv.getaddresses(host).filter_map { |ip| literal_ip(ip) }
93
+ addresses.any? { |address| BLOCKED_IP_RANGES.any? { |range| range.include?(address) } }
94
+ end
95
+
96
+ def literal_ip(host)
97
+ IPAddr.new(host)
98
+ rescue IPAddr::Error
99
+ nil
100
+ end
101
+
65
102
  def request(uri)
66
103
  Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
67
104
  open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
data/lib/openehr_rails.rb CHANGED
@@ -63,4 +63,29 @@ module OpenehrRails
63
63
 
64
64
  defined?(::Rails.env) && ::Rails.env.development?
65
65
  end
66
+
67
+ # Authentication hook, run via before_action ahead of every engine
68
+ # action (all controllers inherit from OpenehrRails::ApplicationController).
69
+ # A zero-arity proc, instance_exec'd in the controller -- so it can use
70
+ # request/render/redirect_to/head and any helper the host mixes into
71
+ # ActionController::Base (e.g. Devise's authenticate_user!). Deny by
72
+ # rendering or redirecting (standard before_action halting); some engine
73
+ # controllers rescue_from StandardError, so a hook that raises instead
74
+ # of rendering would be masked as a misleading error response --
75
+ # render/redirect only.
76
+ mattr_accessor :authenticate_with, default: nil
77
+
78
+ # Explicit escape hatch for intentionally-open deployments (e.g. behind
79
+ # a reverse proxy that already authenticates, or network-isolated
80
+ # internal-only apps). Overrides the environment-based default in
81
+ # EITHER direction: true forces access even in production; false forces
82
+ # denial even in development.
83
+ mattr_accessor :allow_unauthenticated_access, default: nil
84
+
85
+ def self.unauthenticated_access_allowed?
86
+ return allow_unauthenticated_access unless allow_unauthenticated_access.nil?
87
+ return false unless defined?(::Rails.env)
88
+
89
+ ::Rails.env.development? || ::Rails.env.test?
90
+ end
66
91
  end
@@ -40,6 +40,8 @@ describe Openehr::Generators::InstallGenerator do
40
40
  .to contain("require 'openehr_rails'")
41
41
  expect(file('config/initializers/openehr.rb'))
42
42
  .to contain('rm_persistence_enabled')
43
+ expect(file('config/initializers/openehr.rb'))
44
+ .to contain('OpenehrRails.authenticate_with')
43
45
  end
44
46
 
45
47
  it 'creates the operational template directory' do
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'openehr_rails'
5
+ require_relative '../../app/controllers/openehr_rails/application_controller'
6
+
7
+ describe 'OpenehrRails engine authentication' do
8
+ after do
9
+ OpenehrRails.authenticate_with = nil
10
+ OpenehrRails.allow_unauthenticated_access = nil
11
+ end
12
+
13
+ describe '.unauthenticated_access_allowed?' do
14
+ def stub_env(name)
15
+ allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new(name))
16
+ end
17
+
18
+ it 'is true in development when unconfigured' do
19
+ stub_env('development')
20
+ expect(OpenehrRails.unauthenticated_access_allowed?).to be true
21
+ end
22
+
23
+ it 'is true in test when unconfigured' do
24
+ stub_env('test')
25
+ expect(OpenehrRails.unauthenticated_access_allowed?).to be true
26
+ end
27
+
28
+ it 'is false in production when unconfigured' do
29
+ stub_env('production')
30
+ expect(OpenehrRails.unauthenticated_access_allowed?).to be false
31
+ end
32
+
33
+ it 'honours an explicit true override even in production' do
34
+ stub_env('production')
35
+ OpenehrRails.allow_unauthenticated_access = true
36
+ expect(OpenehrRails.unauthenticated_access_allowed?).to be true
37
+ end
38
+
39
+ it 'honours an explicit false override even in development' do
40
+ stub_env('development')
41
+ OpenehrRails.allow_unauthenticated_access = false
42
+ expect(OpenehrRails.unauthenticated_access_allowed?).to be false
43
+ end
44
+ end
45
+
46
+ describe OpenehrRails::ApplicationController do
47
+ subject(:controller) { described_class.new }
48
+
49
+ def stub_request(json: true)
50
+ allow(controller).to receive(:request).and_return(
51
+ instance_double(ActionDispatch::Request, format: double('format', json?: json))
52
+ )
53
+ end
54
+
55
+ before do
56
+ allow(controller).to receive(:render)
57
+ stub_request
58
+ end
59
+
60
+ it 'defaults openehr_access_scope to :admin' do
61
+ expect(controller.openehr_access_scope).to eq(:admin)
62
+ end
63
+
64
+ context 'when no hook is configured' do
65
+ it 'allows the request in development/test (unauthenticated_access_allowed? true)' do
66
+ allow(OpenehrRails).to receive(:unauthenticated_access_allowed?).and_return(true)
67
+
68
+ controller.send(:authenticate_openehr_access!)
69
+
70
+ expect(controller).not_to have_received(:render)
71
+ end
72
+
73
+ it 'denies the request with 403 JSON outside development/test' do
74
+ allow(OpenehrRails).to receive(:unauthenticated_access_allowed?).and_return(false)
75
+ stub_request(json: true)
76
+
77
+ controller.send(:authenticate_openehr_access!)
78
+
79
+ expect(controller).to have_received(:render).with(hash_including(status: :forbidden, json: anything))
80
+ end
81
+
82
+ it 'denies non-JSON requests with a plain-text 403' do
83
+ allow(OpenehrRails).to receive(:unauthenticated_access_allowed?).and_return(false)
84
+ stub_request(json: false)
85
+
86
+ controller.send(:authenticate_openehr_access!)
87
+
88
+ expect(controller).to have_received(:render).with(hash_including(status: :forbidden, plain: anything))
89
+ end
90
+ end
91
+
92
+ context 'when a hook is configured' do
93
+ it 'defers entirely to the hook, even outside development/test' do
94
+ allow(OpenehrRails).to receive(:unauthenticated_access_allowed?).and_return(false)
95
+ ran = false
96
+ OpenehrRails.authenticate_with = -> { ran = true }
97
+
98
+ controller.send(:authenticate_openehr_access!)
99
+
100
+ expect(ran).to be true
101
+ expect(controller).not_to have_received(:render)
102
+ end
103
+
104
+ it 'instance_execs the hook in controller context so it can call controller methods' do
105
+ def controller.custom_auth_check
106
+ render plain: 'nope', status: :unauthorized
107
+ end
108
+ OpenehrRails.authenticate_with = -> { custom_auth_check }
109
+
110
+ controller.send(:authenticate_openehr_access!)
111
+
112
+ expect(controller).to have_received(:render).with(hash_including(status: :unauthorized))
113
+ end
114
+ end
115
+ end
116
+
117
+ describe 'openehr_access_scope overrides' do
118
+ require_relative '../../app/controllers/openehr_rails/fhir_controller'
119
+ require_relative '../../app/controllers/openehr_rails/queries_controller'
120
+ require_relative '../../app/controllers/openehr_rails/openehr_api/ehrs_controller'
121
+ require_relative '../../app/controllers/openehr_rails/openehr_api/compositions_controller'
122
+
123
+ it 'is :fhir for FhirController' do
124
+ expect(OpenehrRails::FhirController.new.openehr_access_scope).to eq(:fhir)
125
+ end
126
+
127
+ it 'is :rest_api for OpenehrApi::EhrsController and OpenehrApi::CompositionsController' do
128
+ expect(OpenehrRails::OpenehrApi::EhrsController.new.openehr_access_scope).to eq(:rest_api)
129
+ expect(OpenehrRails::OpenehrApi::CompositionsController.new.openehr_access_scope).to eq(:rest_api)
130
+ end
131
+
132
+ it 'is :rest_api for QueriesController#execute and :admin for QueriesController#show' do
133
+ controller = OpenehrRails::QueriesController.new
134
+ allow(controller).to receive(:action_name).and_return('execute')
135
+ expect(controller.openehr_access_scope).to eq(:rest_api)
136
+
137
+ allow(controller).to receive(:action_name).and_return('show')
138
+ expect(controller.openehr_access_scope).to eq(:admin)
139
+ end
140
+ end
141
+ end
@@ -68,4 +68,39 @@ describe OpenehrRails::Opt::RemoteFetcher do
68
68
  expect { described_class.fetch('https://example.com/slow.opt') }
69
69
  .to raise_error(described_class::FetchError)
70
70
  end
71
+
72
+ describe 'SSRF protection' do
73
+ # No stub_request for any of these: a correctly-blocked URL must never
74
+ # reach Net::HTTP at all (WebMock would raise its own "real requests
75
+ # are not allowed" error if it did, which -- being a different error
76
+ # class -- would also fail these expectations).
77
+ [
78
+ '127.0.0.1', # loopback
79
+ '169.254.169.254', # link-local, incl. cloud metadata endpoints
80
+ '10.1.2.3', # private
81
+ '172.16.0.5', # private
82
+ '192.168.1.1', # private
83
+ '[::1]' # IPv6 loopback
84
+ ].each do |host|
85
+ it "rejects a URL targeting the blocked address #{host}" do
86
+ expect { described_class.fetch("http://#{host}/x.opt") }
87
+ .to raise_error(described_class::FetchError, /internal|private|blocked/i)
88
+ end
89
+ end
90
+
91
+ it 'rejects a redirect to a blocked address, not just the initial URL' do
92
+ stub_request(:get, 'https://example.com/external.opt')
93
+ .to_return(status: 302, headers: { 'Location' => 'http://127.0.0.1/internal.opt' })
94
+
95
+ expect { described_class.fetch('https://example.com/external.opt') }
96
+ .to raise_error(described_class::FetchError, /internal|private|blocked/i)
97
+ end
98
+
99
+ it 'still allows a normal public host' do
100
+ stub_request(:get, 'https://example.com/bmi_calculation.opt')
101
+ .to_return(status: 200, body: opt_body)
102
+
103
+ expect(described_class.fetch('https://example.com/bmi_calculation.opt')).to eq(opt_body)
104
+ end
105
+ end
71
106
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openehr-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shinji KOBAYASHI
@@ -310,6 +310,7 @@ files:
310
310
  - spec/openehr_rails/aql/executor_spec.rb
311
311
  - spec/openehr_rails/aql/model_api_spec.rb
312
312
  - spec/openehr_rails/aql/query_validator_spec.rb
313
+ - spec/openehr_rails/authentication_spec.rb
313
314
  - spec/openehr_rails/fhir/profile_generator_spec.rb
314
315
  - spec/openehr_rails/fhir/serializer_spec.rb
315
316
  - spec/openehr_rails/naming_spec.rb