مراية لـ
https://github.com/postalserver/postal.git
تم المزامنة 2026-06-03 21:45:48 +00:00
The Legacy API message lookup endpoints parsed the request body as JSON and passed the `id` parameter straight through to the message database. A JSON object supplied for `id` arrived as a Ruby Hash and was used as a raw set of SQL `WHERE` conditions. `hash_to_sql` interpolated each Hash key directly inside backtick identifier quoting while escaping only the value, so a key containing a backtick could break out of the identifier and inject arbitrary SQL into the SELECT (blind, time-based) against the message database. Fixes: - Escape all identifiers (columns, tables, database names) through a new `escape_identifier` helper that wraps in backticks and doubles embedded backticks. Applied across hash_to_sql, select, insert, insert_multi, update and delete so no caller can inject via an identifier. - Validate the Legacy API `id` parameter at the controller boundary: reject any non-scalar value before it reaches the database and coerce it to an integer. Internal Hash-based lookups (e.g. tracking middleware) are unaffected. Adds regression tests at the unit (hash_to_sql / escape_identifier) and request (legacy messages/deliveries) levels.
364 أسطر
14 KiB
Ruby
364 أسطر
14 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "rails_helper"
|
|
|
|
RSpec.describe "Legacy Messages API", type: :request do
|
|
describe "/api/v1/messages/message" do
|
|
context "when no authentication is provided" do
|
|
it "returns an error" do
|
|
post "/api/v1/messages/message"
|
|
expect(response.status).to eq 200
|
|
parsed_body = JSON.parse(response.body)
|
|
expect(parsed_body["status"]).to eq "error"
|
|
expect(parsed_body["data"]["code"]).to eq "AccessDenied"
|
|
end
|
|
end
|
|
|
|
context "when the credential does not match anything" do
|
|
it "returns an error" do
|
|
post "/api/v1/messages/message", headers: { "x-server-api-key" => "invalid" }
|
|
expect(response.status).to eq 200
|
|
parsed_body = JSON.parse(response.body)
|
|
expect(parsed_body["status"]).to eq "error"
|
|
expect(parsed_body["data"]["code"]).to eq "InvalidServerAPIKey"
|
|
end
|
|
end
|
|
|
|
context "when the credential belongs to a suspended server" do
|
|
it "returns an error" do
|
|
server = create(:server, :suspended)
|
|
credential = create(:credential, server: server)
|
|
post "/api/v1/messages/message", headers: { "x-server-api-key" => credential.key }
|
|
expect(response.status).to eq 200
|
|
parsed_body = JSON.parse(response.body)
|
|
expect(parsed_body["status"]).to eq "error"
|
|
expect(parsed_body["data"]["code"]).to eq "ServerSuspended"
|
|
end
|
|
end
|
|
|
|
context "when the credential is valid" do
|
|
let(:server) { create(:server) }
|
|
let(:credential) { create(:credential, server: server) }
|
|
|
|
context "when no message ID is provided" do
|
|
it "returns an error" do
|
|
post "/api/v1/messages/message", headers: { "x-server-api-key" => credential.key }
|
|
expect(response.status).to eq 200
|
|
parsed_body = JSON.parse(response.body)
|
|
expect(parsed_body["status"]).to eq "parameter-error"
|
|
expect(parsed_body["data"]["message"]).to match(/`id` parameter is required but is missing/)
|
|
end
|
|
end
|
|
|
|
context "when the message ID does not exist" do
|
|
it "returns an error" do
|
|
post "/api/v1/messages/message",
|
|
headers: { "x-server-api-key" => credential.key,
|
|
"content-type" => "application/json" },
|
|
params: { id: 123 }.to_json
|
|
expect(response.status).to eq 200
|
|
parsed_body = JSON.parse(response.body)
|
|
expect(parsed_body["status"]).to eq "error"
|
|
expect(parsed_body["data"]["code"]).to eq "MessageNotFound"
|
|
end
|
|
end
|
|
|
|
# Regression tests for GHSA-x2hq-rfpg-3xr5. The request body is parsed as
|
|
# JSON, so a JSON object/array supplied for `id` would otherwise arrive as
|
|
# a Ruby Hash/Array and be passed straight through to the message database
|
|
# as a raw set of SQL conditions (blind SQL injection). These must be
|
|
# rejected before reaching the database.
|
|
context "when the message ID is a JSON object (SQL injection attempt)" do
|
|
it "rejects it with a parameter error and never reaches the database" do
|
|
expect_any_instance_of(Server).not_to receive(:message)
|
|
post "/api/v1/messages/message",
|
|
headers: { "x-server-api-key" => credential.key,
|
|
"content-type" => "application/json" },
|
|
params: { id: { "id`=0 OR SLEEP(5)#" => "x" } }.to_json
|
|
expect(response.status).to eq 200
|
|
parsed_body = JSON.parse(response.body)
|
|
expect(parsed_body["status"]).to eq "parameter-error"
|
|
expect(parsed_body["data"]["message"]).to match(/must be a string or integer/)
|
|
end
|
|
end
|
|
|
|
context "when the message ID is a JSON array" do
|
|
it "rejects it with a parameter error" do
|
|
post "/api/v1/messages/message",
|
|
headers: { "x-server-api-key" => credential.key,
|
|
"content-type" => "application/json" },
|
|
params: { id: [1, 2, 3] }.to_json
|
|
expect(response.status).to eq 200
|
|
parsed_body = JSON.parse(response.body)
|
|
expect(parsed_body["status"]).to eq "parameter-error"
|
|
expect(parsed_body["data"]["message"]).to match(/must be a string or integer/)
|
|
end
|
|
end
|
|
|
|
context "when the message ID is provided as a numeric string" do
|
|
let(:message) { MessageFactory.outgoing(server) }
|
|
|
|
it "is coerced to an integer and looks the message up" do
|
|
post "/api/v1/messages/message",
|
|
headers: { "x-server-api-key" => credential.key,
|
|
"content-type" => "application/json" },
|
|
params: { id: message.id.to_s }.to_json
|
|
expect(response.status).to eq 200
|
|
parsed_body = JSON.parse(response.body)
|
|
expect(parsed_body["status"]).to eq "success"
|
|
expect(parsed_body["data"]).to match({
|
|
"id" => message.id,
|
|
"token" => message.token
|
|
})
|
|
end
|
|
end
|
|
|
|
context "when the message ID exists" do
|
|
let(:server) { create(:server) }
|
|
let(:credential) { create(:credential, server: server) }
|
|
let(:message) { MessageFactory.outgoing(server) }
|
|
let(:expansions) { [] }
|
|
|
|
before do
|
|
post "/api/v1/messages/message",
|
|
headers: { "x-server-api-key" => credential.key,
|
|
"content-type" => "application/json" },
|
|
params: { id: message.id, _expansions: expansions }.to_json
|
|
end
|
|
|
|
context "when no expansions are requested" do
|
|
it "returns details about the message" do
|
|
expect(response.status).to eq 200
|
|
parsed_body = JSON.parse(response.body)
|
|
expect(parsed_body["status"]).to eq "success"
|
|
expect(parsed_body["data"]).to match({
|
|
"id" => message.id,
|
|
"token" => message.token
|
|
})
|
|
end
|
|
end
|
|
|
|
context "when all expansions are requested" do
|
|
let(:expansions) { true }
|
|
|
|
it "returns details about the message" do
|
|
expect(response.status).to eq 200
|
|
parsed_body = JSON.parse(response.body)
|
|
expect(parsed_body["status"]).to eq "success"
|
|
expect(parsed_body["data"]).to match({
|
|
"id" => message.id,
|
|
"token" => message.token,
|
|
"status" => { "held" => false,
|
|
"hold_expiry" => nil,
|
|
"last_delivery_attempt" => nil,
|
|
"status" => "Pending" },
|
|
"details" => { "bounce" => false,
|
|
"bounce_for_id" => 0,
|
|
"direction" => "outgoing",
|
|
"mail_from" => "test@example.com",
|
|
"message_id" => message.message_id,
|
|
"rcpt_to" => "john@example.com",
|
|
"received_with_ssl" => nil,
|
|
"size" => kind_of(String),
|
|
"subject" => "An example message",
|
|
"tag" => nil,
|
|
"timestamp" => kind_of(Float) },
|
|
"inspection" => { "inspected" => false,
|
|
"spam" => false,
|
|
"spam_score" => 0.0,
|
|
"threat" => false,
|
|
"threat_details" => nil },
|
|
"plain_body" => message.plain_body,
|
|
"html_body" => message.html_body,
|
|
"attachments" => [],
|
|
"headers" => message.headers,
|
|
"raw_message" => Base64.encode64(message.raw_message),
|
|
"activity_entries" => {
|
|
"loads" => [],
|
|
"clicks" => []
|
|
}
|
|
})
|
|
end
|
|
end
|
|
|
|
context "when the status expansion is requested" do
|
|
let(:expansions) { ["status"] }
|
|
|
|
it "returns details about the message" do
|
|
expect(response.status).to eq 200
|
|
parsed_body = JSON.parse(response.body)
|
|
expect(parsed_body["status"]).to eq "success"
|
|
expect(parsed_body["data"]).to match({
|
|
"id" => message.id,
|
|
"token" => message.token,
|
|
"status" => { "held" => false,
|
|
"hold_expiry" => nil,
|
|
"last_delivery_attempt" => nil,
|
|
"status" => "Pending" }
|
|
})
|
|
end
|
|
end
|
|
|
|
context "when the details expansion is requested" do
|
|
let(:expansions) { ["details"] }
|
|
|
|
it "returns details about the message" do
|
|
expect(response.status).to eq 200
|
|
parsed_body = JSON.parse(response.body)
|
|
expect(parsed_body["status"]).to eq "success"
|
|
expect(parsed_body["data"]).to match({
|
|
"id" => message.id,
|
|
"token" => message.token,
|
|
"details" => { "bounce" => false,
|
|
"bounce_for_id" => 0,
|
|
"direction" => "outgoing",
|
|
"mail_from" => "test@example.com",
|
|
"message_id" => message.message_id,
|
|
"rcpt_to" => "john@example.com",
|
|
"received_with_ssl" => nil,
|
|
"size" => kind_of(String),
|
|
"subject" => "An example message",
|
|
"tag" => nil,
|
|
"timestamp" => kind_of(Float) }
|
|
})
|
|
end
|
|
end
|
|
|
|
context "when the details expansion is requested" do
|
|
let(:expansions) { ["inspection"] }
|
|
|
|
it "returns details about the message" do
|
|
expect(response.status).to eq 200
|
|
parsed_body = JSON.parse(response.body)
|
|
expect(parsed_body["status"]).to eq "success"
|
|
expect(parsed_body["data"]).to match({
|
|
"id" => message.id,
|
|
"token" => message.token,
|
|
"inspection" => { "inspected" => false,
|
|
"spam" => false,
|
|
"spam_score" => 0.0,
|
|
"threat" => false,
|
|
"threat_details" => nil }
|
|
})
|
|
end
|
|
end
|
|
|
|
context "when the body expansions are requested" do
|
|
let(:expansions) { %w[plain_body html_body] }
|
|
|
|
it "returns details about the message" do
|
|
expect(response.status).to eq 200
|
|
parsed_body = JSON.parse(response.body)
|
|
expect(parsed_body["status"]).to eq "success"
|
|
expect(parsed_body["data"]).to match({
|
|
"id" => message.id,
|
|
"token" => message.token,
|
|
"plain_body" => message.plain_body,
|
|
"html_body" => message.html_body
|
|
})
|
|
end
|
|
end
|
|
|
|
context "when the attachments expansions is requested" do
|
|
let(:message) do
|
|
MessageFactory.outgoing(server) do |_, mail|
|
|
mail.attachments["example.txt"] = "hello world!"
|
|
end
|
|
end
|
|
let(:expansions) { ["attachments"] }
|
|
|
|
it "returns details about the message" do
|
|
expect(response.status).to eq 200
|
|
parsed_body = JSON.parse(response.body)
|
|
expect(parsed_body["status"]).to eq "success"
|
|
expect(parsed_body["data"]).to match({
|
|
"id" => message.id,
|
|
"token" => message.token,
|
|
"attachments" => [
|
|
{
|
|
"content_type" => "text/plain",
|
|
"data" => Base64.encode64("hello world!"),
|
|
"filename" => "example.txt",
|
|
"hash" => Digest::SHA1.hexdigest("hello world!"),
|
|
"size" => 12
|
|
},
|
|
]
|
|
})
|
|
end
|
|
end
|
|
|
|
context "when the headers expansions is requested" do
|
|
let(:expansions) { ["headers"] }
|
|
|
|
it "returns details about the message" do
|
|
expect(response.status).to eq 200
|
|
parsed_body = JSON.parse(response.body)
|
|
expect(parsed_body["status"]).to eq "success"
|
|
expect(parsed_body["data"]).to match({
|
|
"id" => message.id,
|
|
"token" => message.token,
|
|
"headers" => message.headers
|
|
})
|
|
end
|
|
end
|
|
|
|
context "when the raw_message expansions is requested" do
|
|
let(:expansions) { ["raw_message"] }
|
|
|
|
it "returns details about the message" do
|
|
expect(response.status).to eq 200
|
|
parsed_body = JSON.parse(response.body)
|
|
expect(parsed_body["status"]).to eq "success"
|
|
expect(parsed_body["data"]).to match({
|
|
"id" => message.id,
|
|
"token" => message.token,
|
|
"raw_message" => Base64.encode64(message.raw_message)
|
|
})
|
|
end
|
|
end
|
|
|
|
context "when the activity_entries expansions is requested" do
|
|
let(:message) do
|
|
MessageFactory.outgoing(server) do |msg|
|
|
msg.create_load(double("request", ip: "1.2.3.4", user_agent: "user agent"))
|
|
link = msg.create_link("https://example.com")
|
|
link_id = msg.database.select(:links, where: { token: link }).first["id"]
|
|
msg.database.insert(:clicks, {
|
|
message_id: msg.id,
|
|
link_id: link_id,
|
|
ip_address: "1.2.3.4",
|
|
user_agent: "user agent",
|
|
timestamp: Time.now.to_f
|
|
})
|
|
end
|
|
end
|
|
let(:expansions) { ["activity_entries"] }
|
|
|
|
it "returns details about the message" do
|
|
expect(response.status).to eq 200
|
|
parsed_body = JSON.parse(response.body)
|
|
expect(parsed_body["status"]).to eq "success"
|
|
expect(parsed_body["data"]).to match({
|
|
"id" => message.id,
|
|
"token" => message.token,
|
|
"activity_entries" => {
|
|
"loads" => [{
|
|
"ip_address" => "1.2.3.4",
|
|
"user_agent" => "user agent",
|
|
"timestamp" => match(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\z/)
|
|
}],
|
|
"clicks" => [{
|
|
"url" => "https://example.com",
|
|
"ip_address" => "1.2.3.4",
|
|
"user_agent" => "user agent",
|
|
"timestamp" => match(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\z/)
|
|
}]
|
|
}
|
|
})
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|