iron-cms 0.18.2 → 0.19.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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -0
  3. data/app/assets/builds/iron.css +255 -218
  4. data/app/controllers/concerns/iron/api/token_authentication.rb +2 -7
  5. data/app/controllers/concerns/iron/bounded_request_body.rb +48 -0
  6. data/app/controllers/iron/api/base_controller.rb +3 -0
  7. data/app/controllers/iron/api/mcp_controller.rb +119 -0
  8. data/app/controllers/iron/api/openapi_controller.rb +1 -8
  9. data/app/controllers/iron/oauth/authorizations_controller.rb +120 -0
  10. data/app/controllers/iron/oauth/metadata_controller.rb +38 -0
  11. data/app/controllers/iron/oauth/registrations_controller.rb +54 -0
  12. data/app/controllers/iron/oauth/tokens_controller.rb +92 -0
  13. data/app/mcp/iron/mcp/error.rb +5 -0
  14. data/app/mcp/iron/mcp/forbidden.rb +9 -0
  15. data/app/mcp/iron/mcp/invalid_request.rb +5 -0
  16. data/app/mcp/iron/mcp/paginator.rb +19 -0
  17. data/app/mcp/iron/mcp/server.rb +38 -0
  18. data/app/mcp/iron/mcp/tool_context.rb +114 -0
  19. data/app/mcp/iron/mcp/tools/base.rb +61 -0
  20. data/app/mcp/iron/mcp/tools/create_entry.rb +28 -0
  21. data/app/mcp/iron/mcp/tools/delete_entry.rb +25 -0
  22. data/app/mcp/iron/mcp/tools/describe_schema.rb +20 -0
  23. data/app/mcp/iron/mcp/tools/get_entry.rb +25 -0
  24. data/app/mcp/iron/mcp/tools/list_content_types.rb +20 -0
  25. data/app/mcp/iron/mcp/tools/list_entries.rb +27 -0
  26. data/app/mcp/iron/mcp/tools/search.rb +25 -0
  27. data/app/mcp/iron/mcp/tools/update_entry.rb +28 -0
  28. data/app/mcp/iron/mcp/tools/upload_asset.rb +27 -0
  29. data/app/models/iron/api/openapi_spec.rb +7 -0
  30. data/app/models/iron/content/download_budget.rb +31 -0
  31. data/app/models/iron/content.rb +201 -32
  32. data/app/models/iron/field_definition.rb +7 -1
  33. data/app/models/iron/field_definitions/block.rb +3 -1
  34. data/app/models/iron/field_definitions/block_list.rb +3 -1
  35. data/app/models/iron/integration.rb +11 -0
  36. data/app/models/iron/oauth/client.rb +63 -0
  37. data/app/models/iron/oauth/grant.rb +67 -0
  38. data/app/models/iron/oauth/resource.rb +27 -0
  39. data/app/models/iron/oauth/scope.rb +13 -0
  40. data/app/models/iron/oauth/secret.rb +13 -0
  41. data/app/models/iron/oauth/token.rb +123 -0
  42. data/app/models/iron/ssrf_protection.rb +82 -0
  43. data/app/views/iron/authentication/_brandmark.html.erb +5 -0
  44. data/app/views/iron/oauth/authorizations/new.html.erb +31 -0
  45. data/app/views/iron/sessions/new.html.erb +1 -5
  46. data/app/views/layouts/iron/application.html.erb +3 -0
  47. data/app/views/layouts/iron/authentication.html.erb +3 -0
  48. data/config/locales/en.yml +12 -0
  49. data/config/locales/it.yml +12 -0
  50. data/config/routes.rb +1 -0
  51. data/db/migrate/20260626090000_create_iron_oauth_tables.rb +50 -0
  52. data/lib/generators/iron/install/install_generator.rb +19 -0
  53. data/lib/iron/engine.rb +6 -0
  54. data/lib/iron/oauth_body_limit.rb +52 -0
  55. data/lib/iron/version.rb +1 -1
  56. metadata +50 -2
@@ -0,0 +1,123 @@
1
+ module Iron
2
+ module Oauth
3
+ class Token < ApplicationRecord
4
+ self.table_name = "iron_oauth_tokens"
5
+
6
+ Issued = Data.define(:access_token, :refresh_token, :expires_in, :scope)
7
+
8
+ REFRESH_RETRY_GRACE = 30.seconds
9
+
10
+ belongs_to :client
11
+ belongs_to :user, class_name: "Iron::User"
12
+
13
+ def self.issue!(client:, user:, scopes:, audience:, family_id: Secret.generate, access_ttl: 2.hours, refresh_ttl: 30.days)
14
+ access_token = Secret.generate
15
+ refresh_token = Secret.generate
16
+ create!(
17
+ client: client,
18
+ user: user,
19
+ scopes: scopes,
20
+ audience: audience,
21
+ family_id: family_id,
22
+ access_token_digest: Secret.digest(access_token),
23
+ refresh_token_digest: Secret.digest(refresh_token),
24
+ expires_at: access_ttl.from_now,
25
+ refresh_expires_at: refresh_ttl.from_now
26
+ )
27
+ Issued.new(
28
+ access_token: access_token,
29
+ refresh_token: refresh_token,
30
+ expires_in: access_ttl.to_i,
31
+ scope: scopes
32
+ )
33
+ end
34
+
35
+ def self.authenticate(access_token)
36
+ token = find_by(access_token_digest: Secret.digest(access_token))
37
+ return unless token&.active?
38
+
39
+ token.record_use
40
+ token
41
+ end
42
+
43
+ # Rotates a refresh token to a fresh one, keeping at most a single active
44
+ # token per family. Only a lost-response retry may redo an exchange: the
45
+ # token's revocation must be the family's newest, still inside the grace
46
+ # window, and the minted successor never used — the one shape a client
47
+ # retrying a dropped response can produce. Every other reuse is a replay
48
+ # and burns the whole lineage (RFC 9700 §4.14.2), so a stolen refresh
49
+ # token cannot quietly take over the session.
50
+ #
51
+ # Every exchange in a family serializes on the same anchor row, so a
52
+ # retry and a rotation can't interleave into two live tokens; a partial
53
+ # unique index is the database backstop.
54
+ def self.exchange_refresh(refresh_token, client:)
55
+ current = find_by(refresh_token_digest: Secret.digest(refresh_token), client: client)
56
+ return unless current
57
+
58
+ issued = nil
59
+ family_anchor(current.family_id).with_lock do
60
+ current.reload
61
+ if !current.refresh_active_window?
62
+ issued = nil
63
+ elsif current.revoked_at.nil? || current.lost_response_retry?
64
+ current.revoke_family!
65
+ issued = issue!(client: client, user: current.user, scopes: current.scopes, audience: current.audience, family_id: current.family_id)
66
+ else
67
+ current.revoke_family!
68
+ end
69
+ end
70
+ issued
71
+ end
72
+
73
+ def self.family_anchor(family_id)
74
+ where(family_id: family_id).order(:id).first
75
+ end
76
+
77
+ def revoke_family!
78
+ self.class.where(family_id: family_id, revoked_at: nil).update_all(revoked_at: Time.current)
79
+ end
80
+
81
+ def revoke!
82
+ update!(revoked_at: Time.current)
83
+ end
84
+
85
+ def record_use
86
+ update_column(:used_at, Time.current) if used_at.nil?
87
+ end
88
+
89
+ def active?
90
+ revoked_at.nil? && expires_at.future?
91
+ end
92
+
93
+ def refresh_active_window?
94
+ refresh_expires_at&.future? || false
95
+ end
96
+
97
+ def lost_response_retry?
98
+ revoked_within_grace? && newest_family_revocation? && successor_untouched?
99
+ end
100
+
101
+ def revoked_within_grace?
102
+ revoked_at? && revoked_at > REFRESH_RETRY_GRACE.ago
103
+ end
104
+
105
+ def newest_family_revocation?
106
+ family_tokens.where("revoked_at > ?", revoked_at).none?
107
+ end
108
+
109
+ def successor_untouched?
110
+ successor = family_tokens.find_by(revoked_at: nil)
111
+ successor.present? && successor.used_at.nil?
112
+ end
113
+
114
+ def family_tokens
115
+ self.class.where(family_id: family_id)
116
+ end
117
+
118
+ def scopes_include?(scope)
119
+ scopes.to_s.split.include?(scope)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,82 @@
1
+ require "resolv"
2
+ require "ipaddr"
3
+
4
+ module Iron
5
+ module SsrfProtection
6
+ extend self
7
+
8
+ DNS_RESOLUTION_TIMEOUT = 2
9
+
10
+ # 2000::/3 is the only IPv6 block IANA has delegated as global unicast, so
11
+ # anything outside it (unspecified, loopback, link-local, ULA, multicast,
12
+ # translation, discard) is non-global by construction. Within it, a few
13
+ # sub-ranges are still reserved and must be denied explicitly.
14
+ GLOBAL_UNICAST_V6 = IPAddr.new("2000::/3")
15
+
16
+ DISALLOWED_RANGES = [
17
+ IPAddr.new("0.0.0.0/8"), # "This" network / unspecified (RFC1122)
18
+ IPAddr.new("100.64.0.0/10"), # Carrier-grade NAT (RFC6598)
19
+ IPAddr.new("192.0.0.0/24"), # IETF protocol assignments (RFC6890)
20
+ IPAddr.new("192.0.2.0/24"), # Documentation TEST-NET-1 (RFC5737)
21
+ IPAddr.new("198.18.0.0/15"), # Benchmark testing (RFC2544)
22
+ IPAddr.new("198.51.100.0/24"), # Documentation TEST-NET-2 (RFC5737)
23
+ IPAddr.new("203.0.113.0/24"), # Documentation TEST-NET-3 (RFC5737)
24
+ IPAddr.new("192.88.99.0/24"), # 6to4 relay anycast (RFC7526)
25
+ IPAddr.new("224.0.0.0/4"), # IPv4 multicast (RFC5771)
26
+ IPAddr.new("240.0.0.0/4"), # Reserved (RFC1112)
27
+ IPAddr.new("2001::/23"), # IETF protocol assignments — non-global (Teredo, ORCHID, benchmarking)
28
+ IPAddr.new("2001:db8::/32"), # Documentation (RFC3849)
29
+ IPAddr.new("2002::/16"), # 6to4 (RFC3056) — non-global
30
+ IPAddr.new("3fff::/20") # Documentation (RFC9637)
31
+ ].freeze
32
+
33
+ def public_host?(hostname)
34
+ public_addresses(hostname).any?
35
+ end
36
+
37
+ # Resolves a hostname and returns its addresses only when every one of them
38
+ # is public, so a caller can connect to a vetted IP without re-resolving.
39
+ def public_addresses(hostname)
40
+ addresses = resolve(hostname)
41
+ return [] if addresses.empty? || addresses.any? { |ip| blocked?(ip) }
42
+
43
+ addresses.map(&:to_s)
44
+ end
45
+
46
+ def blocked?(ip)
47
+ ip = IPAddr.new(ip.to_s) unless ip.is_a?(IPAddr)
48
+
49
+ return true if ip.ipv6? && !GLOBAL_UNICAST_V6.include?(ip)
50
+
51
+ ip.private? ||
52
+ ip.loopback? ||
53
+ ip.link_local? ||
54
+ ip.ipv4_mapped? ||
55
+ ip.ipv4_compat? ||
56
+ DISALLOWED_RANGES.any? { |range| range.include?(ip) }
57
+ rescue IPAddr::Error
58
+ true
59
+ end
60
+
61
+ private
62
+
63
+ def resolve(hostname)
64
+ return [] if hostname.blank?
65
+
66
+ literal = ip_literal(hostname)
67
+ return [ literal ] if literal
68
+
69
+ Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns|
70
+ dns.getaddresses(hostname).map { |address| IPAddr.new(address.to_s) }
71
+ end
72
+ rescue Resolv::ResolvError, IPAddr::Error
73
+ []
74
+ end
75
+
76
+ def ip_literal(hostname)
77
+ IPAddr.new(hostname)
78
+ rescue IPAddr::Error
79
+ nil
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,5 @@
1
+ <div class="monogram flex size-12 items-center justify-center rounded-xl">
2
+ <span class="text-lg/none font-bold text-white/70 select-none"><%= Iron::Current.account&.name&.first&.upcase || "I" %></span>
3
+ </div>
4
+ <div class="h-px w-12 bg-gradient-to-r from-transparent via-stone-300 to-transparent dark:via-stone-700/50"></div>
5
+ <span class="text-[11px]/6 font-semibold uppercase tracking-widest text-stone-500"><%= Iron::Current.account&.name&.upcase || "IRON" %></span>
@@ -0,0 +1,31 @@
1
+ <% content_for :title, t(".title") %>
2
+
3
+ <div class="w-full max-w-sm">
4
+ <div class="flex flex-col items-center gap-4 mb-8">
5
+ <%= render "iron/authentication/brandmark" %>
6
+ <h1 class="text-base font-semibold text-stone-950 dark:text-white"><%= t(".heading") %></h1>
7
+ <p class="text-center text-sm text-stone-500"><%= t(".description", client: @client.name) %></p>
8
+ </div>
9
+
10
+ <div class="rounded-lg bg-white p-4 ring-1 ring-stone-200 dark:bg-white/5 dark:ring-white/10">
11
+ <p class="text-xs font-semibold uppercase tracking-wide text-stone-400"><%= t(".permissions") %></p>
12
+ <p class="mt-1 text-sm text-stone-700 dark:text-stone-200"><%= t(".scope_content") %></p>
13
+
14
+ <p class="mt-4 text-xs font-semibold uppercase tracking-wide text-stone-400"><%= t(".destination") %></p>
15
+ <p class="mt-1 text-sm break-all text-stone-700 dark:text-stone-200"><%= @redirect_uri %></p>
16
+ </div>
17
+
18
+ <%= form_with url: "/oauth/authorize", method: :post, class: "mt-6 grid grid-cols-2 gap-3" do |form| %>
19
+ <%= form.hidden_field :client_id, value: params[:client_id] %>
20
+ <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
21
+ <%= form.hidden_field :response_type, value: params[:response_type] %>
22
+ <%= form.hidden_field :scope, value: @scopes %>
23
+ <%= form.hidden_field :state, value: params[:state] %>
24
+ <%= form.hidden_field :code_challenge, value: @code_challenge %>
25
+ <%= form.hidden_field :code_challenge_method, value: @code_challenge_method %>
26
+ <%= form.hidden_field :resource, value: @resource %>
27
+
28
+ <%= form.button t(".deny"), name: "decision", value: "deny", class: "button-secondary w-full" %>
29
+ <%= form.button t(".approve"), name: "decision", value: "approve", class: "button-primary w-full" %>
30
+ <% end %>
31
+ </div>
@@ -1,10 +1,6 @@
1
1
  <div class="w-full max-w-sm">
2
2
  <div class="flex flex-col items-center gap-4 mb-8">
3
- <div class="monogram flex size-12 items-center justify-center rounded-xl">
4
- <span class="text-lg/none font-bold text-white/70 select-none"><%= Iron::Current.account&.name&.first&.upcase || "I" %></span>
5
- </div>
6
- <div class="h-px w-12 bg-gradient-to-r from-transparent via-stone-300 to-transparent dark:via-stone-700/50"></div>
7
- <span class="text-[11px]/6 font-semibold uppercase tracking-widest text-stone-500"><%= Iron::Current.account&.name&.upcase || "IRON" %></span>
3
+ <%= render "iron/authentication/brandmark" %>
8
4
  <p class="text-sm text-stone-500"><%= t(".title") %></p>
9
5
  </div>
10
6
 
@@ -42,5 +42,8 @@
42
42
  <dialog class="drawer" data-controller="drawer" closedby="any">
43
43
  <%= turbo_frame_tag :drawer, data: { drawer_target: "frame" } %>
44
44
  </dialog>
45
+
46
+ <%# Host-app opt-in dev tooling: the dummy app defines this partial in development. %>
47
+ <%= render "layouts/dev_tools" if Rails.env.development? && lookup_context.exists?("layouts/dev_tools", [], true) %>
45
48
  </body>
46
49
  </html>
@@ -20,5 +20,8 @@
20
20
  </div>
21
21
 
22
22
  <%= render "layouts/iron/toast" %>
23
+
24
+ <%# Host-app opt-in dev tooling: the dummy app defines this partial in development. %>
25
+ <%= render "layouts/dev_tools" if Rails.env.development? && lookup_context.exists?("layouts/dev_tools", [], true) %>
23
26
  </body>
24
27
  </html>
@@ -348,6 +348,18 @@ en:
348
348
  api_error: "is managed in code and cannot be edited on this environment"
349
349
  badge: "Managed in code"
350
350
  details: "Content types, blocks, fields, and locales are defined in db/cms/schema.json and applied on deploy. Editing is disabled on this environment — change the schema in development, commit it, and run bin/rails iron:schema:apply."
351
+ oauth:
352
+ authorizations:
353
+ invalid_request: "We couldn't verify this authorization request."
354
+ new:
355
+ title: "Authorize access"
356
+ heading: "Authorize access"
357
+ description: "%{client} wants to connect to your account."
358
+ permissions: "This will allow it to"
359
+ scope_content: "Read and edit content"
360
+ destination: "Authorization codes will be sent to"
361
+ approve: "Approve"
362
+ deny: "Deny"
351
363
  sessions:
352
364
  new:
353
365
  email_address: "Email address"
@@ -387,6 +387,18 @@ it:
387
387
  api_error: "è gestito nel codice e non può essere modificato in questo ambiente"
388
388
  badge: "Gestito nel codice"
389
389
  details: "Tipi di contenuto, blocchi, campi e lingue sono definiti in db/cms/schema.json e applicati al deploy. La modifica è disabilitata in questo ambiente — modifica lo schema in sviluppo, esegui il commit e lancia bin/rails iron:schema:apply."
390
+ oauth:
391
+ authorizations:
392
+ invalid_request: "Non siamo riusciti a verificare questa richiesta di autorizzazione."
393
+ new:
394
+ title: "Autorizza l'accesso"
395
+ heading: "Autorizza l'accesso"
396
+ description: "%{client} vuole connettersi al tuo account."
397
+ permissions: "Questo gli permetterà di"
398
+ scope_content: "Leggere e modificare i contenuti"
399
+ destination: "I codici di autorizzazione verranno inviati a"
400
+ approve: "Autorizza"
401
+ deny: "Rifiuta"
390
402
  sessions:
391
403
  new:
392
404
  email_address: "Indirizzo email"
data/config/routes.rb CHANGED
@@ -28,6 +28,7 @@ Iron::Engine.routes.draw do
28
28
 
29
29
  namespace :api, defaults: { format: :json } do
30
30
  get "openapi.json", to: "openapi#show", as: :openapi
31
+ match "mcp", to: "mcp#handle", via: %i[ get post ], as: :mcp
31
32
  post "uploads", to: "uploads#create"
32
33
  get "content/:handle", to: "content#index", as: :content_index
33
34
  post "content/:handle", to: "content#create", as: :content_create
@@ -0,0 +1,50 @@
1
+ class CreateIronOauthTables < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :iron_oauth_clients do |t|
4
+ t.string :uid, null: false
5
+ t.string :name, null: false
6
+ t.text :redirect_uris, null: false
7
+ t.string :scopes, null: false, default: "mcp:access"
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :iron_oauth_clients, :uid, unique: true
12
+
13
+ create_table :iron_oauth_grants do |t|
14
+ t.references :client, null: false, foreign_key: { to_table: :iron_oauth_clients }
15
+ t.references :user, null: false, foreign_key: { to_table: :iron_users }
16
+ t.string :code_digest, null: false
17
+ t.text :redirect_uri, null: false
18
+ t.string :scopes, null: false, default: "mcp:access"
19
+ t.string :code_challenge, null: false
20
+ t.string :code_challenge_method, null: false, default: "S256"
21
+ t.string :resource, null: false
22
+ t.datetime :expires_at, null: false
23
+ t.datetime :redeemed_at
24
+ t.timestamps
25
+ end
26
+
27
+ add_index :iron_oauth_grants, :code_digest, unique: true
28
+
29
+ create_table :iron_oauth_tokens do |t|
30
+ t.references :client, null: false, foreign_key: { to_table: :iron_oauth_clients }
31
+ t.references :user, null: false, foreign_key: { to_table: :iron_users }
32
+ t.string :family_id, null: false
33
+ t.string :access_token_digest, null: false
34
+ t.string :refresh_token_digest
35
+ t.string :scopes, null: false, default: "mcp:access"
36
+ t.string :audience, null: false
37
+ t.datetime :expires_at, null: false
38
+ t.datetime :refresh_expires_at, null: false
39
+ t.datetime :used_at
40
+ t.datetime :revoked_at
41
+ t.timestamps
42
+ end
43
+
44
+ add_index :iron_oauth_tokens, :access_token_digest, unique: true
45
+ add_index :iron_oauth_tokens, :refresh_token_digest, unique: true
46
+ add_index :iron_oauth_tokens, :family_id
47
+ add_index :iron_oauth_tokens, :family_id, unique: true, where: "revoked_at IS NULL",
48
+ name: "index_iron_oauth_tokens_active_per_family"
49
+ end
50
+ end
@@ -8,6 +8,7 @@ module Iron
8
8
  MOUNT_PATH = "/admin".freeze
9
9
  MOUNT_LINE = %(mount Iron::Engine => "#{MOUNT_PATH}").freeze
10
10
  SEEDS_MARKER = "# == Iron CMS admin ==".freeze
11
+ OAUTH_ROUTES_MARKER = "iron/oauth/metadata#protected_resource".freeze
11
12
 
12
13
  class_option :skip_db, type: :boolean, default: false,
13
14
  desc: "Skip the database steps (migrate, seed, schema apply)"
@@ -16,6 +17,12 @@ module Iron
16
17
  route MOUNT_LINE unless routes_include?(MOUNT_LINE)
17
18
  end
18
19
 
20
+ def wire_oauth_routes
21
+ return if routes_include?(OAUTH_ROUTES_MARKER)
22
+
23
+ inject_into_file routes_path, "\n#{oauth_routes_block}", after: /#{Regexp.escape(MOUNT_LINE)}\n/
24
+ end
25
+
19
26
  def install_active_storage
20
27
  return if skip_db_steps?
21
28
  return if migration_present?("create_active_storage_tables.active_storage.rb")
@@ -76,6 +83,7 @@ module Iron
76
83
  say " - db/cms/schema.json (minimal CMS schema skeleton — edit, then bin/rails iron:schema:apply)"
77
84
  say " - db/seeds.rb provisions the first administrator (run by db:seed)"
78
85
  say " - lib/tasks/iron_release.rake applies the CMS schema during db:prepare"
86
+ say " - config/routes.rb OAuth endpoints power the MCP endpoint at #{MOUNT_PATH}/api/mcp"
79
87
  say " - .claude/skills/iron-cms/SKILL.md (the Iron CMS workflow for coding agents)"
80
88
  say " - app/controllers/pages_controller.rb renders published content"
81
89
  say "\nSet IRON_SEED_EMAIL and IRON_SEED_PASSWORD outside local environments, or seeding fails fast."
@@ -86,6 +94,17 @@ module Iron
86
94
 
87
95
  private
88
96
 
97
+ def oauth_routes_block
98
+ <<~ROUTES.gsub(/^(?=.)/, " ")
99
+ get "/.well-known/oauth-protected-resource", to: "iron/oauth/metadata#protected_resource"
100
+ get "/.well-known/oauth-authorization-server", to: "iron/oauth/metadata#authorization_server"
101
+ get "/oauth/authorize", to: "iron/oauth/authorizations#new"
102
+ post "/oauth/authorize", to: "iron/oauth/authorizations#create"
103
+ post "/oauth/token", to: "iron/oauth/tokens#create"
104
+ post "/oauth/register", to: "iron/oauth/registrations#create"
105
+ ROUTES
106
+ end
107
+
89
108
  def skip_db_steps?
90
109
  options[:skip_db] || options[:pretend]
91
110
  end
data/lib/iron/engine.rb CHANGED
@@ -8,11 +8,13 @@ require "lexxy"
8
8
  require "bcrypt"
9
9
  require "ostruct"
10
10
  require "rqrcode"
11
+ require "mcp"
11
12
  require "iron/lexorank"
12
13
  require "iron/sdk"
13
14
  require "pagy"
14
15
  require "iron/routing"
15
16
  require "iron/image_analyzer"
17
+ require "iron/oauth_body_limit"
16
18
 
17
19
  module Iron
18
20
  class Engine < ::Rails::Engine
@@ -32,6 +34,10 @@ module Iron
32
34
  ActionDispatch::Routing::Mapper.include Iron::Routing
33
35
  end
34
36
 
37
+ initializer "iron.oauth_body_limit" do |app|
38
+ app.middleware.insert 0, Iron::OauthBodyLimit
39
+ end
40
+
35
41
  initializer "iron.active_storage_analyzers" do |app|
36
42
  app.config.active_storage.analyzers.prepend Iron::ImageAnalyzer
37
43
  end
@@ -0,0 +1,52 @@
1
+ require "stringio"
2
+
3
+ module Iron
4
+ # Rack::MethodOverride parses form-encoded POST bodies before the request
5
+ # reaches any controller, so the public OAuth endpoints installed at the
6
+ # host root get their size ceiling from the very front of the middleware
7
+ # stack, ahead of anything that might read the body.
8
+ class OauthBodyLimit
9
+ PATHS = %w[ /oauth/authorize /oauth/token /oauth/register ].freeze
10
+ MAX_BYTES = 64 * 1024
11
+
12
+ def initialize(app)
13
+ @app = app
14
+ end
15
+
16
+ def call(env)
17
+ if guarded?(env) && oversized?(env)
18
+ [ 413, { "content-length" => "0" }, [] ]
19
+ else
20
+ @app.call(env)
21
+ end
22
+ end
23
+
24
+ private
25
+ def guarded?(env)
26
+ env["REQUEST_METHOD"] == "POST" && PATHS.include?(normalized_path(env["PATH_INFO"]))
27
+ end
28
+
29
+ # Rails resolves /oauth/token.json and /oauth/token/// to the same route,
30
+ # so the ceiling has to match those variants too — not just the literal
31
+ # paths — or an oversized body slips past on a format suffix or trailing
32
+ # slashes.
33
+ def normalized_path(path)
34
+ path = path.sub(%r{/+\z}, "")
35
+ path.sub(%r{\.[^./]+\z}, "")
36
+ end
37
+
38
+ def oversized?(env)
39
+ declared = env["CONTENT_LENGTH"]
40
+ return declared.to_i > MAX_BYTES if declared
41
+
42
+ buffer_capped(env)
43
+ end
44
+
45
+ def buffer_capped(env)
46
+ body = env["rack.input"].read(MAX_BYTES + 1).to_s
47
+ env["rack.input"] = StringIO.new(body)
48
+ env["CONTENT_LENGTH"] = body.bytesize.to_s
49
+ body.bytesize > MAX_BYTES
50
+ end
51
+ end
52
+ end
data/lib/iron/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Iron
2
- VERSION = "0.18.2"
2
+ VERSION = "0.19.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: iron-cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.2
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Massimo De Marchi
@@ -279,6 +279,20 @@ dependencies:
279
279
  - - "~>"
280
280
  - !ruby/object:Gem::Version
281
281
  version: '2.13'
282
+ - !ruby/object:Gem::Dependency
283
+ name: mcp
284
+ requirement: !ruby/object:Gem::Requirement
285
+ requirements:
286
+ - - "~>"
287
+ - !ruby/object:Gem::Version
288
+ version: '0.20'
289
+ type: :runtime
290
+ prerelease: false
291
+ version_requirements: !ruby/object:Gem::Requirement
292
+ requirements:
293
+ - - "~>"
294
+ - !ruby/object:Gem::Version
295
+ version: '0.20'
282
296
  description: A Rails engine for managing and publishing content with a flexible schema.
283
297
  email:
284
298
  - massimo@inkofpixel.com
@@ -326,6 +340,7 @@ files:
326
340
  - app/controllers/concerns/iron/api/token_authentication.rb
327
341
  - app/controllers/concerns/iron/authentication.rb
328
342
  - app/controllers/concerns/iron/authorization.rb
343
+ - app/controllers/concerns/iron/bounded_request_body.rb
329
344
  - app/controllers/concerns/iron/locale_aware.rb
330
345
  - app/controllers/concerns/iron/schema_editing.rb
331
346
  - app/controllers/concerns/iron/web_page.rb
@@ -333,6 +348,7 @@ files:
333
348
  - app/controllers/iron/account/imports_controller.rb
334
349
  - app/controllers/iron/api/base_controller.rb
335
350
  - app/controllers/iron/api/content_controller.rb
351
+ - app/controllers/iron/api/mcp_controller.rb
336
352
  - app/controllers/iron/api/openapi_controller.rb
337
353
  - app/controllers/iron/api/schema/base_controller.rb
338
354
  - app/controllers/iron/api/schema/block_definitions_controller.rb
@@ -353,6 +369,10 @@ files:
353
369
  - app/controllers/iron/home_controller.rb
354
370
  - app/controllers/iron/icons_controller.rb
355
371
  - app/controllers/iron/locales_controller.rb
372
+ - app/controllers/iron/oauth/authorizations_controller.rb
373
+ - app/controllers/iron/oauth/metadata_controller.rb
374
+ - app/controllers/iron/oauth/registrations_controller.rb
375
+ - app/controllers/iron/oauth/tokens_controller.rb
356
376
  - app/controllers/iron/passwords_controller.rb
357
377
  - app/controllers/iron/qr_codes_controller.rb
358
378
  - app/controllers/iron/references_controller.rb
@@ -402,6 +422,22 @@ files:
402
422
  - app/jobs/iron/reindex_search_job.rb
403
423
  - app/mailers/iron/application_mailer.rb
404
424
  - app/mailers/iron/passwords_mailer.rb
425
+ - app/mcp/iron/mcp/error.rb
426
+ - app/mcp/iron/mcp/forbidden.rb
427
+ - app/mcp/iron/mcp/invalid_request.rb
428
+ - app/mcp/iron/mcp/paginator.rb
429
+ - app/mcp/iron/mcp/server.rb
430
+ - app/mcp/iron/mcp/tool_context.rb
431
+ - app/mcp/iron/mcp/tools/base.rb
432
+ - app/mcp/iron/mcp/tools/create_entry.rb
433
+ - app/mcp/iron/mcp/tools/delete_entry.rb
434
+ - app/mcp/iron/mcp/tools/describe_schema.rb
435
+ - app/mcp/iron/mcp/tools/get_entry.rb
436
+ - app/mcp/iron/mcp/tools/list_content_types.rb
437
+ - app/mcp/iron/mcp/tools/list_entries.rb
438
+ - app/mcp/iron/mcp/tools/search.rb
439
+ - app/mcp/iron/mcp/tools/update_entry.rb
440
+ - app/mcp/iron/mcp/tools/upload_asset.rb
405
441
  - app/models/concerns/iron/broadcastable.rb
406
442
  - app/models/concerns/iron/instance_scoped.rb
407
443
  - app/models/concerns/iron/processable.rb
@@ -415,6 +451,7 @@ files:
415
451
  - app/models/iron/block_definition/exportable.rb
416
452
  - app/models/iron/block_definition/importable.rb
417
453
  - app/models/iron/content.rb
454
+ - app/models/iron/content/download_budget.rb
418
455
  - app/models/iron/content_type.rb
419
456
  - app/models/iron/content_type/exportable.rb
420
457
  - app/models/iron/content_type/field_queryable.rb
@@ -474,6 +511,12 @@ files:
474
511
  - app/models/iron/importer.rb
475
512
  - app/models/iron/integration.rb
476
513
  - app/models/iron/locale.rb
514
+ - app/models/iron/oauth/client.rb
515
+ - app/models/iron/oauth/grant.rb
516
+ - app/models/iron/oauth/resource.rb
517
+ - app/models/iron/oauth/scope.rb
518
+ - app/models/iron/oauth/secret.rb
519
+ - app/models/iron/oauth/token.rb
477
520
  - app/models/iron/person.rb
478
521
  - app/models/iron/person/transferable.rb
479
522
  - app/models/iron/qr_code_link.rb
@@ -487,6 +530,7 @@ files:
487
530
  - app/models/iron/search_record/fts.rb
488
531
  - app/models/iron/seed.rb
489
532
  - app/models/iron/session.rb
533
+ - app/models/iron/ssrf_protection.rb
490
534
  - app/models/iron/system.rb
491
535
  - app/models/iron/user.rb
492
536
  - app/models/iron/user/deactivatable.rb
@@ -525,6 +569,7 @@ files:
525
569
  - app/views/iron/api/schema/locales/index.json.jbuilder
526
570
  - app/views/iron/api/schema/locales/show.json.jbuilder
527
571
  - app/views/iron/api/search/show.json.jbuilder
572
+ - app/views/iron/authentication/_brandmark.html.erb
528
573
  - app/views/iron/block_definitions/_block_definition.html.erb
529
574
  - app/views/iron/block_definitions/_empty_state.html.erb
530
575
  - app/views/iron/block_definitions/_form.html.erb
@@ -595,6 +640,7 @@ files:
595
640
  - app/views/iron/locales/edit.html.erb
596
641
  - app/views/iron/locales/index.html.erb
597
642
  - app/views/iron/locales/new.html.erb
643
+ - app/views/iron/oauth/authorizations/new.html.erb
598
644
  - app/views/iron/passwords/edit.html.erb
599
645
  - app/views/iron/passwords/new.html.erb
600
646
  - app/views/iron/passwords_mailer/reset.html.erb
@@ -661,6 +707,7 @@ files:
661
707
  - db/migrate/20260215222735_remove_legacy_auth_from_iron_users.rb
662
708
  - db/migrate/20260221231832_create_iron_integrations.rb
663
709
  - db/migrate/20260612131538_create_iron_systems.rb
710
+ - db/migrate/20260626090000_create_iron_oauth_tables.rb
664
711
  - exe/iron
665
712
  - lib/generators/iron/agents/agents_generator.rb
666
713
  - lib/generators/iron/agents/templates/AGENTS.md
@@ -686,6 +733,7 @@ files:
686
733
  - lib/iron/lexorank/rankable.rb
687
734
  - lib/iron/lexorank/rebalance_rank_job.rb
688
735
  - lib/iron/lexorank/utils.rb
736
+ - lib/iron/oauth_body_limit.rb
689
737
  - lib/iron/published_page_constraint.rb
690
738
  - lib/iron/routing.rb
691
739
  - lib/iron/sdk.rb
@@ -716,7 +764,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
716
764
  - !ruby/object:Gem::Version
717
765
  version: '0'
718
766
  requirements: []
719
- rubygems_version: 3.6.9
767
+ rubygems_version: 4.0.16
720
768
  specification_version: 4
721
769
  summary: Iron is a Ruby on Rails engine for managing and publishing content.
722
770
  test_files: []