Handling null bytes in Postgres

Postgres doesn’t support null bytes in string columns. However, null bytes do show up in requests (for whatever reason) and at the moment result in a 500 error, as in the following example:

class BooksController < ActionController::Base
  def show
    @books = Book.find_by(name: book_params[:name])
    render plain: @books.count
  end

  private

  def book_params
    params.require(:search).permit(:name)
  end
end

class BooksControllerTest < Minitest::Test
  include Rack::Test::Methods

  def test_index
    get "/book", search: { name: "\u0000" }
    assert last_response.ok? # ArgumentError: string contains null byte
  end
end

Possible solutions

In order to solve the problem in our applications, we discussed multiple strategies on how to handle null bytes in our application. In particular, we tried to figure out where the best place is to handle them:

  • Rack Middleware: could come with quite an overhead and Model.find_by(attribute: "test\00") still raises
  • case-by-case: normalizes could be used to sanitise certain attributes. class User < ApplicationRecord normalizes :email, with: ->(str) { str&.delete("\u0000") }) end. However, we would have to remember to apply this change everywhere and a query like User.exists?(["email = ?" , "test\00"]) will still raise

For our solution, we’ve defined a set of requirements:

RSpec.describe 'Postgres string null byte support' do
  let(:search_term) { "test\00" }

  context 'when quering directly on the database' do
    let(:query) do
      ActiveRecord::Base
        .connection
        .execute("SELECT * FROM materials where materialcode = '#{search_term}'")
    end
    it 'raises an error as postgres does NOT support null bytes' do
      expect { query }.to raise_error(ArgumentError).with_message('string contains null byte')
    end
  end

  context 'when using ActiveRecord' do
    context 'with attribute-aware methods' do
      let(:query) { Material.where(name: search_term) }
      it 'does NOT raise an error' do
        expect { query }.not_to raise_error
        expect(query.first).to be_nil
      end
    end
    
    context 'without attribute-aware methods' do
      let(:query) { Manufacturer.where('manufacturers.name ILIKE ? '
                                         , search_term) }
      it 'does NOT raise an error' do
        expect { query }.not_to raise_error
        expect(query.first).to be_nil
      end
    end
  end
end

In the end we decided to implement a new OID::PostgresString class:

module ActiveRecord
  module ConnectionAdapters
    module PostgreSQL
      module OID
        class PostgresString < Type::String
          def serialize(value)
            super(PostgreSQL::StringUtils.strip_null_bytes(value))
          end

          private

          def cast_value(value)
            super(PostgreSQL::StringUtils.strip_null_bytes(value))
          end
        end
      end
    end
  end
end

which then gets registered in our PostgreSQLAdapter patch:

# config/initializers/postgres_string_null_byte_support.rb
module ActiveRecord
  module ConnectionAdapters
    class PostgreSQLAdapter < AbstractAdapter
      class << self
        alias original_initialize_type_map initialize_type_map

        def initialize_type_map(m = type_map)
          original_initialize_type_map(m)
          register_class_with_limit m,'varchar', OID::PostgresString
        end
      end

      ActiveRecord::Type.register(:string, OID::PostgresString, adapter: :postgresql)
    end
  end
end

This then allowed us to strip null bytes in all finders and assignments.

Where do you think makes most sense to implement a fix for this? Maybe in the PG adapter as a configurable option (similiar as the ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.decode_dates flag)? Or rather in ruby-pg?

References

Their were also various issues about this already:

I also made a talk about this problem with more detailed snippets: https://github.com/renuo/postgres-null-bytes-talk/blob/main/presentation.pdf

3 Likes

Thank you for starting this discussion thread and for drafting the PR at Add `strip_null_bytes` option to the PostgreSQL adapter by sislr · Pull Request #57306 · rails/rails · GitHub.

I totally get that this is a problem that needs to be solved, but I’m not sure that this is the right solution.

The four issues you mentioned were all closed:

  • #26891 (2016), closed: not an issue.
  • #30730 (2017), closed: no escaping is possible; use a bytea column.
  • #42696 (2021), closed as a support question.
  • #50166 (2023), closed not-planned.

In #50166 in particular, Nikita compared it to silently truncating an integer that is too wide for its column, which I think is appropriate – changing the value in this way (the database value is different from the attribute in memory) seems like a deep violation of expectations that is sure to lead to unwanted behaviors, and I’m not convinced that it’s the right solution, at least as an opt-in configuration on the entire adapter.

For the per-column config, normalizes already covers this case explicitly. Widening it to “everything on this connection” gives up the property that makes it safe.

Specific problematic behaviors

A specific behavior I think violates expectations is that the PR strips below the attribute layer, so the object and the row disagree. With the flag on:

book = Book.create!(name: "a\0b")
book.name         # => "a\u0000b"
book.reload.name  # => "ab"

The assertions in the PR’s model tests all call reload first, which hides this. The OID::PostgresString approach in your first post does not have this problem, because it strips in cast_value as well as serialize. If stripping ships at all, I think the type layer is the correct place for it, not quote_string and type_cast.

Another specific behavior that violates expectations is that two forms of the same query behave differently:

Blob.where(payload: "\x00\x01")       # null byte preserved
Blob.where("payload = ?", "\x00\x01") # null byte stripped, asks for "\x01"

The hash form serializes through ActiveRecord::Type::Binary into Type::Binary::Data, which type_cast matches before the new when String branch. The SQL fragment form arrives as a plain Ruby String and gets stripped. Same intent, two different queries.

This only shows up on bytea. A text column strips both spellings identically, and no stored row can hold a null byte, so both find the same nothing. bytea rows can hold null bytes, so here the flag reaches a column type where the byte is meaningful, and the two spellings diverge.

Today the second form raises, so the divergence is not reachable. The flag makes it reachable.

An alternative?

I want to pick up a different thread that I don’t think has been suggested. A common complaint in all these reports is the unhandled 500 carrying an ArgumentError from the driver.

Could we instead wrap the driver’s ArgumentError in an ActiveRecord exception class? Applications can then optionally rescue it and return a 400, which is the correct response to input the column cannot hold (or it could even be added to the default set of rescue_responses). It needs no configuration flag, it does not modify data, and it leaves the choice of stripping, validating, or rejecting with the application.

Would you be interested in pursuing that instead? I think it has a better chance of landing.

1 Like

Thank you very much for your review and suggestion! I agree with your input and opened a new PR here: Wrap PostgreSQL null byte error in ActiveRecord::NullByteError by sislr · Pull Request #58442 · rails/rails · GitHub. Looking forward to hearing from you again :slight_smile:

1 Like