From acf45c44c01341e428f3528e6df66f112ff6f151 Mon Sep 17 00:00:00 2001 From: w0xlt <94266259+w0xlt@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:00:18 -0700 Subject: [PATCH] rest: add Cache-Control headers to REST responses Add Cache-Control headers to REST API responses so standard HTTP caches can cache safe responses by default without per-deployment proxy rules. Cache policy summary: - Immutable: /block binary and hex responses, /blockpart, /blockfilter, and /spenttxouts in all formats, and blockhash-specific /deploymentinfo/.json responses return "public, immutable, max-age=86400". - No-store: /block and /block/notxdetails JSON, /tx, /headers, /blockfilterheaders, /blockhashbyheight, /chaininfo, /mempool, /getutxos, tip-relative /deploymentinfo.json, and RESTERR error responses return "no-store". Mutable responses are not stored because REST does not provide cache validators such as ETag or Last-Modified. Co-authored-by: stickies-v --- src/rest.cpp | 41 +++++++++- test/functional/interface_rest.py | 123 +++++++++++++++++++++++++++++- 2 files changed, 158 insertions(+), 6 deletions(-) diff --git a/src/rest.cpp b/src/rest.cpp index 9be319a3866..fd40d25b9d2 100644 --- a/src/rest.cpp +++ b/src/rest.cpp @@ -45,6 +45,12 @@ using util::SplitString; static const size_t MAX_GETUTXOS_OUTPOINTS = 15; //allow a max of 15 outpoints to be queried at once static constexpr unsigned int MAX_REST_HEADERS_RESULTS = 2000; +// Cache-Control values for REST responses. +/** Response bytes never change. One-day TTL limits staleness across software upgrades. */ +static constexpr const char* REST_CACHE_IMMUTABLE = "public, immutable, max-age=86400"; +/** Mutable, node-local, or error response; must not be cached. */ +static constexpr const char* REST_CACHE_NO_STORE = "no-store"; + static const struct { RESTResponseFormat rf; const char* name; @@ -71,6 +77,7 @@ struct CCoin { static bool RESTERR(HTTPRequest* req, enum HTTPStatusCode status, std::string message) { + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "text/plain"); req->WriteReply(status, message + "\r\n"); return false; @@ -242,6 +249,8 @@ static bool rest_headers(const std::any& context, ssHeader << pindex->GetBlockHeader(); } + // Do not cache because chain extensions and reorgs can affect the response. + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "application/octet-stream"); req->WriteReply(HTTP_OK, ssHeader); return true; @@ -254,6 +263,7 @@ static bool rest_headers(const std::any& context, } std::string strHex = HexStr(ssHeader) + "\n"; + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "text/plain"); req->WriteReply(HTTP_OK, strHex); return true; @@ -264,6 +274,7 @@ static bool rest_headers(const std::any& context, jsonHeaders.push_back(blockheaderToJSON(*tip, *pindex, chainman.GetConsensus().powLimit)); } std::string strJSON = jsonHeaders.write() + "\n"; + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "application/json"); req->WriteReply(HTTP_OK, strJSON); return true; @@ -352,6 +363,7 @@ static bool rest_spent_txouts(const std::any& context, HTTPRequest* req, const s case RESTResponseFormat::BINARY: { DataStream ssSpentResponse{}; SerializeBlockUndo(ssSpentResponse, block_undo); + req->WriteHeader("Cache-Control", REST_CACHE_IMMUTABLE); req->WriteHeader("Content-Type", "application/octet-stream"); req->WriteReply(HTTP_OK, ssSpentResponse); return true; @@ -361,6 +373,7 @@ static bool rest_spent_txouts(const std::any& context, HTTPRequest* req, const s DataStream ssSpentResponse{}; SerializeBlockUndo(ssSpentResponse, block_undo); const std::string strHex{HexStr(ssSpentResponse) + "\n"}; + req->WriteHeader("Cache-Control", REST_CACHE_IMMUTABLE); req->WriteHeader("Content-Type", "text/plain"); req->WriteReply(HTTP_OK, strHex); return true; @@ -370,6 +383,7 @@ static bool rest_spent_txouts(const std::any& context, HTTPRequest* req, const s UniValue result(UniValue::VARR); BlockUndoToJSON(block_undo, result); std::string strJSON = result.write() + "\n"; + req->WriteHeader("Cache-Control", REST_CACHE_IMMUTABLE); req->WriteHeader("Content-Type", "application/json"); req->WriteReply(HTTP_OK, strJSON); return true; @@ -438,6 +452,7 @@ static bool rest_block(const std::any& context, switch (rf) { case RESTResponseFormat::BINARY: { + req->WriteHeader("Cache-Control", REST_CACHE_IMMUTABLE); req->WriteHeader("Content-Type", "application/octet-stream"); req->WriteReply(HTTP_OK, *block_data); return true; @@ -445,6 +460,7 @@ static bool rest_block(const std::any& context, case RESTResponseFormat::HEX: { const std::string strHex{HexStr(*block_data) + "\n"}; + req->WriteHeader("Cache-Control", REST_CACHE_IMMUTABLE); req->WriteHeader("Content-Type", "text/plain"); req->WriteReply(HTTP_OK, strHex); return true; @@ -456,6 +472,7 @@ static bool rest_block(const std::any& context, SpanReader{*block_data} >> TX_WITH_WITNESS(block); UniValue objBlock = blockToJSON(chainman.m_blockman, block, *tip, *pblockindex, *tx_verbosity, chainman.GetConsensus().powLimit); std::string strJSON = objBlock.write() + "\n"; + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "application/json"); req->WriteReply(HTTP_OK, strJSON); return true; @@ -588,6 +605,8 @@ static bool rest_filter_header(const std::any& context, HTTPRequest* req, const ssHeader << header; } + // Do not cache because chain extensions and reorgs can affect the response. + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "application/octet-stream"); req->WriteReply(HTTP_OK, ssHeader); return true; @@ -599,6 +618,7 @@ static bool rest_filter_header(const std::any& context, HTTPRequest* req, const } std::string strHex = HexStr(ssHeader) + "\n"; + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "text/plain"); req->WriteReply(HTTP_OK, strHex); return true; @@ -610,6 +630,7 @@ static bool rest_filter_header(const std::any& context, HTTPRequest* req, const } std::string strJSON = jsonHeaders.write() + "\n"; + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "application/json"); req->WriteReply(HTTP_OK, strJSON); return true; @@ -684,6 +705,7 @@ static bool rest_block_filter(const std::any& context, HTTPRequest* req, const s DataStream ssResp{}; ssResp << filter; + req->WriteHeader("Cache-Control", REST_CACHE_IMMUTABLE); req->WriteHeader("Content-Type", "application/octet-stream"); req->WriteReply(HTTP_OK, ssResp); return true; @@ -693,6 +715,7 @@ static bool rest_block_filter(const std::any& context, HTTPRequest* req, const s ssResp << filter; std::string strHex = HexStr(ssResp) + "\n"; + req->WriteHeader("Cache-Control", REST_CACHE_IMMUTABLE); req->WriteHeader("Content-Type", "text/plain"); req->WriteReply(HTTP_OK, strHex); return true; @@ -701,6 +724,7 @@ static bool rest_block_filter(const std::any& context, HTTPRequest* req, const s UniValue ret(UniValue::VOBJ); ret.pushKV("filter", HexStr(filter.GetEncodedFilter())); std::string strJSON = ret.write() + "\n"; + req->WriteHeader("Cache-Control", REST_CACHE_IMMUTABLE); req->WriteHeader("Content-Type", "application/json"); req->WriteReply(HTTP_OK, strJSON); return true; @@ -728,6 +752,7 @@ static bool rest_chaininfo(const std::any& context, HTTPRequest* req, const std: jsonRequest.params = UniValue(UniValue::VARR); UniValue chainInfoObject = getblockchaininfo().HandleRequest(jsonRequest); std::string strJSON = chainInfoObject.write() + "\n"; + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "application/json"); req->WriteReply(HTTP_OK, strJSON); return true; @@ -747,6 +772,7 @@ static bool rest_deploymentinfo(const std::any& context, HTTPRequest* req, const std::string hash_str; const RESTResponseFormat rf = ParseDataFormat(hash_str, str_uri_part); + const bool current_tip{hash_str.empty()}; switch (rf) { case RESTResponseFormat::JSON: { @@ -754,7 +780,7 @@ static bool rest_deploymentinfo(const std::any& context, HTTPRequest* req, const jsonRequest.context = context; jsonRequest.params = UniValue(UniValue::VARR); - if (!hash_str.empty()) { + if (!current_tip) { auto hash{uint256::FromHex(hash_str)}; if (!hash) { return RESTERR(req, HTTP_BAD_REQUEST, "Invalid hash: " + hash_str); @@ -769,6 +795,7 @@ static bool rest_deploymentinfo(const std::any& context, HTTPRequest* req, const jsonRequest.params.push_back(hash_str); } + req->WriteHeader("Cache-Control", current_tip ? REST_CACHE_NO_STORE : REST_CACHE_IMMUTABLE); req->WriteHeader("Content-Type", "application/json"); req->WriteReply(HTTP_OK, getdeploymentinfo().HandleRequest(jsonRequest).write() + "\n"); return true; @@ -826,6 +853,7 @@ static bool rest_mempool(const std::any& context, HTTPRequest* req, const std::s str_json = MempoolInfoToJSON(*mempool).write() + "\n"; } + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "application/json"); req->WriteReply(HTTP_OK, str_json); return true; @@ -859,12 +887,12 @@ static bool rest_tx(const std::any& context, HTTPRequest* req, const std::string if (!tx) { return RESTERR(req, HTTP_NOT_FOUND, hashStr + " not found"); } - switch (rf) { case RESTResponseFormat::BINARY: { DataStream ssTx; ssTx << TX_WITH_WITNESS(tx); + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "application/octet-stream"); req->WriteReply(HTTP_OK, ssTx); return true; @@ -875,6 +903,7 @@ static bool rest_tx(const std::any& context, HTTPRequest* req, const std::string ssTx << TX_WITH_WITNESS(tx); std::string strHex = HexStr(ssTx) + "\n"; + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "text/plain"); req->WriteReply(HTTP_OK, strHex); return true; @@ -884,6 +913,7 @@ static bool rest_tx(const std::any& context, HTTPRequest* req, const std::string UniValue objTx(UniValue::VOBJ); TxToUniv(*tx, /*block_hash=*/hashBlock, /*entry=*/ objTx); std::string strJSON = objTx.write() + "\n"; + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "application/json"); req->WriteReply(HTTP_OK, strJSON); return true; @@ -1039,6 +1069,7 @@ static bool rest_getutxos(const std::any& context, HTTPRequest* req, const std:: DataStream ssGetUTXOResponse{}; ssGetUTXOResponse << active_height << active_hash << bitmap << outs; + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "application/octet-stream"); req->WriteReply(HTTP_OK, ssGetUTXOResponse); return true; @@ -1049,6 +1080,7 @@ static bool rest_getutxos(const std::any& context, HTTPRequest* req, const std:: ssGetUTXOResponse << active_height << active_hash << bitmap << outs; std::string strHex = HexStr(ssGetUTXOResponse) + "\n"; + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "text/plain"); req->WriteReply(HTTP_OK, strHex); return true; @@ -1079,6 +1111,7 @@ static bool rest_getutxos(const std::any& context, HTTPRequest* req, const std:: // return json string std::string strJSON = objGetUTXOResponse.write() + "\n"; + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "application/json"); req->WriteReply(HTTP_OK, strJSON); return true; @@ -1117,16 +1150,20 @@ static bool rest_blockhash_by_height(const std::any& context, HTTPRequest* req, case RESTResponseFormat::BINARY: { DataStream ss_blockhash{}; ss_blockhash << pblockindex->GetBlockHash(); + // Do not cache because reorgs can change the response. + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "application/octet-stream"); req->WriteReply(HTTP_OK, ss_blockhash); return true; } case RESTResponseFormat::HEX: { + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "text/plain"); req->WriteReply(HTTP_OK, pblockindex->GetBlockHash().GetHex() + "\n"); return true; } case RESTResponseFormat::JSON: { + req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE); req->WriteHeader("Content-Type", "application/json"); UniValue resp = UniValue(UniValue::VOBJ); resp.pushKV("blockhash", pblockindex->GetBlockHash().GetHex()); diff --git a/test/functional/interface_rest.py b/test/functional/interface_rest.py index c3dc6bc406b..fd7bd34a957 100755 --- a/test/functional/interface_rest.py +++ b/test/functional/interface_rest.py @@ -23,6 +23,8 @@ from test_framework.util import ( assert_equal, assert_greater_than, assert_greater_than_or_equal, + assert_not_equal, + sync_txindex, ) from test_framework.wallet import ( MiniWallet, @@ -52,7 +54,7 @@ def filter_output_indices_by_value(vouts, value): class RESTTest (BitcoinTestFramework): def set_test_params(self): self.num_nodes = 2 - self.extra_args = [["-rest", "-blockfilterindex=1"], []] + self.extra_args = [["-rest", "-blockfilterindex=1", "-txindex"], []] # whitelist peers to speed up tx relay / mempool sync self.noban_tx_relay = True @@ -60,14 +62,14 @@ class RESTTest (BitcoinTestFramework): self, uri: str, http_method: str = 'GET', - req_type: ReqType = ReqType.JSON, + req_type: typing.Optional[ReqType] = ReqType.JSON, body: str = '', status: int = 200, ret_type: RetType = RetType.JSON, query_params: typing.Union[dict[str, typing.Any], str, None] = None, ) -> typing.Union[http.client.HTTPResponse, bytes, str, None]: rest_uri = '/rest' + uri - if req_type in ReqType: + if isinstance(req_type, ReqType): rest_uri += f'.{req_type.name.lower()}' if query_params: if isinstance(query_params, str): @@ -304,7 +306,7 @@ class RESTTest (BitcoinTestFramework): expected_filter = { 'basic block filter index': {'synced': True, 'best_block_height': 208}, } - self.wait_until(lambda: self.nodes[0].getindexinfo() == expected_filter) + self.wait_until(lambda: self.nodes[0].getindexinfo("basic block filter index") == expected_filter) json_obj = self.test_rest_request(f"/headers/{bb_hash}", query_params={"count": 5}) assert_equal(len(json_obj), 5) # now we should have 5 header objects json_obj = self.test_rest_request(f"/blockfilterheaders/basic/{bb_hash}", query_params={"count": 5}) @@ -523,5 +525,118 @@ class RESTTest (BitcoinTestFramework): resp = self.test_rest_request(f"/deploymentinfo/{INVALID_PARAM}", ret_type=RetType.OBJ, status=400) assert_equal(resp.read().decode('utf-8').rstrip(), f"Invalid hash: {INVALID_PARAM}") + self.log.info("Test Cache-Control headers on REST responses") + + blockhash = self.nodes[0].getbestblockhash() + height = self.nodes[0].getblockcount() + immutable = "public, immutable, max-age=86400" + no_store = "no-store" + + def assert_cache_control( + uri: str, + expected: str, + *, + req_type: typing.Optional[ReqType] = ReqType.JSON, + status: int = 200, + query_params: typing.Union[dict[str, typing.Any], str, None] = None, + ) -> bytes: + """Assert `uri` has expected Cache-Control and return the response body.""" + response = self.test_rest_request( + uri, + req_type=req_type, + status=status, + ret_type=RetType.OBJ, + query_params=query_params, + ) + assert isinstance(response, http.client.HTTPResponse) + assert_equal(response.getheader("Cache-Control"), expected) + return response.read() + + # Immutable endpoints + immutable_block = assert_cache_control(f"/block/{blockhash}", immutable, req_type=ReqType.BIN) + immutable_spenttxouts = assert_cache_control(f"/spenttxouts/{blockhash}", immutable) + assert_cache_control(f"/spenttxouts/{blockhash}", immutable, req_type=ReqType.BIN) + assert_cache_control(f"/spenttxouts/{blockhash}", immutable, req_type=ReqType.HEX) + immutable_blockfilter = assert_cache_control( + f"/blockfilter/basic/{blockhash}", immutable, req_type=ReqType.JSON + ) + assert_cache_control(f"/blockfilter/basic/{blockhash}", immutable, req_type=ReqType.BIN) + assert_cache_control(f"/blockfilter/basic/{blockhash}", immutable, req_type=ReqType.HEX) + + # Mutable endpoints are not stored until validator support is available. + mutable_block = assert_cache_control(f"/block/{blockhash}", no_store) + assert_cache_control(f"/block/notxdetails/{blockhash}", no_store) + mutable_headers = assert_cache_control( + f"/headers/{blockhash}", no_store, req_type=ReqType.JSON, query_params={"count": 2} + ) + assert_cache_control(f"/headers/{blockhash}", no_store, req_type=ReqType.BIN, query_params={"count": 1}) + assert_cache_control(f"/headers/{blockhash}", no_store, req_type=ReqType.HEX, query_params={"count": 1}) + assert_cache_control(f"/blockfilterheaders/basic/{blockhash}", no_store, req_type=ReqType.JSON, query_params={"count": 1}) + assert_cache_control(f"/blockfilterheaders/basic/{blockhash}", no_store, req_type=ReqType.BIN, query_params={"count": 1}) + assert_cache_control(f"/blockfilterheaders/basic/{blockhash}", no_store, req_type=ReqType.HEX, query_params={"count": 1}) + assert_cache_control(f"/blockhashbyheight/{height}", no_store) + assert_cache_control(f"/blockhashbyheight/{height}", no_store, req_type=ReqType.BIN) + assert_cache_control(f"/blockhashbyheight/{height}", no_store, req_type=ReqType.HEX) + + # Dynamic endpoints + mutable_chaininfo = assert_cache_control("/chaininfo", no_store) + assert_cache_control("/mempool/info", no_store) + mutable_deploymentinfo = assert_cache_control("/deploymentinfo", no_store) + immutable_deploymentinfo = assert_cache_control(f"/deploymentinfo/{blockhash}", immutable) + + cache_tx = self.wallet.send_self_transfer(from_node=self.nodes[0]) + mempool_txid = cache_tx["txid"] + cache_utxo = cache_tx["new_utxo"] + cache_utxo_path = f"/getutxos/{mempool_txid}-{cache_utxo['vout']}" + + mempool_tx_response = assert_cache_control(f"/tx/{mempool_txid}", no_store, req_type=ReqType.JSON) + mempool_tx = json.loads(mempool_tx_response, parse_float=Decimal) + assert "blockhash" not in mempool_tx + assert_cache_control(f"/tx/{mempool_txid}", no_store, req_type=ReqType.BIN) + assert_cache_control(f"/tx/{mempool_txid}", no_store, req_type=ReqType.HEX) + + self.generate(self.nodes[0], 1) + sync_txindex(self, self.nodes[0]) + + self.log.info("Test Cache-Control response behavior across a newly mined block") + assert_equal(immutable_block, assert_cache_control(f"/block/{blockhash}", immutable, req_type=ReqType.BIN)) + assert_equal(immutable_spenttxouts, assert_cache_control(f"/spenttxouts/{blockhash}", immutable)) + assert_equal( + immutable_blockfilter, + assert_cache_control(f"/blockfilter/basic/{blockhash}", immutable, req_type=ReqType.JSON), + ) + assert_equal(immutable_deploymentinfo, assert_cache_control(f"/deploymentinfo/{blockhash}", immutable)) + + # no-store responses need not change after every block. Only compare responses + # that are guaranteed to incorporate this active chain extension. + assert_not_equal(mutable_block, assert_cache_control(f"/block/{blockhash}", no_store)) + assert_not_equal( + mutable_headers, + assert_cache_control(f"/headers/{blockhash}", no_store, req_type=ReqType.JSON, query_params={"count": 2}), + ) + assert_not_equal(mutable_chaininfo, assert_cache_control("/chaininfo", no_store)) + assert_not_equal(mutable_deploymentinfo, assert_cache_control("/deploymentinfo", no_store)) + + confirmed_tx_response = assert_cache_control(f"/tx/{mempool_txid}", no_store, req_type=ReqType.JSON) + assert_not_equal(mempool_tx_response, confirmed_tx_response) + confirmed_tx = json.loads(confirmed_tx_response, parse_float=Decimal) + assert_equal(confirmed_tx["txid"], mempool_txid) + assert "blockhash" in confirmed_tx + assert_cache_control(f"/tx/{mempool_txid}", no_store, req_type=ReqType.BIN) + assert_cache_control(f"/tx/{mempool_txid}", no_store, req_type=ReqType.HEX) + assert_cache_control(cache_utxo_path, no_store, req_type=ReqType.JSON) + assert_cache_control(cache_utxo_path, no_store, req_type=ReqType.BIN) + assert_cache_control(cache_utxo_path, no_store, req_type=ReqType.HEX) + + self.log.info("Test Cache-Control headers on REST error responses") + assert_cache_control(f"/block/{blockhash}.invalid", no_store, status=400, req_type=None) + assert_cache_control(f"/tx/{INVALID_PARAM}", no_store, status=400) + assert_cache_control(f"/deploymentinfo/{INVALID_PARAM}", no_store, status=400) + assert_cache_control("/blockhashbyheight/999999999", no_store, status=404) + assert_cache_control(f"/block/{UNKNOWN_PARAM}", no_store, status=404) + assert_cache_control(f"/tx/{'f' * 64}", no_store, status=404) + assert_cache_control("/mempool/not-a-valid-path", no_store, status=400) + assert_cache_control(f"/deploymentinfo/{non_existing_blockhash}", no_store, status=400) + if __name__ == '__main__': RESTTest(__file__).main()