Skip to main content

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:

  1. Create a malicious API endpoint that returns manipulated prices
  2. Get a valid FDC attestation for their malicious endpoint
  3. Submit this proof to your contract
  4. 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:

LayerPurposeExample
ProtocolPrevent MitM on the wireHTTPS only
HostRestrict to trusted domainsapi.coingecko.com
PathValidate the specific endpoint/api/v3/coins/bitcoin/history
ParametersVerify query parameters match expected valuesAsset ID extraction

Implementation Patterns

Pattern 1: HTTPS Enforcement

Always require HTTPS to prevent network-level interception.

contracts/UrlSecurity.sol
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.

contracts/UrlSecurity.sol
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.

contracts/UrlSecurity.sol
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.

contracts/UrlSecurity.sol
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.

contracts/UrlSecurity.sol
/**
* @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:

contracts/UrlSecurity.sol
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:

contracts/SecureFdcConsumer.sol
// 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:

  1. Cache host comparisons: Pre-compute keccak256 hashes of allowed hosts
  2. Limit URL length: Reject URLs exceeding a maximum length before parsing
  3. Use assembly: For production contracts, consider assembly-optimized string operations
contracts/GasOptimized.sol
// 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.sol
// 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:

  1. HTTPS enforcement - Reject HTTP URLs
  2. Host whitelisting - Only accept data from trusted domains
  3. Case-insensitive comparison - Prevent bypass via mixed-case hosts
  4. Path validation with startsWith - Prevent prefix injection attacks
  5. 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.