From d53bd57cfa46328732dba1bacb683a712d3af4b5 Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Thu, 27 Aug 2026 11:32:47 -0700 Subject: [PATCH 1/6] Fail-closed json/plain deserialize against Oj object-mode gadgets. SECBUGS-174: reject arbitrary ^o instantiation while keeping custody Request/Response payloads and error serialization v2 working. Co-authored-by: Cursor --- .github/workflows/tests.yml | 4 +- CHANGELOG.md | 4 + lib/temporal/errors.rb | 2 + lib/temporal/json.rb | 124 +++++++++++++- lib/temporal/version.rb | 2 +- .../connection/converter/payload/json_spec.rb | 4 + spec/unit/lib/temporal/json.rb | 152 ++++++++++++++++++ 7 files changed, 288 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2456cb5d9..9d7546101 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,9 +2,9 @@ name: Tests on: push: - branches: [ "master" ] + branches: [ "master", "transfers-master" ] pull_request: - branches: [ "master" ] + branches: [ "master", "transfers-master" ] jobs: test_gem: diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9849848..8fadf19b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.0.7 + +- Fail-closed `json/plain` deserialize: Oj `mode: :object` no longer instantiates arbitrary classes. Instances are reconstituted only for loaded `::Request` / `::Response` types, loaded Exception subclasses (error serialization v2), and `Temporal::JSON.allow_class`. Class references (`^c`) require a loaded constant. Encode and the `json/plain` encoding name are unchanged (SECBUGS-174). + ## 0.0.6 - Defer gRPC loading via autoload for fork safety diff --git a/lib/temporal/errors.rb b/lib/temporal/errors.rb index 7aa114056..a6090f734 100644 --- a/lib/temporal/errors.rb +++ b/lib/temporal/errors.rb @@ -2,6 +2,8 @@ module Temporal # Superclass for all Temporal errors class Error < StandardError; end + class JSONDisallowedClassError < Error; end + # Superclass for errors specific to Temporal worker itself class InternalError < Error; end diff --git a/lib/temporal/json.rb b/lib/temporal/json.rb index 9784d1ead..e157a9741 100644 --- a/lib/temporal/json.rb +++ b/lib/temporal/json.rb @@ -1,5 +1,8 @@ # Helper class for serializing/deserializing JSON +require 'json' require 'oj' +require 'set' +require 'temporal/errors' module Temporal module JSON @@ -9,12 +12,131 @@ module JSON float_precision: 0 }.freeze + # ^o / ^O allocate instances. ^c / ^C look up Class objects (error serialization v2). + INSTANCE_DIRECTIVE_KEYS = %w[^o ^O].freeze + CLASS_REFERENCE_DIRECTIVE_KEYS = %w[^c ^C].freeze + STRUCT_DIRECTIVE_KEYS = %w[^u].freeze + ALLOWED_CLASS_SUFFIXES = %w[::Request ::Response].freeze + + ALLOWED_CLASSES = Set.new + ALLOWED_CLASSES_MUTEX = Mutex.new + private_constant :ALLOWED_CLASSES, :ALLOWED_CLASSES_MUTEX + def self.serialize(value) Oj.dump(value, OJ_OPTIONS) end + # SECBUGS-174: Oj mode: :object instantiates any constant named in ^o. + # Walk a JSON.parse tree first and only then Oj.load the original bytes so + # symbol keys and ^t stay compatible with encode. def self.deserialize(value) - Oj.load(value.to_s, OJ_OPTIONS) + return nil if value.nil? + + raw = value.to_s + return nil if raw.empty? + + assert_safe!(::JSON.parse(raw)) + Oj.load(raw, OJ_OPTIONS) + end + + def self.allow_class(name) + with_allowed_classes { |set| set.add(name.to_s) } + name.to_s + end + + def self.allowed_class_names + with_allowed_classes(&:dup) + end + + def self.with_allowed_classes + ALLOWED_CLASSES_MUTEX.synchronize { yield ALLOWED_CLASSES } + end + private_class_method :with_allowed_classes + + def self.assert_safe!(obj) + case obj + when Hash + STRUCT_DIRECTIVE_KEYS.each do |key| + next unless obj.key?(key) + + name = class_name_from_directive(obj[key]) + unless name && registered_class?(name) + raise Temporal::JSONDisallowedClassError, + "json/plain payload requested disallowed class #{obj[key].inspect}" + end + end + + INSTANCE_DIRECTIVE_KEYS.each do |key| + next unless obj.key?(key) + + name = class_name_from_directive(obj[key]) + unless name && allowed_instance_class?(name) + raise Temporal::JSONDisallowedClassError, + "json/plain payload requested disallowed class #{obj[key].inspect}" + end + end + + CLASS_REFERENCE_DIRECTIVE_KEYS.each do |key| + next unless obj.key?(key) + + name = class_name_from_directive(obj[key]) + unless name && allowed_class_reference?(name) + raise Temporal::JSONDisallowedClassError, + "json/plain payload requested disallowed class #{obj[key].inspect}" + end + end + + obj.each_value { |v| assert_safe!(v) } + when Array + obj.each { |v| assert_safe!(v) } + end + end + private_class_method :assert_safe! + + def self.class_name_from_directive(value) + case value + when String + value + when Array + value.first if value.first.is_a?(String) + end + end + private_class_method :class_name_from_directive + + def self.registered_class?(name) + with_allowed_classes { |set| set.include?(name) } + end + private_class_method :registered_class? + + def self.allowed_instance_class?(name) + return true if registered_class?(name) + return true if ALLOWED_CLASS_SUFFIXES.any? { |suffix| name.end_with?(suffix) } && resolve_constant(name) + + klass = resolve_constant(name) + klass.is_a?(Class) && klass <= Exception + end + private_class_method :allowed_instance_class? + + def self.allowed_class_reference?(name) + return true if registered_class?(name) + + resolve_constant(name).is_a?(Module) + end + private_class_method :allowed_class_reference? + + def self.resolve_constant(name) + parts = name.split('::') + parts.shift if parts.first.empty? + return nil if parts.empty? + + parts.reduce(Object) do |mod, part| + return nil unless mod.is_a?(Module) && mod.const_defined?(part, false) + + mod.const_get(part, false) + end + rescue NameError + nil end + private_class_method :resolve_constant end end diff --git a/lib/temporal/version.rb b/lib/temporal/version.rb index 50451bd9b..5bdc1ec74 100644 --- a/lib/temporal/version.rb +++ b/lib/temporal/version.rb @@ -1,3 +1,3 @@ module Temporal - VERSION = '0.0.6'.freeze + VERSION = '0.0.7'.freeze end diff --git a/spec/unit/lib/temporal/connection/converter/payload/json_spec.rb b/spec/unit/lib/temporal/connection/converter/payload/json_spec.rb index ebdb6ce4e..73278c781 100644 --- a/spec/unit/lib/temporal/connection/converter/payload/json_spec.rb +++ b/spec/unit/lib/temporal/connection/converter/payload/json_spec.rb @@ -3,6 +3,10 @@ describe Temporal::Connection::Converter::Payload::JSON do subject { described_class.new } + it 'keeps the json/plain encoding name' do + expect(subject.encoding).to eq('json/plain') + end + describe 'round trip' do it 'safely handles non-ASCII encodable UTF characters' do input = { 'one' => 'one', two: :two, ':three' => '☻' } diff --git a/spec/unit/lib/temporal/json.rb b/spec/unit/lib/temporal/json.rb index c2d00cd4d..7d1ac203a 100644 --- a/spec/unit/lib/temporal/json.rb +++ b/spec/unit/lib/temporal/json.rb @@ -1,9 +1,53 @@ require 'temporal/json' +module TemporalJSONSpecFixtures + module DummyActivity + class Request + attr_accessor :scope + + def initialize(scope: nil) + @scope = scope + end + end + + class Response + attr_accessor :count + + def initialize(count: nil) + @count = count + end + end + end + + class DummyWidget + attr_accessor :name + + def initialize(name: nil) + @name = name + end + end + + class DummyError < StandardError + attr_reader :code + + def initialize(message = nil, code: nil) + super(message) + @code = code + end + end +end + describe Temporal::JSON do let(:hash) { { 'one' => 'one', two: :two, ':three' => ':three' } } let(:json) { '{"one":"one",":two":":two","\u003athree":"\u003athree"}' } + around do |example| + snapshot = described_class.allowed_class_names + example.run + ensure + described_class.send(:with_allowed_classes) { |set| set.replace(snapshot) } + end + describe '.serialize' do it 'generates JSON string' do expect(described_class.serialize(hash)).to eq(json) @@ -22,5 +66,113 @@ it 'parses nil' do expect(described_class.deserialize(nil)).to eq(nil) end + + it 'round-trips Time via ^t' do + time = Time.at(1_700_000_000) + loaded = described_class.deserialize(described_class.serialize(time)) + + expect(loaded).to be_a(Time) + expect(loaded.to_i).to eq(time.to_i) + end + + it 'reconstitutes a loaded ::Request from Go-style ^o JSON' do + payload = '{"^o":"TemporalJSONSpecFixtures::DummyActivity::Request","scope":"to_sync"}' + loaded = described_class.deserialize(payload) + + expect(loaded).to be_a(TemporalJSONSpecFixtures::DummyActivity::Request) + expect(loaded.scope).to eq('to_sync') + end + + it 'round-trips a loaded ::Request through serialize' do + request = TemporalJSONSpecFixtures::DummyActivity::Request.new(scope: 'to_sync') + loaded = described_class.deserialize(described_class.serialize(request)) + + expect(loaded).to be_a(TemporalJSONSpecFixtures::DummyActivity::Request) + expect(loaded.scope).to eq('to_sync') + end + + it 'round-trips a loaded ::Response through serialize' do + response = TemporalJSONSpecFixtures::DummyActivity::Response.new(count: 3) + loaded = described_class.deserialize(described_class.serialize(response)) + + expect(loaded).to be_a(TemporalJSONSpecFixtures::DummyActivity::Response) + expect(loaded.count).to eq(3) + end + + it 'reconstitutes nested Request objects' do + payload = '{"inner":{"^o":"TemporalJSONSpecFixtures::DummyActivity::Request","scope":"nested"}}' + loaded = described_class.deserialize(payload) + + expect(loaded['inner']).to be_a(TemporalJSONSpecFixtures::DummyActivity::Request) + expect(loaded['inner'].scope).to eq('nested') + end + + it 'round-trips a loaded Exception subclass' do + error = TemporalJSONSpecFixtures::DummyError.new('boom', code: 7) + loaded = described_class.deserialize(described_class.serialize(error)) + + expect(loaded).to be_a(TemporalJSONSpecFixtures::DummyError) + expect(loaded.message).to eq('boom') + expect(loaded.code).to eq(7) + end + + it 'reconstitutes a loaded class reference via ^c' do + expect(described_class.deserialize('{"^c":"String"}')).to eq(String) + end + + it 'rejects ^c for an unloaded constant before Oj.load' do + expect(Oj).not_to receive(:load) + expect do + described_class.deserialize('{"^c":"DefinitelyNotAClassXYZ"}') + end.to raise_error(Temporal::JSONDisallowedClassError, /DefinitelyNotAClassXYZ/) + end + + it 'rejects Gem::Requirement gadget payloads before Oj.load' do + expect(Oj).not_to receive(:load) + expect do + described_class.deserialize('{"^o":"Gem::Requirement"}') + end.to raise_error(Temporal::JSONDisallowedClassError, /Gem::Requirement/) + end + + it 'rejects Kernel gadget payloads before Oj.load' do + expect(Oj).not_to receive(:load) + expect do + described_class.deserialize('{"^o":"Kernel"}') + end.to raise_error(Temporal::JSONDisallowedClassError, /Kernel/) + end + + it 'rejects nested gadget payloads before Oj.load' do + expect(Oj).not_to receive(:load) + expect do + described_class.deserialize( + '{"^o":"TemporalJSONSpecFixtures::DummyActivity::Request","scope":{"^o":"Gem::Requirement"}}' + ) + end.to raise_error(Temporal::JSONDisallowedClassError, /Gem::Requirement/) + end + + it 'rejects a ::Request name that is not a loaded constant' do + expect(Oj).not_to receive(:load) + expect do + described_class.deserialize('{"^o":"MissingActivity::Request"}') + end.to raise_error(Temporal::JSONDisallowedClassError, /MissingActivity::Request/) + end + + it 'rejects an unregistered non-Request class' do + expect(Oj).not_to receive(:load) + expect do + described_class.deserialize( + '{"^o":"TemporalJSONSpecFixtures::DummyWidget","name":"x"}' + ) + end.to raise_error(Temporal::JSONDisallowedClassError, /DummyWidget/) + end + + it 'reconstitutes a class registered with allow_class' do + described_class.allow_class('TemporalJSONSpecFixtures::DummyWidget') + widget = TemporalJSONSpecFixtures::DummyWidget.new(name: 'ok') + loaded = described_class.deserialize(described_class.serialize(widget)) + + expect(loaded).to be_a(TemporalJSONSpecFixtures::DummyWidget) + expect(loaded.name).to eq('ok') + end end end From c0ac7e9271a7c0f54856d754c71b70ca7cdd627e Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Thu, 27 Aug 2026 15:44:36 -0700 Subject: [PATCH 2/6] Pin protobuf 3 in CI and use docker compose v2. Unconstrained bundle install was pulling protobuf 4, which cannot load the generated stubs, and ubuntu-latest no longer ships docker-compose. Co-authored-by: Cursor --- .github/workflows/tests.yml | 2 +- Gemfile | 3 +++ examples/Gemfile | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9d7546101..198ecc013 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,7 +37,7 @@ jobs: - name: Start dependencies run: | - docker-compose \ + docker compose \ -f examples/docker-compose.yml \ up -d diff --git a/Gemfile b/Gemfile index fa75df156..c03120191 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,6 @@ source 'https://rubygems.org' gemspec + +# Generated lib/gen stubs use protobuf 3 DescriptorPool#build, which protobuf 4 removed. +gem 'google-protobuf', '~> 3.25.0' diff --git a/examples/Gemfile b/examples/Gemfile index 9c543b774..45e0be6fc 100644 --- a/examples/Gemfile +++ b/examples/Gemfile @@ -1,6 +1,7 @@ source 'https://rubygems.org' gem 'temporal-ruby', path: '../' +gem 'google-protobuf', '~> 3.25.0' gem 'dry-types', '>= 1.2.0' gem 'dry-struct', '~> 1.1.1' From ad7f477e9bc0240e47c1b9f1576050ec8705a70c Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Thu, 27 Aug 2026 15:47:30 -0700 Subject: [PATCH 3/6] Wait for Temporal on 7233 before example namespace registration. docker compose now starts, but auto-setup is not ready after a 10s sleep, so register_namespace hits connection refused. Co-authored-by: Cursor --- .github/workflows/tests.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 198ecc013..72b5c3ef6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,13 +50,23 @@ jobs: run: | cd examples && bundle install --path vendor/bundle - - name: Wait for dependencies to settle + - name: Wait for Temporal run: | - sleep 10 + timeout 180 bash -c 'until echo >/dev/tcp/127.0.0.1/7233; do sleep 2; done' - name: Register namespace run: | - cd examples && bin/register_namespace ruby-samples + cd examples + for i in $(seq 1 30); do + if bin/register_namespace ruby-samples; then + exit 0 + fi + echo "register_namespace failed, retry ${i}" + sleep 5 + done + docker compose -f docker-compose.yml ps + docker compose -f docker-compose.yml logs --tail 200 + exit 1 - name: Wait for namespace to settle run: | From 9f2d8f3745bb6bf49edf7d79deef556e91a5c7e3 Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Thu, 27 Aug 2026 16:38:23 -0700 Subject: [PATCH 4/6] Pin example Temporal images so CI can actually reach 7233. auto-setup: latest never bound gRPC with Cassandra 3.11 on GitHub runners. Dump compose logs if the wait still times out. Co-authored-by: Cursor --- .github/workflows/tests.yml | 8 +++++++- examples/docker-compose.yml | 6 +++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 72b5c3ef6..2e7c85004 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -52,7 +52,13 @@ jobs: - name: Wait for Temporal run: | - timeout 180 bash -c 'until echo >/dev/tcp/127.0.0.1/7233; do sleep 2; done' + if timeout 300 bash -c 'until echo >/dev/tcp/127.0.0.1/7233; do sleep 2; done'; then + exit 0 + fi + echo "Temporal did not listen on 7233" + docker compose -f examples/docker-compose.yml ps -a + docker compose -f examples/docker-compose.yml logs --no-color --tail 400 + exit 1 - name: Register namespace run: | diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml index 4bff724c6..d4a292bfb 100644 --- a/examples/docker-compose.yml +++ b/examples/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.5' services: temporal: - image: temporalio/auto-setup:latest + image: temporalio/auto-setup:1.22.4 ports: - "7233:7233" environment: @@ -14,7 +14,7 @@ services: - cassandra temporal-web: - image: temporalio/web:latest + image: temporalio/web:1.15.0 environment: - "TEMPORAL_GRPC_ENDPOINT=temporal:7233" ports: @@ -23,6 +23,6 @@ services: - temporal cassandra: - image: cassandra:3.11 + image: cassandra:3.11.16 ports: - "9042:9042" From 9d4f61edf7117ba670760fac7048145bd6aa8d58 Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Thu, 27 Aug 2026 16:48:16 -0700 Subject: [PATCH 5/6] Allow Temporal library types and anonymous Structs through json/plain. Example workflows return Metadata::Workflow and Struct.new results. Those are first-party Oj encodings, not gadget classes. Co-authored-by: Cursor --- .github/workflows/tests.yml | 4 ++++ CHANGELOG.md | 2 +- lib/temporal/json.rb | 20 +++++++++++++++++- spec/unit/lib/temporal/json.rb | 38 ++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2e7c85004..6846aab05 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -94,6 +94,10 @@ jobs: run: | cd examples && bin/worker & + - name: Wait for workers + run: | + sleep 10 + - name: Run RSpec env: USE_ERROR_SERIALIZATION_V2: 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fadf19b2..ccb503cc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.0.7 -- Fail-closed `json/plain` deserialize: Oj `mode: :object` no longer instantiates arbitrary classes. Instances are reconstituted only for loaded `::Request` / `::Response` types, loaded Exception subclasses (error serialization v2), and `Temporal::JSON.allow_class`. Class references (`^c`) require a loaded constant. Encode and the `json/plain` encoding name are unchanged (SECBUGS-174). +- Fail-closed `json/plain` deserialize: Oj `mode: :object` no longer instantiates arbitrary classes. Instances are reconstituted only for loaded `Temporal::` types, loaded `::Request` / `::Response` types, loaded Exception subclasses (error serialization v2), and `Temporal::JSON.allow_class`. Anonymous Structs (`^u` with member names) round-trip. Class references (`^c`) require a loaded constant. Encode and the `json/plain` encoding name are unchanged (SECBUGS-174). ## 0.0.6 diff --git a/lib/temporal/json.rb b/lib/temporal/json.rb index e157a9741..d9c7f4cc6 100644 --- a/lib/temporal/json.rb +++ b/lib/temporal/json.rb @@ -58,9 +58,10 @@ def self.assert_safe!(obj) when Hash STRUCT_DIRECTIVE_KEYS.each do |key| next unless obj.key?(key) + next if anonymous_struct_directive?(obj[key]) name = class_name_from_directive(obj[key]) - unless name && registered_class?(name) + unless name && allowed_struct_class?(name) raise Temporal::JSONDisallowedClassError, "json/plain payload requested disallowed class #{obj[key].inspect}" end @@ -110,6 +111,7 @@ def self.registered_class?(name) def self.allowed_instance_class?(name) return true if registered_class?(name) + return true if library_class?(name) return true if ALLOWED_CLASS_SUFFIXES.any? { |suffix| name.end_with?(suffix) } && resolve_constant(name) klass = resolve_constant(name) @@ -117,6 +119,22 @@ def self.allowed_instance_class?(name) end private_class_method :allowed_instance_class? + def self.allowed_struct_class?(name) + registered_class?(name) || library_class?(name) + end + private_class_method :allowed_struct_class? + + def self.library_class?(name) + name.start_with?('Temporal::') && resolve_constant(name).is_a?(Class) + end + private_class_method :library_class? + + # Oj encodes Struct.new(:a, :b).new(...) as ^u with member names, not a class. + def self.anonymous_struct_directive?(value) + value.is_a?(Array) && value.first.is_a?(Array) && value.first.all? { |member| member.is_a?(String) } + end + private_class_method :anonymous_struct_directive? + def self.allowed_class_reference?(name) return true if registered_class?(name) diff --git a/spec/unit/lib/temporal/json.rb b/spec/unit/lib/temporal/json.rb index 7d1ac203a..bf998d3a3 100644 --- a/spec/unit/lib/temporal/json.rb +++ b/spec/unit/lib/temporal/json.rb @@ -174,5 +174,43 @@ def initialize(message = nil, code: nil) expect(loaded).to be_a(TemporalJSONSpecFixtures::DummyWidget) expect(loaded.name).to eq('ok') end + + it 'round-trips Temporal::Metadata::Workflow' do + require 'temporal/metadata/workflow' + + metadata = Temporal::Metadata::Workflow.new( + namespace: 'ns', + id: 'wid', + name: 'WorkflowName', + run_id: 'rid', + parent_id: nil, + parent_run_id: nil, + attempt: 1, + task_queue: 'default', + headers: {}, + run_started_at: Time.at(1_700_000_000), + memo: {} + ) + loaded = described_class.deserialize(described_class.serialize(metadata)) + + expect(loaded).to be_a(Temporal::Metadata::Workflow) + expect(loaded.id).to eq('wid') + expect(loaded.task_queue).to eq('default') + end + + it 'round-trips an anonymous Struct' do + response = Struct.new(:workflow_id, :run_id).new('wid', 'rid') + loaded = described_class.deserialize(described_class.serialize(response)) + + expect(loaded.workflow_id).to eq('wid') + expect(loaded.run_id).to eq('rid') + end + + it 'rejects a named Struct class that is not registered' do + expect(Oj).not_to receive(:load) + expect do + described_class.deserialize('{"^u":["Range",1,7,false]}') + end.to raise_error(Temporal::JSONDisallowedClassError, /Range/) + end end end From 441d5901d425384203fdcb89555e079eda399694 Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Thu, 27 Aug 2026 16:55:13 -0700 Subject: [PATCH 6/6] Allow Thread::Backtrace through json/plain so raised errors round-trip. Oj puts ^o Thread::Backtrace on ~bt_locations. Rename the unit file to json_spec.rb so CI actually runs it. Co-authored-by: Cursor --- CHANGELOG.md | 2 +- lib/temporal/json.rb | 6 ++++++ spec/unit/lib/temporal/{json.rb => json_spec.rb} | 12 ++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) rename spec/unit/lib/temporal/{json.rb => json_spec.rb} (93%) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb503cc5..8f4c275ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.0.7 -- Fail-closed `json/plain` deserialize: Oj `mode: :object` no longer instantiates arbitrary classes. Instances are reconstituted only for loaded `Temporal::` types, loaded `::Request` / `::Response` types, loaded Exception subclasses (error serialization v2), and `Temporal::JSON.allow_class`. Anonymous Structs (`^u` with member names) round-trip. Class references (`^c`) require a loaded constant. Encode and the `json/plain` encoding name are unchanged (SECBUGS-174). +- Fail-closed `json/plain` deserialize: Oj `mode: :object` no longer instantiates arbitrary classes. Instances are reconstituted only for loaded `Temporal::` types, loaded `::Request` / `::Response` types, loaded Exception subclasses (error serialization v2), Oj Exception backtrace types (`Thread::Backtrace`), and `Temporal::JSON.allow_class`. Anonymous Structs (`^u` with member names) round-trip. Class references (`^c`) require a loaded constant. Encode and the `json/plain` encoding name are unchanged (SECBUGS-174). ## 0.0.6 diff --git a/lib/temporal/json.rb b/lib/temporal/json.rb index d9c7f4cc6..5885bf904 100644 --- a/lib/temporal/json.rb +++ b/lib/temporal/json.rb @@ -17,6 +17,11 @@ module JSON CLASS_REFERENCE_DIRECTIVE_KEYS = %w[^c ^C].freeze STRUCT_DIRECTIVE_KEYS = %w[^u].freeze ALLOWED_CLASS_SUFFIXES = %w[::Request ::Response].freeze + # Oj dumps these on a raised Exception (~bt_locations). They are not gadget classes. + ALLOWED_STDLIB_CLASSES = %w[ + Thread::Backtrace + Thread::Backtrace::Location + ].freeze ALLOWED_CLASSES = Set.new ALLOWED_CLASSES_MUTEX = Mutex.new @@ -112,6 +117,7 @@ def self.registered_class?(name) def self.allowed_instance_class?(name) return true if registered_class?(name) return true if library_class?(name) + return true if ALLOWED_STDLIB_CLASSES.include?(name) && resolve_constant(name) return true if ALLOWED_CLASS_SUFFIXES.any? { |suffix| name.end_with?(suffix) } && resolve_constant(name) klass = resolve_constant(name) diff --git a/spec/unit/lib/temporal/json.rb b/spec/unit/lib/temporal/json_spec.rb similarity index 93% rename from spec/unit/lib/temporal/json.rb rename to spec/unit/lib/temporal/json_spec.rb index bf998d3a3..6c81e18f1 100644 --- a/spec/unit/lib/temporal/json.rb +++ b/spec/unit/lib/temporal/json_spec.rb @@ -116,6 +116,18 @@ def initialize(message = nil, code: nil) expect(loaded.code).to eq(7) end + it 'round-trips a raised Exception including backtrace locations' do + begin + raise TemporalJSONSpecFixtures::DummyError.new('boom', code: 7) + rescue TemporalJSONSpecFixtures::DummyError => error + loaded = described_class.deserialize(described_class.serialize(error)) + + expect(loaded).to be_a(TemporalJSONSpecFixtures::DummyError) + expect(loaded.message).to eq('boom') + expect(loaded.code).to eq(7) + end + end + it 'reconstitutes a loaded class reference via ^c' do expect(described_class.deserialize('{"^c":"String"}')).to eq(String) end