Skip to content

Security: freecodexyz/market

Security

SECURITY.md

Security Policy

Reporting a vulnerability

Email paoloanzn@gmail.com. Do not open an issue, a pull request, or a discussion.

Never disclose a vulnerability publicly before it has been fixed. That includes this repository's issue tracker, pull requests, forks, commit messages, and anywhere else public — social media, chat groups, write-ups, and conference talks included.

RIK binds GitHub repositories to wallets, and those keys carry a market's royalties. Anything that allows a repository's key to be minted to an address its owner does not control, or allows one repository to be paid from another's earnings, is directly exploitable once known.

You will get a reply confirming receipt. Please allow time for a fix before considering any further disclosure, and coordinate the timing in that thread.

What to include

Whatever you have. The more of this the better:

  • the contract and file, and the commit or deployed address it applies to
  • what an attacker gains, and what they need in order to do it
  • the smallest reproduction you can manage, ideally a failing Foundry test
  • the chain and transaction hash, if you observed it against a live deployment

Threat model

What the contracts are built to withstand, and what they are not.

Assumed honest

  • The GitHub Actions OIDC issuer. Tokens are taken to be unforgeable and their claims to be set by GitHub, not by the workflow. repository_id and event_name in particular.
  • The deployed GithubOidcVerifier and the key that owns it. That key can add an arbitrary signing key and therefore mint trust in a forged JWT for any repository, which makes it the highest-value secret in the system. It lives in the identity repository and is not market's to hold. test_VerifierIsTheRootOfTrust asserts this explicitly.
  • The pinned attestation workflow. RIK cannot see GitHub permissions, so .github/workflows/register-rik.yml is what decides whether a claimant controls a repository, and the contract believes its answer. It is pinned by job_workflow_ref, so changing it requires an owner transaction, and it should be reviewed like contract code. See ATTESTATION.md.
  • The RIK owner. It can repoint the attestation source at a workflow it controls and therefore mint any repository's key. This is the most powerful role in the system and belongs behind a multisig or a timelock. The deploy script hands ownership over as a two-step transfer and leaves it pending on purpose, so completing it is a deliberate act.
  • The JSON encoder that produced the payload. The claim matcher searches for an exact byte run and is only sound because " is escaped inside values. test_UnescapedQuoteInARawPayloadWouldForgeTheEventClaim demonstrates the failure when that does not hold.
  • The configured Doppler Airlock. It is immutable in both the launcher and the splitter, so a malicious one is a deployment error rather than an attack. The launcher still refuses a zero or sentinel asset from it.

Assumed hostile

  • Everyone submitting a registration. register is permissionless by design; the proof names its own beneficiary through aud.
  • Everyone who opens an issue here. Registration is intentionally open. A proof binds only to the repository the workflow was able to verify, and only to the wallet named in the title.
  • Every wallet a key is minted to. _safeMint hands control to a contract receiver, so a registration can be re-entered exactly there. Re-entering for the same repository is refused; re-entering for another one is legitimate and stays allowed.
  • Every pool and every token the splitter touches. Pools may lie, pay nothing, take tokens, or call back in. Accrual is a measured balance delta, subtraction is checked, and the collect path is guarded.
  • The splitter owner. It can sweep the Airlock's unattributable integrator fees and nothing else. It cannot reach a repository bucket, and test_CollectIntegratorFeesCannotTouchRepositoryBuckets and the system invariants hold it to that.

Out of scope

  • The RSA private key in test/fixtures/load-fixture.mjs. Committed intentionally so the JWT fixtures are reproducible. It signs nothing outside this repository's test suite and is not a secret.
  • Anyone paying for another account's registration. This is intended behaviour for a permissionless register; the proof binds only to the wallet it names.
  • A repository owner choosing a wallet they do not control. The wallet comes from the issue title, so it is chosen by the claimant through GitHub and is publicly attributable. The contracts cannot know better.
  • An admin registering a repository to their own wallet rather than the organisation's. Any account holding admin at the time of the claim can make it. The claimant is recorded on-chain, so the decision is auditable.
  • GitHub itself — the OIDC issuer, the JWKS endpoint, the Actions platform. Report those to GitHub's program.

Scope

Covered by this policy:

  • src/RIK.sol, src/RIKLauncher.sol, src/RIKRoyaltySplitter.sol, src/ClaimMatcher.sol — the contracts authored here
  • .github/workflows/register-rik.yml — the attestation workflow pinned on-chain, including anything that lets a claimant be credited with a repository they do not control, changes which wallet or repository a proof ends up naming, or exposes FCF_REGISTRAR_PRIVATE_KEY or FCF_APP_PRIVATE_KEY
  • .github/scripts/app-token.mjs — anything that mints an installation token for a repository the app is not installed on
  • script/ — anything that misdirects a deployment, in particular the predicted-address wiring between the launcher and the splitter
  • bin/market and tools/ — anything that leaks a key or misdirects an operation

Vendored from identity and reported there instead: src/GithubOidcVerifier.sol, src/IJwtVerifier.sol, src/JsonClaim.sol. They are byte-identical copies of deployed sources and must not be edited here.

How this is checked

Unit, fuzz and adversarial tests forge test
Stateful invariants test/*.invariant.t.sol
Long soak FOUNDRY_PROFILE=deep forge test
Linter forge lint
Static analysis slither .

slither.config.json filters the vendored sources, which are reviewed upstream, and disables two style detectors (assembly, cyclomatic-complexity) that fire on the hand-written scan in ClaimMatcher and the hex encoder in RIK. Both are deliberate and are held to a differential suite instead.

Everything else slither reports is accepted in place with a slither-disable-next-line and a reason next to the code:

Finding Where Why it is accepted
reentrancy-no-eth RIKLauncher.launch The asset is only known after the Airlock answers, so recording it is necessarily post-call. What that write must not do is create a second market, and the slot is reserved before the call so exclusivity holds independently of nonReentrant.
reentrancy-benign RIKRoyaltySplitter.collectPoolFees The amount to credit is the difference the call made, so crediting is necessarily post-call. Guarded, and the reentrancy is tested directly.
unused-return RIKLauncher._create Governance, timelock and migration pool are the Airlock's business; callers read them from its own events.
incorrect-equality RIKRoyaltySplitter._accrue The comparison is against a measured delta, not a balance, and only decides whether to skip a no-op write.
unused-return RIK._verifyClaims The last claim has no successor, so the cursor it returns has nothing to seed.
dead-code RIK._addressText Retained as the readable reference audienceOf is fuzzed against. Nothing in src/ calls it and solc drops it, so the deployed bytecode is byte-for-byte identical with and without it.

forge lint is pointed at the same files, through ignore in foundry.toml: test/, script/ and the three vendored sources. Foundry 1.8 gave the linter its own copy of several slither detectors, and they are aimed at deployed code rather than at mocks and harnesses. What it still reports in src/ is fixed or accepted in place with a forge-lint: disable-next-line — or a disable-start/disable-end pair where the statement spans several lines, or where slither's own directive has to keep the line directly above the statement:

Finding Where Why it is accepted
incorrect-shift ClaimMatcher.charBitmap Yul takes the shift amount first, so shl(byte(n, w), 1) sets bit byte(n, w) of a one-bit mask. The literal in the value position reads to the linter as a swapped pair; there is nothing to swap.
encode-packed-collision ClaimMatcher.requireStringClaimFrom The result is the byte string searched for in the payload, never a hash preimage. What would disambiguate the two dynamic arguments is the JSON quoting being interpolated, and the encoder escaping that the match already rests on removes a " from either one.
reentrancy-events RIK._register The only external call it can follow is the JWT verifier, and a registration cannot be announced before its proof is checked. Every state change precedes the mint, so a nested registration of the same repository reverts with AlreadyRegistered.
reentrancy-events RIKLauncher.launch The asset and the pool are only known once the Airlock has answered. Guarded.
reentrancy-events RIKRoyaltySplitter._accrue The credit is the difference the collect call made, so both the write and its event are necessarily post-call. Every caller is guarded.
unused-return, reentrancy-no-eth RIKLauncher._create As above for slither.
unused-return RIKRoyaltySplitter.collectPoolFees As above for slither.

The one thing the linter found rather than mis-read was non-reentrant-not-first on RIKRoyaltySplitter.collectIntegratorFees, where nonReentrant sat behind onlyOwner. It was reordered rather than accepted. Note that these lint ids are new in Foundry 1.8, so an older forge reports them as unknown and forge lint --deny warnings fails on the directives themselves; CI installs stable, and a local toolchain should match it.

Note that slither builds with --skip ./test/**, which leaves the Foundry cache without test artifacts. Run forge clean before forge test afterwards, or run the two in separate jobs as CI does.

Doppler integration

The market half depends on contracts this project does not control, and the test suite exercises mocks. The following were checked directly against the deployed contracts on Base Mainnet and against Doppler's source:

Interface Checked against Result
IAirlock.create 0x660eAaEdEBc968f8f3694354FA8EC0b4c5Ba8D12 selector 0x882db707 present; CreateParams field order matches; integrator is field 11
IAirlock.getIntegratorFees / collectIntegratorFees as above selectors 0xe7f0d8f1 and 0x1285e1ce present; live call returns
IDopplerHookInitializer.getState DopplerHookInitializer, RehypeDopplerHookInitializer selector 0x1bab58f5 present; the seven-element tuple decodes against the live contract
IDopplerHookInitializer.getShares as above selector 0x5ebb58fb present
IDopplerHookInitializer.collectFees as above selector 0x817db73b present; returns (uint128, uint128)
Pool id derivation Uniswap/v4-core PoolIdLibrary.toId is keccak256(abi.encode(poolKey)) over five slots

getState(address) also exists on the standard and decay multicurve initializers, but those return (address, uint8, PoolKey, int24): eight static ABI words. Hook initializers return a seven-element tuple with dynamic graduation calldata. A matching selector does not imply a matching return layout. DopplerPoolKey is the shared reader used for launch authorization and fee collection; it selects the multicurve layout by its exact 256-byte length and otherwise uses the hook ABI decoder. Truncated or noncanonical pool keys revert. Fuzz tests exercise both layouts, arbitrary graduation calldata, truncated multicurve states and invalid key fields.

Two properties of Doppler's fee model are load-bearing and are covered by tests written against a mock that reproduces FeesManager rather than approximating it:

  • collectFees releases only the caller's share, so RIKRoyaltySplitter must make the call itself and must hold shares. RIKLauncher rejects a launch that has not registered it, because beneficiaries are fixed at pool creation and cannot be added afterwards.
  • Releases are ERC20 transfers, so a native numeraire is rejected at launch.

Supported versions

The main branch and whatever is currently deployed. There are no maintained release branches.

There aren't any published security advisories