Skip to content

fix(remittance_nft): enforce the ipfs:// / https:// metadata URI prefix (#1145) - #1951

Merged
K1NGD4VID merged 5 commits into
LabsCrypt:mainfrom
Banx17:fix/1145-validate-metadata-uri-prefix
Oct 7, 2026
Merged

K1NGD4VID merged 5 commits into
LabsCrypt:mainfrom
Banx17:fix/1145-validate-metadata-uri-prefix

Conversation

@Banx17

@Banx17 Banx17 commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1145

What was wrong

RemittanceNFT::validate_metadata_uri (contracts/remittance_nft/src/lib.rs) claimed, in both its
doc 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 dead
    bindings: constructed and immediately discarded, unused and unused-flagged.
  • The only real rule was 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.

  • Both dead bindings are replaced by has_accepted_metadata_uri_prefix, which reads the URI's
    leading bytes and requires an exact b"ipfs://" or b"https://" prefix.
  • Mechanism: soroban_sdk::String cannot be sliced, and String::copy_into_slice insists on a
    buffer matching the whole string, so the leading bytes are read with
    EnvBase::string_copy_to_slice - the very host function copy_into_slice delegates to - into a
    fixed 8-byte stack buffer. read_len = min(uri.len(), 8) plus the early uri.len() < 7 exit
    guarantee the copy can never read out of range or trap. No new dependency, no new error variant,
    no allocation.
  • Rejection follows this function's existing convention: Err(NftError::InvalidMetadataUri).
  • The rule lives in the single shared helper, so all real callers get it identically. Call sites
    (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 or
    duplicates the check.
  • Full RFC 3986 parsing, content addressing and CID validation stay out of scope, as the issue says.
    A simple prefix check is what is implemented - nothing is over-engineered.

Decision on the old uri.len() < 8 floor: KEPT

if uri.len() < Self::LONGEST_URI_PREFIX_LEN as u32 {
    return Err(NftError::InvalidMetadataUri);
}

Kept, and now expressed through the new LONGEST_URI_PREFIX_LEN = 8 constant ("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 was
rejected 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):

new test what it proves
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 with NftError::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_uris ipfs://QmTest123 and https://example.com/metadata/1.json both mint and are stored verbatim.
test_mint_rejects_metadata_uri_prefix_lookalikes IPFS://..., HTTPS://..., ipfs:/..., ipfs/..., https:/..., http://..., ftp://..., ipfs://..., metadata.json are all rejected (every one is 8+ bytes, i.e. all accepted before this change).
test_mint_enforces_metadata_uri_minimum_length bare ipfs:// is still rejected by the kept floor, and bare https:// (8 bytes, the longest accepted prefix) is the shortest accepted URI - documents the boundary.
test_admin_remint_enforces_metadata_uri_prefix admin_remint shares the check, the rejection is side-effect free (the one-time re-mint approval is not consumed), and the retry with ipfs://... succeeds.
test_update_metadata_uri_enforces_metadata_uri_prefix update_metadata_uri shares the check and a rejected update leaves the previously stored URI untouched.

No existing test fixture needed changing: the suite's create_test_uri helper already returns an
ipfs:// URI, and the whole crate suite is green, so no other test relied on the old permissive
behaviour.

"Done when" checklist

  • dead _ipfs_prefix / _https_prefix bindings removed - they no longer exist, so no unused
    binding/dead-code warnings remain (cargo clippy -p remittance_nft --all-targets -- -D warnings
    is clean).
  • a real ipfs:///https:// prefix check is enforced, uniformly, across mint,
    admin_remint and update_metadata_uri.
  • the doc comment now describes what is actually enforced (with the out-of-scope boundary stated).
  • test proving a non-ipfs/https URI is rejected (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 main

contracts/remittance_nft/src/test.rs defined test_transfer_rejects_burned_destination() twice
(lines 1230 and 1327), so no test in this crate could build or run on main:

error[E0428]: the name `test_transfer_rejects_burned_destination` is defined multiple times
    --> remittance_nft\src\test.rs:1327:1
     |
1230 | fn test_transfer_rejects_burned_destination() {
     | --------------------------------------------- previous definition of the value `test_transfer_rejects_burned_destination` here
1327 | fn test_transfer_rejects_burned_destination() {
     | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `test_transfer_rejects_burned_destination` redefined here

The two bodies exercise different paths (auto-burn reached via a lowered burn threshold +
record_default, vs. an explicit burn), so both are kept and the first is renamed to
test_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 test
run is impossible.

Commands run locally, with real output

$ cargo test -p remittance_nft --lib
test test::test_admin_remint_enforces_metadata_uri_prefix ... ok
test test::test_mint_accepts_ipfs_and_https_metadata_uris ... ok
test test::test_mint_enforces_metadata_uri_minimum_length ... ok
test test::test_mint_rejects_metadata_uri_prefix_lookalikes ... ok
test test::test_mint_rejects_metadata_uri_without_accepted_prefix ... ok
test test::test_transfer_rejects_auto_burned_destination ... ok
test test::test_update_metadata_uri_enforces_metadata_uri_prefix ... ok
...
test result: ok. 94 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.34s

(The plain cargo test -p remittance_nft also builds the cdylib artifact on this Windows host,
where the link step fails with ld: error: export ordinal too large: 68827; --lib runs the same
test binary without that host-specific artifact. It is a Windows linker limitation, not a test
failure.)

$ cargo clippy -p remittance_nft --all-targets -- -D warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.34s

=> clean, no warnings, and no trace of the old _ipfs_prefix/_https_prefix bindings.

$ cargo fmt --all -- --check
Diff in ...\contracts\lending_pool\src\lib.rs:686:
Diff in ...\contracts\lending_pool\src\lib.rs:699:
Diff in ...\contracts\loan_manager\src\lib.rs:1792:
Diff in ...\contracts\loan_manager\src\test.rs:5774:

=> the only files with formatting diffs are files this PR does not touch. (Verified by checking out
upstream/main (f40ae474) in a pristine git worktree and re-running the same command: it reports
exactly the same four diffs, so they are pre-existing. Both files changed here are fmt-clean.)

$ cargo clippy --all-targets -- -D warnings     # workspace-wide, run for completeness
error[E0599]: no method named `get_total_due` found for struct `LoanManagerClient` in the current scope
    --> loan_manager\src\test.rs:5601:29
error[E0061]: this method takes 2 arguments but 3 arguments were supplied
    --> loan_manager\src\test.rs:5677:13

=> loan_manager's test target does not compile on main either (git show upstream/main:contracts/loan_manager/src/test.rs contains those exact lines), so workspace-wide
clippy/cargo test cannot be green regardless of this PR. It is unrelated to this change and left
alone rather than widening the blast radius. The scoped
cargo clippy -p remittance_nft --all-targets -- -D warnings above is the check that covers this PR.

Files changed

  • contracts/remittance_nft/src/lib.rs - new LONGEST_URI_PREFIX_LEN constant, new
    has_accepted_metadata_uri_prefix helper, validate_metadata_uri now enforces the prefix and has
    an accurate doc comment; EnvBase, Val added to the existing soroban_sdk import list.
  • contracts/remittance_nft/src/test.rs - 6 new tests plus the one-line rename of the duplicated
    test that blocked the test target.

Notes / follow-ups (out of scope, not changed here)

Why this PR also carries two small backend/ commits (CI unblock, disclosed up front)

The backend job in .github/workflows/ci.yml has no path filter: on every PR it runs
npm run lint, build, typecheck, the jest suite and a PII scan across the whole backend. At this
branch's base (f40ae474) that job is already red for reasons unrelated to this change, so no
PR touching any part of the repo can be green:

Reproduce: git diff --name-only upstream/main..HEAD | grep -c '^backend/' -> 0 on the review
commit 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) and
test(backend): repair the 9 stale tests that keep the backend job red (3 files). Whichever of the
two 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 lint reports
0 errors, 30 warnings (warnings do not fail the job; the 8 errors did) and npm run typecheck is
clean. 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.

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
Banx17 added 2 commits October 6, 2026 10:47
…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
@K1NGD4VID
K1NGD4VID merged commit a3f83bc into LabsCrypt:main Oct 7, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Contracts] RemittanceNFT validate_metadata_uri ignores the documented ipfs/https prefix check and leaves two dead bindings

2 participants