Harden API auth and request limits (#116)

* Harden API auth and request limits

* run rufo
This commit is contained in:
l5y
2025-09-17 08:00:25 +02:00
committed by GitHub
parent 0936c6087b
commit 71e9f89aae
2 changed files with 81 additions and 3 deletions
+47 -3
View File
@@ -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
+34
View File
@@ -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 } }