fix: medium severity issues #140, #149, #150, #153#162
Conversation
…onfig, ledger index - Close SSRF IPv6 gap: use to_ipv4() instead of to_ipv4_mapped() to block IPv4-compatible addresses like ::10.0.0.1 and ::127.0.0.1. Replace manual bitmask checks with stable std methods (is_unique_local, is_unicast_link_local, is_multicast). - Return 202 Accepted from create_export_job() instead of 200 OK, matching HTTP semantics for async operations. - Return 500 Internal Server Error when SPECTRAPLEX_API_KEY is not configured, distinguishing server misconfiguration from client auth failures (401). - Add migration for index on ledger_entries.transaction_id to improve JOIN performance between bronze and silver layers. Addresses #140, #149, #150, #153. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
user1303836
left a comment
There was a problem hiding this comment.
Review: fix: medium severity issues #140, #149, #150, #153
Good work overall. Three of the four fixes are correct and well-tested. One fix has a migration ordering issue that will break existing deployed databases and must be fixed before merging.
1. SSRF IPv4-compatible bypass (#140 follow-up) — Correct, with one note
The fix is correct and important. Switching from to_ipv4_mapped() to to_ipv4() closes a real SSRF bypass vector. to_ipv4_mapped() only catches ::ffff:x.x.x.x (IPv4-mapped), but to_ipv4() also catches ::x.x.x.x (IPv4-compatible), meaning an attacker could previously bypass SSRF protection by submitting a callback URL with an IPv4-compatible address like ::10.0.0.1 or ::127.0.0.1.
The std method replacements are correct. is_unique_local() (stable since 1.84.0), is_unicast_link_local() (stable since 1.84.0), and is_multicast() (stable since 1.7.0) are all available on rustc 1.92.0 and semantically equivalent to the manual bitmask checks they replace:
is_unique_local()checksfc00::/7— same as(seg0 & 0xfe00) == 0xfc00is_unicast_link_local()checksfe80::/10— same as(seg0 & 0xffc0) == 0xfe80is_multicast()checksff00::/8— same as(seg0 & 0xff00) == 0xff00
Edge case: ::1 and to_ipv4(). to_ipv4() converts ::1 (IPv6 loopback) to Ipv4Addr::new(0, 0, 0, 1), which is NOT matched by any of the IPv4 private checks (is_loopback, is_private, etc.). However, ::1 is already caught by v6.is_loopback() at the top of the IPv6 branch, so this is safe. The new test cases for ::10.0.0.1, ::127.0.0.1, and ::8.8.8.8 are good and directly validate the fix.
No issues here.
2. Export jobs return 202 Accepted (#153) — Correct and thorough
The status code change is correctly applied to the single async export creation path (create_export_job). Both the return type signature and the return value are updated consistently. The synchronous export endpoints (export_ledger at line 1295 and tax_export at line 3011) correctly remain at 200 OK since they return data inline rather than spawning background tasks.
All 12 affected test assertions were updated from StatusCode::OK to StatusCode::ACCEPTED. The response body structure (ExportJobStatus with state: "pending") is unchanged, which is correct — only the HTTP status code changes.
No issues here.
3. Missing API key returns 500 (#150) — Correct
The change correctly distinguishes between two failure modes in require_auth():
- No API key configured on the server (line 413): returns 500 Internal Server Error — this is a server misconfiguration, not a client error.
- Client provides wrong/missing key (line 430): still returns 401 Unauthorized — this is a client auth failure.
The error message "API key not configured" is slightly informative about server state, but this is acceptable since: (a) the endpoint already requires authentication so only authenticated users would see it in normal operation, and (b) knowing that an API key isn't configured doesn't give an attacker actionable information. The test at test_auth_rejects_when_no_key_configured is updated to expect 500.
No issues here.
4. Ledger entries index (#149) — Migration ordering bug, must fix
The index itself is correct. ledger_entries.transaction_id is used in JOINs between the bronze (transactions) and silver (ledger_entries) layers (e.g., the wallet_address backfill in migration 20251219010000), and an index on this FK column is a standard performance improvement. IF NOT EXISTS is used for idempotency. The write performance overhead is acceptable — this is a standard B-tree index on a UUID FK column in a table that receives batch writes during normalization, not high-frequency OLTP inserts.
However, the migration filename 20260313000000 sorts BEFORE the existing 20260313100000_add_enum_check_constraints.sql. The sqlx migrator (sqlx::migrate!) applies migrations in lexicographic order by version and will error with a VersionMissing-type error when it encounters a new migration with a version lower than an already-applied one. On any database that already has 20260313100000 applied, running this new migration will fail.
Fix: Rename the migration to use a timestamp that sorts AFTER 20260313100000, for example:
20260313200000_add_ledger_entries_transaction_id_index.sql
This is a blocking issue.
Renumbers 20260313000000 -> 20260313200000 so the ledger_entries transaction_id index migration sorts after the existing 20260313100000_add_enum_check_constraints migration, preventing sqlx migrator errors on databases that already have it applied. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Entire-Checkpoint: ce309c95f26d
Summary
to_ipv4_mapped()withto_ipv4()inis_private_ip()to block IPv4-compatible addresses like::10.0.0.1and::127.0.0.1that previously bypassed SSRF protection. Also replaces manual bitmask checks with stable std methods (is_unique_local,is_unicast_link_local,is_multicast).create_export_job()now returns 202 instead of 200, matching HTTP semantics for asynchronous operations.require_auth()now returns 500 Internal Server Error whenSPECTRAPLEX_API_KEYis not configured, distinguishing server misconfiguration from client auth failures (401).idx_ledger_entries_transaction_idto improve JOIN performance between the bronze (transactions) and silver (ledger_entries) layers.Closes #149, closes #150, closes #153.
Test plan
cargo fmt --all --checkpassescargo clippy --workspace --all-targets -- -D warningspassescargo test --workspace— 294 passed, 1 pre-existing failure (test_semaphore_limits_concurrent_jobsfails on main as well)test_is_private_ip_ipv4_compatible_ipv6verifies::10.0.0.1(blocked),::127.0.0.1(blocked),::8.8.8.8(allowed)🤖 Generated with Claude Code