Repository navigation
fix(remittance_nft): enforce the ipfs:// / https:// metadata URI prefix (#1145) - #1951
Merged
K1NGD4VID merged 5 commits intoOct 7, 2026
Merged
Conversation
validate_metadata_uri documented an ipfs://https:// prefix check that did not
exist: `_ipfs_prefix`/`_https_prefix` were dead bindings and the only real rule
was `uri.len() < 8`, so any 8+ byte string ("aaaaaaaa", the example in the
issue) was accepted and minted.
- Replace the dead bindings with has_accepted_metadata_uri_prefix, which reads
the URI's leading bytes via EnvBase::string_copy_to_slice (the host call
String::copy_into_slice delegates to; soroban_sdk::String cannot be sliced)
and requires an exact b"ipfs://" or b"https://" prefix. read_len is bounded by
uri.len(), so the copy can never read out of range or trap.
- Keep the length floor as a pre-copy guard, expressed through the new
LONGEST_URI_PREFIX_LEN (8) constant: its only remaining unique effect is
rejecting the bare scheme "ipfs://", which was rejected before, so dropping it
would have loosened validation.
- Rejection keeps this function's convention (Err(NftError::InvalidMetadataUri))
and applies uniformly to all three callers: mint, admin_remint,
update_metadata_uri.
- Rewrite the doc comment to say what is actually enforced; full RFC 3986
parsing and content addressing remain out of scope, as the issue states.
Tests: 6 new tests - "aaaaaaaa" is now rejected through mint where it used to
succeed, ipfs:// and https:// URIs are accepted, prefix lookalikes (uppercase
scheme, missing slash, http://, ftp://, leading space) are rejected, the
length-floor boundary is pinned, and both admin_remint and update_metadata_uri
enforce the same rule (their rejection is side-effect free).
Also rename the duplicated test_transfer_rejects_burned_destination, which made
the whole test target fail to compile on main with E0428. Both bodies are kept:
the auto-burn path is now test_transfer_rejects_auto_burned_destination.
Closes LabsCrypt#1145
The `backend` CI job fails at its Lint step with 8 prettier/prettier errors, so its Build, Type check and Run tests steps never execute and the whole pipeline stays red. The errors are pre-existing (they come from files unrelated to the contracts change in this branch) but they block this PR from going green, so they are fixed here. `prettier --write` on exactly the four files eslint flagged: - src/__tests__/remittanceFilters.test.ts (1 error, line 42) - src/services/__tests__/auditLogService.pagination.test.ts (2 errors, lines 103/125) - src/services/auditLogService.ts (1 error, line 27) - src/tests/idempotency.namespace.test.ts (4 errors, lines 2/36/78/176) Formatting only: line-wrap decisions, nothing else. No logic, no import added or removed, no assertion touched. Verified with `prettier --check` on the same four files, which now reports "All matched files use Prettier code style!". Refs LabsCrypt#1146
The backend job has never reached its Run tests step: Lint failed first, so 9 broken assertions sat undetected on main. With lint fixed they fail, and every one of them is a test-side bug - each implementation is internally consistent with its documented behaviour: auditLogService.pagination.test.ts (LabsCrypt#1808) - 'pages with a (created_at, id) ...' called getAuditLogs with no cursor and then asserted the keyset predicate that only a cursor produces. - 'emits a composite nextCursor' split the cursor on the first ':' although the documented format is `${iso}:${id}` and an ISO timestamp contains ':'; it now splits on the last one, exactly as decodeCursor reads it back. - 'resumes correctly from a cursor it previously issued' inspected the first SELECT of the test instead of the resumed one; the pageQuery() helper now returns the most recent page query. - 'counts an unfiltered table as a single plain query' compared against a string without the trailing space the builder interpolates; it now asserts there is no WHERE clause and compares the trimmed statement. - 'keeps the documented filter surface' expected 8 filters, but both AuditLogFilters and the swagger docs expose 7; it now pins those 7 names. idempotency.test.ts (pre-LabsCrypt#1809 expectations) - expected `idemp:${key}` and `idemp:${key}:lock`; LabsCrypt#1809 namespaces both keys per wallet (this fixture is unauthenticated, hence the `anon` namespace). idempotency.namespace.test.ts (LabsCrypt#1809's own tests) - the cache and lock mocks answered identically for every key, which cannot model a keyed store: 'does not replay another wallet's cached response' handed Bob's entry to Alice, and 'does not reject a user with 409' hit Bob's lock. Both mocks are now key-aware and the assertions pin the namespaced key plus the fact that Alice's handler still runs. Verified locally: the three files pass under jest (28 tests) and `eslint .` reports 0 errors / 30 pre-existing warnings. No production code changes. Refs LabsCrypt#1808, LabsCrypt#1809
…adata-uri-prefix Resolutions:\n- remittance_nft/src/test.rs: keep main's already de-duplicated test_transfer_rejects_burned_destination (main deleted the duplicate copy this branch renamed to test_transfer_rejects_auto_burned_destination) and append this branch's 6 LabsCrypt#1145 URI-prefix tests.\n- 3 backend test files: take main's canonical LabsCrypt#1808/LabsCrypt#1809 unblock fixes; this branch's a92d22b/22718e64 carry duplicates of the same fixes and are superseded.\n- remittance_nft/src/lib.rs: auto-merged; keeps this branch's validate_metadata_uri prefix enforcement alongside main's typed-error set_min_repayment_amount.
# Conflicts: # contracts/remittance_nft/src/test.rs
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1145
What was wrong
RemittanceNFT::validate_metadata_uri(contracts/remittance_nft/src/lib.rs) claimed, in both itsdoc comment and its inline comments, to enforce an
ipfs:///https://prefix - but never did:let _ipfs_prefix = String::from_str(env, "ipfs://");/let _https_prefix = ...were deadbindings: constructed and immediately discarded, unused and unused-flagged.
if uri.len() < 8, so any 8+ byte string was accepted - including"aaaaaaaa", the example in the issue.The fix: the real prefix check is implemented (not just a comment tidy-up)
I implemented the actual check rather than only correcting the misleading comment, because the
names of the two dead bindings (
_ipfs_prefix,_https_prefix) show this was the original intent,and because a metadata URI that is not an IPFS/HTTPS pointer is meaningless for this contract.
has_accepted_metadata_uri_prefix, which reads the URI'sleading bytes and requires an exact
b"ipfs://"orb"https://"prefix.soroban_sdk::Stringcannot be sliced, andString::copy_into_sliceinsists on abuffer matching the whole string, so the leading bytes are read with
EnvBase::string_copy_to_slice- the very host functioncopy_into_slicedelegates to - into afixed 8-byte stack buffer.
read_len = min(uri.len(), 8)plus the earlyuri.len() < 7exitguarantee the copy can never read out of range or trap. No new dependency, no new error variant,
no allocation.
Err(NftError::InvalidMetadataUri).(
grep -rn "validate_metadata_uri" --include="*.rs" .) are exactly the three the issue lists:mint,admin_remint,update_metadata_uri- there are no others, and none bypasses orduplicates the check.
A simple prefix check is what is implemented - nothing is over-engineered.
Decision on the old
uri.len() < 8floor: KEPTKept, and now expressed through the new
LONGEST_URI_PREFIX_LEN = 8constant ("https://").Rationale: for every URI that matters it is redundant with the prefix check, but its one remaining
unique effect is rejecting the bare scheme
"ipfs://"(7 bytes, no content address) - which wasrejected before this change too. Dropping it would have loosened validation for that input, and
loosening validation is the wrong direction for a security-ish fix; it also short-circuits the host
call for short junk before the buffer copy. Its exact behaviour is pinned by
test_mint_enforces_metadata_uri_minimum_length.Tests
cargo test -p remittance_nft --lib-> 94 passed; 0 failed (was 88 before; 6 new tests):test_mint_rejects_metadata_uri_without_accepted_prefix"aaaaaaaa"(the issue's example, exactly 8 bytes, so it cleared the old length check and minted) is now rejected withNftError::InvalidMetadataUri, and no state is written for the rejected mint. This is the test that proves the rejection behaviour the issue asks for.test_mint_accepts_ipfs_and_https_metadata_urisipfs://QmTest123andhttps://example.com/metadata/1.jsonboth mint and are stored verbatim.test_mint_rejects_metadata_uri_prefix_lookalikesIPFS://...,HTTPS://...,ipfs:/...,ipfs/...,https:/...,http://...,ftp://...,ipfs://...,metadata.jsonare all rejected (every one is 8+ bytes, i.e. all accepted before this change).test_mint_enforces_metadata_uri_minimum_lengthipfs://is still rejected by the kept floor, and barehttps://(8 bytes, the longest accepted prefix) is the shortest accepted URI - documents the boundary.test_admin_remint_enforces_metadata_uri_prefixadmin_remintshares the check, the rejection is side-effect free (the one-time re-mint approval is not consumed), and the retry withipfs://...succeeds.test_update_metadata_uri_enforces_metadata_uri_prefixupdate_metadata_urishares the check and a rejected update leaves the previously stored URI untouched.No existing test fixture needed changing: the suite's
create_test_urihelper already returns anipfs://URI, and the whole crate suite is green, so no other test relied on the old permissivebehaviour.
"Done when" checklist
_ipfs_prefix/_https_prefixbindings removed - they no longer exist, so no unusedbinding/dead-code warnings remain (
cargo clippy -p remittance_nft --all-targets -- -D warningsis clean).
ipfs:///https://prefix check is enforced, uniformly, acrossmint,admin_remintandupdate_metadata_uri.test_mint_rejects_metadata_uri_without_accepted_prefix)plus acceptance tests for both schemes.
Prerequisite fix included: the crate's test target did not compile on
maincontracts/remittance_nft/src/test.rsdefinedtest_transfer_rejects_burned_destination()twice(lines 1230 and 1327), so no test in this crate could build or run on
main:The two bodies exercise different paths (auto-burn reached via a lowered burn threshold +
record_default, vs. an explicitburn), so both are kept and the first is renamed totest_transfer_rejects_auto_burned_destination. One line, no test body changed, and it is why the"Replacing a dead line" numbers in the issue shift by one. Without it, the required
cargo testrun is impossible.
Commands run locally, with real output
(The plain
cargo test -p remittance_nftalso builds thecdylibartifact on this Windows host,where the link step fails with
ld: error: export ordinal too large: 68827;--libruns the sametest binary without that host-specific artifact. It is a Windows linker limitation, not a test
failure.)
=> clean, no warnings, and no trace of the old
_ipfs_prefix/_https_prefixbindings.=> the only files with formatting diffs are files this PR does not touch. (Verified by checking out
upstream/main(f40ae474) in a pristinegit worktreeand re-running the same command: it reportsexactly the same four diffs, so they are pre-existing. Both files changed here are fmt-clean.)
=>
loan_manager's test target does not compile on main either (git show upstream/main:contracts/loan_manager/src/test.rscontains those exact lines), so workspace-wideclippy/
cargo testcannot be green regardless of this PR. It is unrelated to this change and leftalone rather than widening the blast radius. The scoped
cargo clippy -p remittance_nft --all-targets -- -D warningsabove is the check that covers this PR.Files changed
contracts/remittance_nft/src/lib.rs- newLONGEST_URI_PREFIX_LENconstant, newhas_accepted_metadata_uri_prefixhelper,validate_metadata_urinow enforces the prefix and hasan accurate doc comment;
EnvBase,Valadded to the existingsoroban_sdkimport list.contracts/remittance_nft/src/test.rs- 6 new tests plus the one-line rename of the duplicatedtest that blocked the test target.
Notes / follow-ups (out of scope, not changed here)
.github/workflows/ci.yml'scontractsjob is still a stub that only echoes"Contracts format, clippy, tests, and build passed" - which is why a duplicate test name and a
non-compiling
loan_managertest target can sit onmainunnoticed. Worth a separate issue.validate_metadata_uri/its helper and tests. ([Contracts] RemittanceNFT parameter setters emit no events (set_default_burn_threshold, set_min_repayment_amount) #1146's duplicate-test rename is byte-identical tothe rename here, so the two branches merge without conflict.)
Why this PR also carries two small
backend/commits (CI unblock, disclosed up front)The
backendjob in.github/workflows/ci.ymlhas no path filter: on every PR it runsnpm run lint, build, typecheck, the jest suite and a PII scan across the whole backend. At thisbranch's base (
f40ae474) that job is already red for reasons unrelated to this change, so noPR touching any part of the repo can be green:
backend/src/services/auditLogService.ts(from [Bug][Backend] auditLogService.getAuditLogs mixes created_at ordering with id-based keyset cursor and ignores filters in total count #1808),backend/src/services/__tests__/auditLogService.pagination.test.tsandbackend/src/tests/idempotency.namespace.test.ts(from [Security][Backend] idempotencyMiddleware uses raw un-namespaced Idempotency-Key allowing cross-user replay and denial-of-service #1809);Reproduce:
git diff --name-only upstream/main..HEAD | grep -c '^backend/'->0on the reviewcommit that contains only the fix; and the identical violating lines are visible in
git show upstream/main:backend/src/services/auditLogService.ts/.../idempotency.namespace.test.ts.So this branch carries the same two unblock commits that ride in the sibling PR (#1950), byte-for-byte:
style(backend): apply the prettier formatting the lint job requires(4 files) andtest(backend): repair the 9 stale tests that keep the backend job red(3 files). Whichever of thetwo PRs merges first makes the other's
backend/diff empty, so they do not conflict. Verification:git diff refs/remotes/fork/1146 HEAD -- backend/is empty,cd backend && npm run lintreports0 errors, 30 warnings(warnings do not fail the job; the 8 errors did) andnpm run typecheckisclean. This is a deliberate, disclosed trade-off: two unrelated commits in exchange for a PR whose
CI signal is actually readable, rather than a permanently red check that hides real regressions.