diff --git a/web/app.rb b/web/app.rb index 630f496..774f0c3 100644 --- a/web/app.rb +++ b/web/app.rb @@ -23,12 +23,21 @@ require "json" require "sqlite3" require "fileutils" require "logger" +require "rack/utils" DB_PATH = ENV.fetch("MESH_DB", File.join(__dir__, "../data/mesh.db")) DB_BUSY_TIMEOUT_MS = ENV.fetch("DB_BUSY_TIMEOUT_MS", "5000").to_i DB_BUSY_MAX_RETRIES = ENV.fetch("DB_BUSY_MAX_RETRIES", "5").to_i DB_BUSY_RETRY_DELAY = ENV.fetch("DB_BUSY_RETRY_DELAY", "0.05").to_f WEEK_SECONDS = 7 * 24 * 60 * 60 +DEFAULT_MAX_JSON_BODY_BYTES = 1_048_576 +MAX_JSON_BODY_BYTES = begin + raw = ENV.fetch("MAX_JSON_BODY_BYTES", DEFAULT_MAX_JSON_BODY_BYTES.to_s) + value = Integer(raw, 10) + value.positive? ? value : DEFAULT_MAX_JSON_BODY_BYTES + rescue ArgumentError + DEFAULT_MAX_JSON_BODY_BYTES + end set :public_folder, File.join(__dir__, "public") set :views, File.join(__dir__, "views") @@ -355,7 +364,42 @@ end def require_token! token = ENV["API_TOKEN"] provided = request.env["HTTP_AUTHORIZATION"].to_s.sub(/^Bearer\s+/i, "") - halt 403, { error: "Forbidden" }.to_json unless token && !token.empty? && provided == token + halt 403, { error: "Forbidden" }.to_json unless token && !token.empty? && secure_token_match?(token, provided) +end + +# Perform a constant-time comparison between two strings, returning false on +# length mismatches or invalid input. +# +# @param expected [String] +# @param provided [String] +# @return [Boolean] +def secure_token_match?(expected, provided) + return false unless expected.is_a?(String) && provided.is_a?(String) + + expected_bytes = expected.b + provided_bytes = provided.b + return false unless expected_bytes.bytesize == provided_bytes.bytesize + Rack::Utils.secure_compare(expected_bytes, provided_bytes) +rescue Rack::Utils::SecurityError + false +end + +# Read the request body enforcing a maximum allowed size. +# +# @param limit [Integer, nil] optional override for the number of bytes. +# @return [String] +def read_json_body(limit: nil) + max_bytes = limit || MAX_JSON_BODY_BYTES + max_bytes = max_bytes.to_i + max_bytes = MAX_JSON_BODY_BYTES if max_bytes <= 0 + + body = request.body.read(max_bytes + 1) + body = "" if body.nil? + halt 413, { error: "payload too large" }.to_json if body.bytesize > max_bytes + + body +ensure + request.body.rewind if request.body.respond_to?(:rewind) end # Determine whether the canonical node identifier should replace the provided @@ -456,7 +500,7 @@ post "/api/nodes" do require_token! content_type :json begin - data = JSON.parse(request.body.read) + data = JSON.parse(read_json_body) rescue JSON::ParserError halt 400, { error: "invalid JSON" }.to_json end @@ -477,7 +521,7 @@ post "/api/messages" do require_token! content_type :json begin - data = JSON.parse(request.body.read) + data = JSON.parse(read_json_body) rescue JSON::ParserError halt 400, { error: "invalid JSON" }.to_json end diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index af36859..151d413 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -314,6 +314,23 @@ RSpec.describe "Potato Mesh Sinatra app" do end end + it "returns 413 when the request body exceeds the configured byte limit" do + limit = 64 + stub_const("MAX_JSON_BODY_BYTES", limit) + payload = { "huge-node" => { "user" => { "shortName" => "A" * (limit + 50) } } }.to_json + expect(payload.bytesize).to be > limit + + post "/api/nodes", payload, auth_headers + + expect(last_response.status).to eq(413) + expect(JSON.parse(last_response.body)).to eq("error" => "payload too large") + + with_db(readonly: true) do |db| + count = db.get_first_value("SELECT COUNT(*) FROM nodes") + expect(count).to eq(0) + end + end + it "treats SQL-looking node identifiers as plain data" do malicious_id = "spec-node'); DROP TABLE nodes;--" payload = { @@ -417,6 +434,23 @@ RSpec.describe "Potato Mesh Sinatra app" do expect(JSON.parse(last_response.body)).to eq("error" => "invalid JSON") end + it "rejects message payloads that are larger than the configured byte limit" do + limit = 64 + stub_const("MAX_JSON_BODY_BYTES", limit) + payload = [{ "id" => "m1", "text" => "A" * (limit + 50) }].to_json + expect(payload.bytesize).to be > limit + + post "/api/messages", payload, auth_headers + + expect(last_response.status).to eq(413) + expect(JSON.parse(last_response.body)).to eq("error" => "payload too large") + + with_db(readonly: true) do |db| + count = db.get_first_value("SELECT COUNT(*) FROM messages") + expect(count).to eq(0) + end + end + it "returns 400 when more than 1000 messages are provided" do payload = Array.new(1001) { |i| { "packet_id" => i + 1 } }