URL Parsing Security for FDC
When using the Web2Json attestation type, your smart contract receives data along with a proof that includes the source URL.
Without proper URL validation, an attacker could submit a valid FDC proof from a malicious source and have your contract accept it as legitimate data.
This guide covers security patterns for parsing and validating URLs in your FDC-enabled smart contracts.
The Problem: Man-in-the-Middle Attacks
FDC proves that data came from a specific URL. It does not prove that the URL is the one you intended to use.
Consider a price feed contract that expects data from CoinGecko. An attacker could:
- Create a malicious API endpoint that returns manipulated prices
- Get a valid FDC attestation for their malicious endpoint
- Submit this proof to your contract
- Your contract accepts the manipulated data because the FDC proof is cryptographically valid
URL validation ensures your contract only accepts data from trusted sources.
Security Layers
A robust URL validation strategy includes multiple layers:
| Layer | Purpose | Example |
|---|---|---|
| Protocol | Prevent MitM on the wire | HTTPS only |
| Host | Restrict to trusted domains | api.coingecko.com |
| Path | Validate the specific endpoint | /api/v3/coins/bitcoin/history |
| Parameters | Verify query parameters match expected values | Asset ID extraction |
Implementation Patterns
Pattern 1: HTTPS Enforcement
Always require HTTPS to prevent network-level interception.
function _validateProtocol(string memory _url) internal pure {
bytes memory urlBytes = bytes(_url);
bytes memory httpsPrefix = bytes("https://");
if (!_startsWith(urlBytes, httpsPrefix)) {
revert InvalidUrlProtocol();
}
}
function _startsWith(bytes memory data, bytes memory prefix) internal pure returns (bool) {
uint256 prefixLen = prefix.length;
if (data.length < prefixLen) return false;
for (uint256 i = 0; i < prefixLen; ) {
if (data[i] != prefix[i]) return false;
unchecked { ++i; }
}
return true;
}
Pattern 2: Host Extraction and Validation
Extract the host from the URL and validate against a whitelist.
function _extractHost(string memory _url) internal pure returns (string memory) {
bytes memory urlBytes = bytes(_url);
bytes memory httpsPrefix = bytes("https://");
uint256 startIndex = httpsPrefix.length;
uint256 urlLen = urlBytes.length;
uint256 endIndex = urlLen;
// Find the first "/" after the host
for (uint256 i = startIndex; i < urlLen; ) {
if (urlBytes[i] == "/") {
endIndex = i;
break;
}
unchecked { ++i; }
}
return string(_slice(urlBytes, startIndex, endIndex));
}
function _validateHost(string memory _url) internal pure {
string memory host = _extractHost(_url);
string memory lowerHost = _toLowerCase(host);
bool validHost = _stringsEqual(lowerHost, "api.coingecko.com") ||
_stringsEqual(lowerHost, "api.trusted-source.com");
if (!validHost) {
revert InvalidUrlHost(_url, host);
}
}
Case-insensitive comparison prevents bypasses using mixed-case hostnames.
function _toLowerCase(string memory str) internal pure returns (string memory) {
bytes memory strBytes = bytes(str);
uint256 len = strBytes.length;
bytes memory result = new bytes(len);
for (uint256 i = 0; i < len; ) {
bytes1 char = strBytes[i];
// Convert A-Z (65-90) to a-z (97-122)
if (char >= 0x41 && char <= 0x5A) {
result[i] = bytes1(uint8(char) + 32);
} else {
result[i] = char;
}
unchecked { ++i; }
}
return string(result);
}
Pattern 3: Path Validation with StartsWith
Validate that the path begins with the expected endpoint.
Using startsWith prevents prefix injection attacks where an attacker creates a path like /malicious/api/v1/data that contains your expected path as a substring.
function _extractPath(string memory _url) internal pure returns (string memory) {
bytes memory urlBytes = bytes(_url);
bytes memory httpsPrefix = bytes("https://");
uint256 startIndex = httpsPrefix.length;
uint256 urlLen = urlBytes.length;
// Find the first "/" after the host (start of path)
for (uint256 i = startIndex; i < urlLen; ) {
if (urlBytes[i] == "/") {
return string(_slice(urlBytes, i, urlLen));
}
unchecked { ++i; }
}
return "";
}
function _validatePath(string memory _url, string memory expectedPathPrefix) internal pure {
string memory path = _extractPath(_url);
if (!_startsWith(bytes(path), bytes(expectedPathPrefix))) {
revert InvalidUrlPath(_url, path);
}
}
Pattern 4: Parameter Extraction
For APIs like CoinGecko where the asset identifier is embedded in the URL path, extract and validate it.
/**
* @notice Extracts the asset ID from a URL like "/coins/{id}/history..."
* @param _url The full URL string
* @return The extracted asset ID
*/
function _extractAssetIdFromUrl(string memory _url) internal pure returns (string memory) {
bytes memory urlBytes = bytes(_url);
bytes memory prefix = bytes("/coins/");
bytes memory suffix = bytes("/history");
uint256 startIndex = _indexOf(urlBytes, prefix);
if (startIndex == type(uint256).max) {
return ""; // Prefix not found
}
startIndex += prefix.length;
uint256 endIndex = _indexOfFrom(urlBytes, suffix, startIndex);
if (endIndex == type(uint256).max) {
endIndex = urlBytes.length;
}
return string(_slice(urlBytes, startIndex, endIndex));
}
function _indexOf(bytes memory data, bytes memory marker) internal pure returns (uint256) {
uint256 dataLen = data.length;
uint256 markerLen = marker.length;
if (markerLen == 0 || dataLen < markerLen) return type(uint256).max;
for (uint256 i = 0; i <= dataLen - markerLen; i++) {
bool found = true;
for (uint256 j = 0; j < markerLen; j++) {
if (data[i + j] != marker[j]) {
found = false;
break;
}
}
if (found) return i;
}
return type(uint256).max;
}
function _indexOfFrom(bytes memory data, bytes memory marker, uint256 from) internal pure returns (uint256) {
uint256 dataLen = data.length;
uint256 markerLen = marker.length;
if (markerLen == 0 || dataLen < markerLen) return type(uint256).max;
for (uint256 i = from; i <= dataLen - markerLen; i++) {
bool found = true;
for (uint256 j = 0; j < markerLen; j++) {
if (data[i + j] != marker[j]) {
found = false;
break;
}
}
if (found) return i;
}
return type(uint256).max;
}
Then validate the extracted ID matches your expected asset:
function verifyPrice(IWeb2Json.Proof calldata _proof) external {
// Extract and validate the asset ID from the URL
string memory extractedId = _extractAssetIdFromUrl(_proof.data.requestBody.url);
string memory expectedId = assetIdMapping[expectedSymbol];
if (keccak256(bytes(extractedId)) != keccak256(bytes(expectedId))) {
revert InvalidAssetIdInUrl(_proof.data.requestBody.url, extractedId, expectedId);
}
// Continue with FDC verification...
require(
ContractRegistry.getFdcVerification().verifyWeb2Json(_proof),
"Invalid proof"
);
}
Complete Example
Here is a complete URL validation function combining all security layers:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import { ContractRegistry } from "@flarenetwork/flare-periphery-contracts/coston2/ContractRegistry.sol";
import { IWeb2Json } from "@flarenetwork/flare-periphery-contracts/coston2/IWeb2Json.sol";
contract SecureFdcConsumer {
// Allowed hosts for data sources
string[] public allowedHosts;
// Expected API path prefix
string public expectedPathPrefix;
error InvalidUrlProtocol();
error InvalidUrlHost(string url, string host);
error InvalidUrlPath(string url, string path);
error SliceOutOfBounds();
constructor(string[] memory _allowedHosts, string memory _expectedPath) {
allowedHosts = _allowedHosts;
expectedPathPrefix = _expectedPath;
}
function _validateUrl(string memory _url) internal view {
bytes memory urlBytes = bytes(_url);
// 1. Enforce HTTPS
if (!_startsWith(urlBytes, bytes("https://"))) {
revert InvalidUrlProtocol();
}
// 2. Validate host
string memory host = _extractHost(_url);
string memory lowerHost = _toLowerCase(host);
bool validHost = false;
for (uint256 i = 0; i < allowedHosts.length; i++) {
if (_stringsEqual(lowerHost, allowedHosts[i])) {
validHost = true;
break;
}
}
if (!validHost) {
revert InvalidUrlHost(_url, host);
}
// 3. Validate path
string memory path = _extractPath(_url);
if (!_startsWith(bytes(path), bytes(expectedPathPrefix))) {
revert InvalidUrlPath(_url, path);
}
}
function consumeData(IWeb2Json.Proof calldata _proof) external {
// Validate URL before accepting proof
_validateUrl(_proof.data.requestBody.url);
// Verify FDC proof
require(
ContractRegistry.getFdcVerification().verifyWeb2Json(_proof),
"Invalid proof"
);
// Process the verified data...
}
// Helper functions (see implementations above)
function _startsWith(bytes memory data, bytes memory prefix) internal pure returns (bool) { /* ... */ }
function _extractHost(string memory _url) internal pure returns (string memory) { /* ... */ }
function _extractPath(string memory _url) internal pure returns (string memory) { /* ... */ }
function _toLowerCase(string memory str) internal pure returns (string memory) { /* ... */ }
function _stringsEqual(string memory a, string memory b) internal pure returns (bool) {
return keccak256(abi.encodePacked(a)) == keccak256(abi.encodePacked(b));
}
function _slice(bytes memory data, uint256 start, uint256 end) internal pure returns (bytes memory) { /* ... */ }
}
Common Vulnerabilities
Missing HTTPS Check
Without HTTPS enforcement, an attacker could intercept HTTP requests and modify responses.
// ❌ VULNERABLE: Accepts HTTP URLs
function validateUrl(string memory url) internal pure {
// Only checks host, not protocol
require(_isAllowedHost(url), "Invalid host");
}
// ✅ SECURE: Requires HTTPS
function validateUrl(string memory url) internal pure {
require(_startsWith(bytes(url), bytes("https://")), "HTTPS required");
require(_isAllowedHost(url), "Invalid host");
}
Case-Sensitive Host Comparison
DNS is case-insensitive, so API.COINGECKO.COM resolves to the same server as api.coingecko.com.
// ❌ VULNERABLE: Case-sensitive comparison
function isValidHost(string memory host) internal pure returns (bool) {
return keccak256(bytes(host)) == keccak256(bytes("api.coingecko.com"));
}
// ✅ SECURE: Case-insensitive comparison
function isValidHost(string memory host) internal pure returns (bool) {
return keccak256(bytes(_toLowerCase(host))) == keccak256(bytes("api.coingecko.com"));
}
Contains Instead of StartsWith
Using contains for path validation allows prefix injection attacks.
// ❌ VULNERABLE: Contains check allows prefix injection
// Attacker could use: /malicious/api/v1/price -> contains "/api/v1/price" = true
function isValidPath(string memory path) internal pure returns (bool) {
return _contains(bytes(path), bytes("/api/v1/price"));
}
// ✅ SECURE: StartsWith prevents prefix injection
function isValidPath(string memory path) internal pure returns (bool) {
return _startsWith(bytes(path), bytes("/api/v1/price"));
}
Gas Considerations
URL parsing in Solidity is gas-intensive due to string operations. Consider these optimizations:
- Cache host comparisons: Pre-compute keccak256 hashes of allowed hosts
- Limit URL length: Reject URLs exceeding a maximum length before parsing
- Use assembly: For production contracts, consider assembly-optimized string operations
// Pre-computed host hashes
bytes32 constant COINGECKO_HOST_HASH = keccak256(bytes("api.coingecko.com"));
bytes32 constant BACKUP_HOST_HASH = keccak256(bytes("backup.coingecko.com"));
function _isValidHost(string memory host) internal pure returns (bool) {
bytes32 hostHash = keccak256(bytes(_toLowerCase(host)));
return hostHash == COINGECKO_HOST_HASH || hostHash == BACKUP_HOST_HASH;
}
Reference Implementations
For production-ready examples, see:
- PriceVerifierCustomFeed - Asset ID extraction from CoinGecko URLs
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import {ContractRegistry} from "@flarenetwork/flare-periphery-contracts/coston2/ContractRegistry.sol";
import {IWeb2Json} from "@flarenetwork/flare-periphery-contracts/coston2/IWeb2Json.sol";
import {IICustomFeed} from "@flarenetwork/flare-periphery-contracts/coston2/customFeeds/interface/IICustomFeed.sol";
struct PriceData {
uint256 price;
}
/**
* @title PriceVerifierCustomFeed
* @notice An FTSO Custom Feed contract that sources its value from FDC-verified data using Web2Json.
* @dev Implements the IICustomFeed interface and includes verification logic specific to Web2Json API structure.
*/
contract PriceVerifierCustomFeed is IICustomFeed {
// --- State Variables ---
bytes21 public immutable feedIdentifier;
string public expectedSymbol;
int8 public decimals_;
uint256 public latestVerifiedPrice;
address public owner;
mapping(bytes32 => string) public symbolToCoinGeckoId;
// --- Events ---
event PriceVerified(string indexed symbol, uint256 price, string apiUrl);
event UrlParsingCheck(string apiUrl, string coinGeckoId, string dateString);
event CoinGeckoIdMappingSet(bytes32 indexed symbolHash, string coinGeckoId);
// --- Errors ---
error InvalidFeedId();
error InvalidSymbol();
error UrlCoinGeckoIdMismatchExpected();
error CoinGeckoIdParsingFailed();
error UnknownSymbolForCoinGeckoId(); // Kept for direct call if needed, but mapping is primary
error CoinGeckoIdNotMapped(string symbol);
error DateStringParsingFailed();
error InvalidCoinGeckoIdInUrl(
string url,
string extractedId,
string expectedId
);
error InvalidProof();
// --- Constructor ---
constructor(
bytes21 _feedId,
string memory _expectedSymbol,
int8 _decimals
) {
if (_feedId == bytes21(0)) revert InvalidFeedId();
if (bytes(_expectedSymbol).length == 0) revert InvalidSymbol();
if (_decimals < 0) revert InvalidSymbol();
owner = msg.sender;
feedIdentifier = _feedId;
expectedSymbol = _expectedSymbol;
decimals_ = _decimals;
// Initialize default CoinGecko IDs
_setCoinGeckoIdInternal("BTC", "bitcoin");
_setCoinGeckoIdInternal("ETH", "ethereum");
// Ensure the expected symbol has a mapping at deployment time
require(
bytes(
symbolToCoinGeckoId[
keccak256(abi.encodePacked(_expectedSymbol))
]
).length > 0,
"Initial symbol not mapped"
);
}
// --- Owner Functions ---
/**
* @notice Allows the owner to add or update a CoinGecko ID mapping for a symbol.
* @param _symbol The trading symbol (e.g., "LTC").
* @param _coinGeckoId The corresponding CoinGecko ID (e.g., "litecoin").
*/
function setCoinGeckoIdMapping(
string calldata _symbol,
string calldata _coinGeckoId
) external {
_setCoinGeckoIdInternal(_symbol, _coinGeckoId);
}
function _setCoinGeckoIdInternal(
string memory _symbol,
string memory _coinGeckoId
) internal {
require(bytes(_symbol).length > 0, "Symbol cannot be empty");
require(bytes(_coinGeckoId).length > 0, "CoinGecko ID cannot be empty");
bytes32 symbolHash = keccak256(abi.encodePacked(_symbol));
symbolToCoinGeckoId[symbolHash] = _coinGeckoId;
emit CoinGeckoIdMappingSet(symbolHash, _coinGeckoId);
}
// --- FDC Verification & Price Logic ---
/**
* @notice Verifies the price data proof and stores the price.
* @dev Uses Web2Json FDC verification. Checks if the symbol in the URL matches expectedSymbol.
* @param _proof The IWeb2Json.Proof data structure.
*/
function verifyPrice(IWeb2Json.Proof calldata _proof) external {
// 1. CoinGecko ID Verification (from URL)
string memory extractedCoinGeckoId = _extractCoinGeckoIdFromUrl(
_proof.data.requestBody.url
);
string memory expectedCoinGeckoId = symbolToCoinGeckoId[
keccak256(abi.encodePacked(expectedSymbol))
];
if (bytes(expectedCoinGeckoId).length == 0) {
revert CoinGeckoIdNotMapped(expectedSymbol);
}
if (
keccak256(abi.encodePacked(extractedCoinGeckoId)) !=
keccak256(abi.encodePacked(expectedCoinGeckoId))
) {
revert InvalidCoinGeckoIdInUrl(
_proof.data.requestBody.url,
extractedCoinGeckoId,
expectedCoinGeckoId
);
}
// 2. FDC Verification (Web2Json)
// Aligned with the Web2Json.sol example's pattern
require(
ContractRegistry.getFdcVerification().verifyWeb2Json(_proof),
"FDC: Invalid Web2Json proof"
);
// 3. Decode Price Data
// Path changed to _proof.data.responseBody.abiEncodedData
PriceData memory newPriceData = abi.decode(
_proof.data.responseBody.abiEncodedData,
(PriceData)
);
// 4. Store verified data
latestVerifiedPrice = newPriceData.price;
// 5. Emit main event
emit PriceVerified(
expectedSymbol,
newPriceData.price,
_proof.data.requestBody.url // URL from the Web2Json proof
);
}
// --- Custom Feed Logic ---
function read() public view returns (uint256 value) {
value = latestVerifiedPrice;
}
function feedId() external view override returns (bytes21 _feedId) {
_feedId = feedIdentifier;
}
function calculateFee() external pure override returns (uint256 _fee) {
return 0;
}
function getFeedDataView()
external
view
returns (uint256 _value, int8 _decimals)
{
_value = latestVerifiedPrice;
_decimals = decimals_;
}
function getCurrentFeed()
external
payable
override
returns (uint256 _value, int8 _decimals, uint64 _timestamp)
{
_value = latestVerifiedPrice;
_decimals = decimals_;
_timestamp = 0;
}
function decimals() external view returns (int8) {
return decimals_;
}
// --- Internal Helper Functions To Parse URL---
/**
* @notice Helper function to extract a slice of bytes.
* @param data The original bytes array.
* @param start The starting index (inclusive).
* @param end The ending index (exclusive).
* @return The sliced bytes.
*/
function slice(
bytes memory data,
uint256 start,
uint256 end
) internal pure returns (bytes memory) {
require(end >= start, "Slice: end before start");
require(data.length >= end, "Slice: end out of bounds");
bytes memory result = new bytes(end - start);
for (uint256 i = start; i < end; i++) {
result[i - start] = data[i];
}
return result;
}
/**
* @notice Extracts the CoinGecko ID from the API URL.
* @dev It assumes the URL format is like ".../coins/{id}/history..." or ".../coins/{id}"
* @param _url The full URL string from the proof.
* @return The extracted CoinGecko ID.
*/
function _extractCoinGeckoIdFromUrl(
string memory _url
) internal pure returns (string memory) {
bytes memory urlBytes = bytes(_url);
bytes memory prefix = bytes("/coins/");
bytes memory suffix = bytes("/history");
uint256 startIndex = _indexOf(urlBytes, prefix);
if (startIndex == type(uint256).max) {
return ""; // Prefix not found
}
startIndex += prefix.length;
uint256 endIndex = _indexOfFrom(urlBytes, suffix, startIndex);
if (endIndex == type(uint256).max) {
// Suffix not found, assume it's the end of the string
endIndex = urlBytes.length;
}
return string(slice(urlBytes, startIndex, endIndex));
}
/**
* @notice Helper to find the first occurrence of a marker in bytes.
* @param data The bytes data to search in.
* @param marker The bytes marker to find.
* @return The starting index of the marker, or type(uint256).max if not found.
*/
function _indexOf(
bytes memory data,
bytes memory marker
) internal pure returns (uint256) {
uint256 dataLen = data.length;
uint256 markerLen = marker.length;
if (markerLen == 0 || dataLen < markerLen) return type(uint256).max;
for (uint256 i = 0; i <= dataLen - markerLen; i++) {
bool found = true;
for (uint256 j = 0; j < markerLen; j++) {
if (data[i + j] != marker[j]) {
found = false;
break;
}
}
if (found) return i;
}
return type(uint256).max;
}
/**
* @notice Helper to find the first occurrence of a marker in bytes, starting from an index.
* @param data The bytes data to search in.
* @param marker The bytes marker to find.
* @param from The index to start searching from.
* @return The starting index of the marker, or type(uint256).max if not found.
*/
function _indexOfFrom(
bytes memory data,
bytes memory marker,
uint256 from
) internal pure returns (uint256) {
uint256 dataLen = data.length;
uint256 markerLen = marker.length;
if (markerLen == 0 || dataLen < markerLen) return type(uint256).max;
for (uint256 i = from; i <= dataLen - markerLen; i++) {
bool found = true;
for (uint256 j = 0; j < markerLen; j++) {
if (data[i + j] != marker[j]) {
found = false;
break;
}
}
if (found) return i;
}
return type(uint256).max;
}
}
Summary
Secure URL parsing for FDC requires:
- HTTPS enforcement - Reject HTTP URLs
- Host whitelisting - Only accept data from trusted domains
- Case-insensitive comparison - Prevent bypass via mixed-case hosts
- Path validation with startsWith - Prevent prefix injection attacks
- Parameter extraction - Validate embedded identifiers match expected values
Always validate URLs before calling verifyWeb2Json().
A valid FDC proof only guarantees the data came from the URL in the proof—it does not guarantee the URL is trustworthy.