Skip to content

Deep-copy ERROR_SCHEMA/PROBLEM_SCHEMA before handing them out - #78

Merged
VSN2015 merged 1 commit into
masterfrom
fix/deep-frozen-error-schemas
Sep 29, 2026
Merged

VSN2015 merged 1 commit into
masterfrom
fix/deep-frozen-error-schemas

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 28, 2026

Copy link
Copy Markdown
Owner

The bug

Permittable::OpenAPI::ERROR_SCHEMA and PROBLEM_SCHEMA are .freezed, but Ruby's .freeze is shallow — it only freezes the top-level Hash, not the Hashes nested inside it. .components (and, through it, .document) handed these constants out by direct reference:

def error_schema
  problem_format? ? PROBLEM_SCHEMA : ERROR_SCHEMA
end

So a caller mutating a nested level of ITS OWN generated document — an easy mistake, since everything else about the returned document is caller-owned data — silently succeeded with no FrozenError, and permanently corrupted the shared constant for every document generated for the rest of the process:

doc = Permittable::OpenAPI.document(...)
doc["components"]["schemas"]["PermittableInvalidParameters"]["properties"]["error"]["properties"]["message"]["description"] = "MUTATED"
# no error raised, and now:
Permittable::OpenAPI::ERROR_SCHEMA["properties"]["error"]["properties"]["message"]["description"] # => "MUTATED", forever

In a long-lived process — a Rails console, or an admin endpoint that regenerates the OpenAPI doc on demand — one caller's mutation leaks into every other caller's document from then on.

The fix

error_schema now returns .deep_dup of the constant instead of the constant itself:

def error_schema
  (problem_format? ? PROBLEM_SCHEMA : ERROR_SCHEMA).deep_dup
end

deep_dup is ActiveSupport's existing recursive-copy helper — already required by lib/permittable.rb and already the pattern this gem uses to copy authored default:/example: values before freezing them (see lib/permittable.rb), so this reuses rather than duplicates that mechanism. VIOLATION_SCHEMA, nested inside both ERROR_SCHEMA and PROBLEM_SCHEMA, is copied along with them since deep_dup recurses through nested Hashes/Arrays. lib/permittable/json_schema.rb doesn't need an equivalent change here — it builds its schema Hashes fresh on every call rather than freezing and sharing a constant — so this fix is scoped to open_api.rb only.

Verification

  • New test written first (spec/open_api_spec.rb), confirmed to fail before the fix (mutating a returned document's nested schema silently succeeded and leaked into a freshly generated document) and pass after
  • bundle exec rspec — 852 examples, 0 failures
  • bundle exec rubocop lib/permittable/open_api.rb spec/open_api_spec.rb — no offenses

🤖 Generated with Claude Code

.freeze on these constants is shallow — only the top-level Hash is
frozen, not the Hashes nested inside it — but Permittable::OpenAPI
handed the constants out by direct reference through .components and
.document. A caller mutating a nested level of ITS document (an easy
mistake, since the rest of the document is caller-owned data) silently
succeeded with no FrozenError and permanently corrupted the shared
constant for every document generated for the rest of the process,
e.g. a long-lived Rails console or admin endpoint regenerating docs.

error_schema now returns .deep_dup of the constant, the same
ActiveSupport helper the contract registry already uses to copy
authored default:/example: values before freezing them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@VSN2015
VSN2015 merged commit d6284a3 into master Sep 29, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant