Skip to content
Merged
8 changes: 4 additions & 4 deletions lib/x402.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ defmodule X402 do
## Examples

iex> payload = %{
...> "transactionHash" => "0xabc",
...> "transactionHash" => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
...> "network" => "eip155:8453",
...> "scheme" => "exact",
...> "payerWallet" => "0x1111111111111111111111111111111111111111"
Expand All @@ -73,7 +73,7 @@ defmodule X402 do
## Examples

iex> X402.validate_payment_signature(%{
...> "transactionHash" => "0xabc",
...> "transactionHash" => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
...> "network" => "eip155:8453",
...> "scheme" => "exact",
...> "payerWallet" => "0x1111111111111111111111111111111111111111"
Expand All @@ -82,7 +82,7 @@ defmodule X402 do
"network" => "eip155:8453",
"payerWallet" => "0x1111111111111111111111111111111111111111",
"scheme" => "exact",
"transactionHash" => "0xabc"
"transactionHash" => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delegate specs missing new invalid_format error type

Medium Severity

The @spec for validate_payment_signature/1 and decode_and_validate_payment_signature/1 in lib/x402.ex don't include {:invalid_format, [{String.t(), atom()}]} as a possible error return. The underlying PaymentSignature.validate/1 now returns this new error type via check_field_formats/1, but the delegate specs still only list :invalid_payload, {:missing_fields, ...}, and {:invalid_upto_payment, ...}. Callers relying on these specs (or Dialyzer) won't know to handle the new error case.

Additional Locations (1)
Fix in CursorΒ Fix in Web

}}
"""
@spec validate_payment_signature(map()) ::
Expand All @@ -100,7 +100,7 @@ defmodule X402 do
## Examples

iex> payload = %{
...> "transactionHash" => "0xabc",
...> "transactionHash" => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
...> "network" => "eip155:8453",
...> "scheme" => "exact",
...> "payerWallet" => "0x1111111111111111111111111111111111111111"
Expand Down
36 changes: 29 additions & 7 deletions lib/x402/extensions/siwx/ets_storage.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ defmodule X402.Extensions.SIWX.ETSStorage do
@default_table :x402_siwx_storage
@default_cleanup_interval_ms 60_000

@default_max_size 100_000

@start_link_options_schema [
name: [
type: :any,
Expand All @@ -32,6 +34,12 @@ defmodule X402.Extensions.SIWX.ETSStorage do
type: :pos_integer,
default: @default_cleanup_interval_ms,
doc: "Interval in milliseconds between cleanup sweeps."
],
max_size: [
type: :pos_integer,
default: @default_max_size,
doc:
"Maximum number of {address, resource} entries to keep in the ETS table. When reached, new `put/4` calls return `{:error, :storage_full}` until the next cleanup sweep evicts expired records. Prevents unbounded memory growth under spam attacks."
]
]

Expand All @@ -40,7 +48,8 @@ defmodule X402.Extensions.SIWX.ETSStorage do

@type state :: %{
table: atom(),
cleanup_interval_ms: pos_integer()
cleanup_interval_ms: pos_integer(),
max_size: pos_integer()
}

@doc """
Expand Down Expand Up @@ -152,6 +161,7 @@ defmodule X402.Extensions.SIWX.ETSStorage do
def init(opts) do
table = Keyword.fetch!(opts, :table)
cleanup_interval_ms = Keyword.fetch!(opts, :cleanup_interval_ms)
max_size = Keyword.fetch!(opts, :max_size)

:ets.new(table, [
:named_table,
Expand All @@ -165,7 +175,8 @@ defmodule X402.Extensions.SIWX.ETSStorage do
{:ok,
%{
table: table,
cleanup_interval_ms: cleanup_interval_ms
cleanup_interval_ms: cleanup_interval_ms,
max_size: max_size
}}
end

Expand All @@ -191,11 +202,22 @@ defmodule X402.Extensions.SIWX.ETSStorage do

def handle_call({:put, address, resource, payment_proof, ttl_ms}, _from, state) do
key = {address, resource}
expires_at_ms = now_ms() + ttl_ms

true = :ets.insert(state.table, {key, payment_proof, expires_at_ms})

{:reply, :ok, state}
current_size = :ets.info(state.table, :size)

# Enforce size cap to prevent unbounded ETS memory growth under spam attacks.
# An attacker who generates unique (address, resource) pairs would grow the
# table without limit until the node OOMs. We check if the key already
# exists (an update never grows the table) and only reject genuinely new
# insertions that would push us over the limit.
already_exists = :ets.member(state.table, key)

if not already_exists and current_size >= state.max_size do
{:reply, {:error, :storage_full}, state}
else
expires_at_ms = now_ms() + ttl_ms
true = :ets.insert(state.table, {key, payment_proof, expires_at_ms})
{:reply, :ok, state}
end
end

def handle_call({:delete, address, resource}, _from, state) do
Expand Down
128 changes: 101 additions & 27 deletions lib/x402/payment_signature.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ defmodule X402.PaymentSignature do

@required_fields ~w(transactionHash network scheme payerWallet)

# EIP-55 Ethereum address: 0x followed by exactly 40 hex characters (case-insensitive).
# We accept mixed-case (checksummed) and lower-case (normalised) addresses.
@eth_address_regex ~r/^0x[0-9a-fA-F]{40}$/

# Transaction hash: 0x followed by exactly 64 hex characters (256-bit hash).
# Solana tx IDs are base58, 87-88 chars β€” we detect them by absence of "0x" prefix.
@eth_tx_hash_regex ~r/^0x[0-9a-fA-F]{64}$/

# Solana base-58 transaction signature (87–88 characters of base58 alphabet).
@solana_tx_sig_regex ~r/^[1-9A-HJ-NP-Za-km-z]{87,88}$/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solana tx signature regex rejects valid short signatures

Low Severity

@solana_tx_sig_regex restricts Solana transaction signatures to exactly {87,88} base58 characters, but valid 64-byte Ed25519 signatures with leading zero bytes produce shorter base58 encodings (e.g., 86 characters with one leading zero byte). About ~0.4% of valid signatures could be incorrectly rejected. The proper approach (per Solana's own codebase) is to decode the base58 and verify the result is exactly 64 bytes, or widen the length range.

Fix in CursorΒ Fix in Web


# Solana wallet address: base58, 32-44 characters.
@solana_address_regex ~r/^[1-9A-HJ-NP-Za-km-z]{43,44}$/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wallet address regexes duplicated from Wallet module

Low Severity

@eth_address_regex and @solana_address_regex are identical copies of @evm_regex and @solana_regex from X402.Wallet, which already exposes valid_evm?/1 and valid_solana?/1 as public API. The validate_payer_wallet/1 function performs the exact same check as Wallet.valid?/1. If address format rules are updated in Wallet, the duplicated regexes here could silently diverge.

Fix in CursorΒ Fix in Web


# Single source of truth for the 8 KB decode guard β€” see X402.Header.
@max_header_bytes X402.Header.max_header_bytes()

Expand All @@ -31,6 +45,7 @@ defmodule X402.PaymentSignature do
:invalid_payload
| {:missing_fields, [String.t()]}
| {:invalid_upto_payment, upto_validation_error()}
| {:invalid_format, [{field :: String.t(), reason :: atom()}]}

@type decode_and_validate_error :: decode_error() | validate_error()

Expand All @@ -52,7 +67,7 @@ defmodule X402.PaymentSignature do

## Examples

iex> payload = %{"transactionHash" => "0xabc", "network" => "eip155:8453", "scheme" => "exact", "payerWallet" => "0x1111111111111111111111111111111111111111"}
iex> payload = %{"transactionHash" => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "network" => "eip155:8453", "scheme" => "exact", "payerWallet" => "0x1111111111111111111111111111111111111111"}
iex> value = payload |> Jason.encode!() |> Base.encode64()
iex> X402.PaymentSignature.decode(value)
{:ok, payload}
Expand Down Expand Up @@ -124,7 +139,7 @@ defmodule X402.PaymentSignature do
## Examples

iex> payload = %{
...> "transactionHash" => "0xabc",
...> "transactionHash" => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
...> "network" => "eip155:8453",
...> "scheme" => "exact",
...> "payerWallet" => "0x1111111111111111111111111111111111111111"
Expand Down Expand Up @@ -152,30 +167,35 @@ defmodule X402.PaymentSignature do
"""
@spec validate(map(), map()) :: {:ok, map()} | {:error, validate_error()}
def validate(payload, requirements) when is_map(payload) and is_map(requirements) do
missing = missing_fields(payload)

case missing do
[] ->
case validate_scheme(payload, requirements) do
:ok ->
result = {:ok, payload}

Telemetry.emit(:payment_signature, :validate, :ok, %{
required_fields: @required_fields
})

result
with :ok <- check_missing_fields(payload),
:ok <- check_field_formats(payload) do
case validate_scheme(payload, requirements) do
:ok ->
Telemetry.emit(:payment_signature, :validate, :ok, %{required_fields: @required_fields})
{:ok, payload}

{:error, {:invalid_upto_payment, reason}} = error ->
Telemetry.emit(:payment_signature, :validate, :error, %{
reason: :invalid_upto_payment,
detail: reason
})

error
end
end
end

{:error, {:invalid_upto_payment, reason}} = error ->
Telemetry.emit(:payment_signature, :validate, :error, %{
reason: :invalid_upto_payment,
detail: reason
})
def validate(_payload, _requirements) do
Telemetry.emit(:payment_signature, :validate, :error, %{reason: :invalid_payload})
{:error, :invalid_payload}
end

error
end
defp check_missing_fields(payload) do
case missing_fields(payload) do
[] ->
:ok

_ ->
missing ->
Telemetry.emit(:payment_signature, :validate, :error, %{
reason: :missing_fields,
fields: missing
Expand All @@ -185,9 +205,19 @@ defmodule X402.PaymentSignature do
end
end

def validate(_payload, _requirements) do
Telemetry.emit(:payment_signature, :validate, :error, %{reason: :invalid_payload})
{:error, :invalid_payload}
defp check_field_formats(payload) do
case validate_field_formats(payload) do
[] ->
:ok

format_errors ->
Telemetry.emit(:payment_signature, :validate, :error, %{
reason: :invalid_format,
fields: Enum.map(format_errors, &elem(&1, 0))
})

{:error, {:invalid_format, format_errors}}
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing rejection_error clause for new invalid_format error

Medium Severity

The new {:invalid_format, ...} error variant from check_field_formats has no dedicated rejection_error clause in payment_gate.ex. It falls through to the catch-all, returning "payment verification failed" instead of the more accurate "invalid payment payload" that other validation errors like {:missing_fields, _} and {:invalid_upto_payment, _} return. This gives clients a misleading error message for format failures.

Fix in CursorΒ Fix in Web

end

Comment thread
cursor[bot] marked this conversation as resolved.
@doc since: "0.1.0", group: :verification
Expand All @@ -196,7 +226,7 @@ defmodule X402.PaymentSignature do

## Examples

iex> payload = %{"transactionHash" => "0xabc", "network" => "eip155:8453", "scheme" => "exact", "payerWallet" => "0x1111111111111111111111111111111111111111"}
iex> payload = %{"transactionHash" => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "network" => "eip155:8453", "scheme" => "exact", "payerWallet" => "0x1111111111111111111111111111111111111111"}
iex> value = payload |> Jason.encode!() |> Base.encode64()
iex> X402.PaymentSignature.decode_and_validate(value)
{:ok, payload}
Expand Down Expand Up @@ -240,6 +270,50 @@ defmodule X402.PaymentSignature do
|> Enum.sort()
end

# Validates the format of fields that have known structural constraints:
# - transactionHash: EVM 0x+64hex OR Solana base58 87-88 chars
# - payerWallet: EVM 0x+40hex OR Solana base58 32-44 chars
#
# We intentionally do NOT validate `network` and `scheme` here β€” those fields
# are validated downstream by the facilitator / scheme validators which have
# the authoritative list of supported values.
#
# Returns a list of {field, reason} tuples for each invalid field; empty list = ok.
@spec validate_field_formats(map()) :: [{String.t(), atom()}]
defp validate_field_formats(payload) do
[
validate_transaction_hash(Map.get(payload, "transactionHash")),
validate_payer_wallet(Map.get(payload, "payerWallet"))
]
|> Enum.reject(&is_nil/1)
end

@spec validate_transaction_hash(String.t() | nil) :: {String.t(), atom()} | nil
defp validate_transaction_hash(nil), do: nil

defp validate_transaction_hash(hash) when is_binary(hash) do
cond do
Regex.match?(@eth_tx_hash_regex, hash) -> nil
Regex.match?(@solana_tx_sig_regex, hash) -> nil
true -> {"transactionHash", :invalid_format}
end
end

defp validate_transaction_hash(_), do: {"transactionHash", :invalid_format}

@spec validate_payer_wallet(String.t() | nil) :: {String.t(), atom()} | nil
defp validate_payer_wallet(nil), do: nil

defp validate_payer_wallet(wallet) when is_binary(wallet) do
cond do
Regex.match?(@eth_address_regex, wallet) -> nil
Regex.match?(@solana_address_regex, wallet) -> nil
true -> {"payerWallet", :invalid_format}
end
end

defp validate_payer_wallet(_), do: {"payerWallet", :invalid_format}

@spec validate_scheme(map(), map()) ::
:ok | {:error, {:invalid_upto_payment, upto_validation_error()}}
defp validate_scheme(payload, requirements) do
Expand Down
18 changes: 9 additions & 9 deletions test/x402/payment_signature_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ defmodule X402.PaymentSignatureTest do
describe "decode/1" do
test "decodes a valid base64 json payload" do
payload = %{
"transactionHash" => "0xabc",
"transactionHash" => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"network" => "eip155:8453",
"scheme" => "exact",
"payerWallet" => "0x1111111111111111111111111111111111111111"
Expand Down Expand Up @@ -58,7 +58,7 @@ defmodule X402.PaymentSignatureTest do
describe "validate/1" do
test "returns ok for complete payload" do
payload = %{
"transactionHash" => "0xabc",
"transactionHash" => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"network" => "eip155:8453",
"scheme" => "exact",
"payerWallet" => "0x1111111111111111111111111111111111111111"
Expand Down Expand Up @@ -92,7 +92,7 @@ defmodule X402.PaymentSignatureTest do

test "validates upto payments when value is within maxPrice from payload" do
payload = %{
"transactionHash" => "0xabc",
"transactionHash" => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"network" => "eip155:8453",
"scheme" => "upto",
"payerWallet" => "0x1111111111111111111111111111111111111111",
Expand All @@ -107,7 +107,7 @@ defmodule X402.PaymentSignatureTest do
describe "validate/2" do
test "validates upto payments when value is within requirements maxPrice" do
payload = %{
"transactionHash" => "0xabc",
"transactionHash" => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"network" => "eip155:8453",
"scheme" => "upto",
"payerWallet" => "0x1111111111111111111111111111111111111111",
Expand All @@ -121,7 +121,7 @@ defmodule X402.PaymentSignatureTest do

test "rejects upto payments when value exceeds maxPrice" do
payload = %{
"transactionHash" => "0xabc",
"transactionHash" => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"network" => "eip155:8453",
"scheme" => "upto",
"payerWallet" => "0x1111111111111111111111111111111111111111",
Expand All @@ -136,7 +136,7 @@ defmodule X402.PaymentSignatureTest do

test "rejects upto payments when value is missing" do
payload = %{
"transactionHash" => "0xabc",
"transactionHash" => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"network" => "eip155:8453",
"scheme" => "upto",
"payerWallet" => "0x1111111111111111111111111111111111111111"
Expand All @@ -150,7 +150,7 @@ defmodule X402.PaymentSignatureTest do

test "returns invalid_payload for non-map requirements" do
payload = %{
"transactionHash" => "0xabc",
"transactionHash" => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"network" => "eip155:8453",
"scheme" => "upto",
"payerWallet" => "0x1111111111111111111111111111111111111111",
Expand All @@ -164,7 +164,7 @@ defmodule X402.PaymentSignatureTest do
describe "decode_and_validate/1" do
test "returns ok for valid encoded payload" do
payload = %{
"transactionHash" => "0xabc",
"transactionHash" => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"network" => "eip155:8453",
"scheme" => "exact",
"payerWallet" => "0x1111111111111111111111111111111111111111"
Expand All @@ -191,7 +191,7 @@ defmodule X402.PaymentSignatureTest do
describe "decode_and_validate/2" do
test "validates upto scheme against requirements" do
payload = %{
"transactionHash" => "0xabc",
"transactionHash" => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"network" => "eip155:8453",
"scheme" => "upto",
"payerWallet" => "0x1111111111111111111111111111111111111111",
Expand Down
Loading
Loading