From 21a47a7b4bdcbb00f50064b0cfab052ca53d3c2c Mon Sep 17 00:00:00 2001 From: junderw Date: Wed, 19 Apr 2023 18:10:10 -0700 Subject: [PATCH 1/5] Push TX: Include validation to prevent DoS --- backend/src/api/bitcoin/bitcoin.routes.ts | 13 +--- backend/src/api/common.ts | 85 +++++++++++++++++++++++ backend/src/config.ts | 4 ++ 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 8f31e152d..17ebc9275 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -723,12 +723,7 @@ class BitcoinRoutes { private async $postTransaction(req: Request, res: Response) { res.setHeader('content-type', 'text/plain'); try { - let rawTx; - if (typeof req.body === 'object') { - rawTx = Object.keys(req.body)[0]; - } else { - rawTx = req.body; - } + const rawTx = Common.getTransactionFromRequest(req, false); const txIdResult = await bitcoinApi.$sendRawTransaction(rawTx); res.send(txIdResult); } catch (e: any) { @@ -739,12 +734,8 @@ class BitcoinRoutes { private async $postTransactionForm(req: Request, res: Response) { res.setHeader('content-type', 'text/plain'); - const matches = /tx=([a-z0-9]+)/.exec(req.body); - let txHex = ''; - if (matches && matches[1]) { - txHex = matches[1]; - } try { + const txHex = Common.getTransactionFromRequest(req, true); const txIdResult = await bitcoinClient.sendRawTransaction(txHex); res.send(txIdResult); } catch (e: any) { diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 735e240c1..b854c1701 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -1,3 +1,5 @@ +import * as bitcoinjs from 'bitcoinjs-lib'; +import { Request } from 'express'; import { Ancestor, CpfpInfo, CpfpSummary, CpfpCluster, EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, MempoolTransactionExtended, TransactionStripped, WorkingEffectiveFeeStats } from '../mempool.interfaces'; import config from '../config'; import { NodeSocket } from '../repositories/NodesSocketsRepository'; @@ -511,6 +513,89 @@ export class Common { static getNthPercentile(n: number, sortedDistribution: any[]): any { return sortedDistribution[Math.floor((sortedDistribution.length - 1) * (n / 100))]; } + + static getTransactionFromRequest(req: Request, form: boolean): string { + let rawTx: any = typeof req.body === 'object' && form + ? Object.values(req.body)[0] as any + : req.body; + if (typeof rawTx !== 'string') { + throw Object.assign(new Error('Non-string request body'), { code: -1 }); + } + + // Support both upper and lower case hex + // Support both txHash= Form and direct API POST + const reg = form ? /^txHash=((?:[a-fA-F0-9]{2})+)$/ : /^((?:[a-fA-F0-9]{2})+)$/; + const matches = reg.exec(rawTx); + if (!matches || !matches[1]) { + throw Object.assign(new Error('Non-hex request body'), { code: -2 }); + } + + // Guaranteed to be a hex string of multiple of 2 + // Guaranteed to be lower case + // Guaranteed to pass validation (see function below) + return this.validateTransactionHex(matches[1].toLowerCase()); + } + + private static validateTransactionHex(txhex: string): string { + // Do not mutate txhex + + // We assume txhex to be valid hex (output of getTransactionFromRequest above) + + // Check 1: Valid transaction parse + let tx: bitcoinjs.Transaction; + try { + tx = bitcoinjs.Transaction.fromHex(txhex); + } catch(e) { + throw Object.assign(new Error('Invalid transaction (could not parse)'), { code: -4 }); + } + + // Check 2: Simple size check + if (tx.weight() > config.MEMPOOL.MAX_PUSH_TX_SIZE_WEIGHT) { + throw Object.assign(new Error(`Transaction too large (max ${config.MEMPOOL.MAX_PUSH_TX_SIZE_WEIGHT} weight units)`), { code: -3 }); + } + + // Check 3: Check unreachable script in taproot (if not allowed) + if (!config.MEMPOOL.ALLOW_UNREACHABLE) { + tx.ins.forEach(input => { + const witness = input.witness; + // See BIP 341: Script validation rules + const hasAnnex = witness.length >= 2 && + witness[witness.length - 1].length > 1 && + witness[witness.length - 1][0] === 0x50; + const scriptSpendMinLength = hasAnnex ? 3 : 2; + const maybeScriptSpend = witness.length >= scriptSpendMinLength; + + if (maybeScriptSpend) { + const controlBlock = witness[witness.length - scriptSpendMinLength + 1]; + if (controlBlock.length === 0 || (controlBlock[0] & 0xfe) < 0xc0) { + // Skip this input, it's not taproot + return; + } + // Definitely taproot. Get script + const script = witness[witness.length - scriptSpendMinLength]; + const decompiled = bitcoinjs.script.decompile(script); + if (!decompiled || decompiled.length < 2) { + // Skip this input + return; + } + // Iterate up to second last (will look ahead 1 item) + for (let i = 0; i < decompiled.length - 1; i++) { + const first = decompiled[i]; + const second = decompiled[i + 1]; + if ( + first === bitcoinjs.opcodes.OP_FALSE && + second === bitcoinjs.opcodes.OP_IF + ) { + throw Object.assign(new Error('Unreachable taproot scripts not allowed'), { code: -5 }); + } + } + } + }) + } + + // Pass through the input string untouched + return txhex; + } } /** diff --git a/backend/src/config.ts b/backend/src/config.ts index fd7d7bc28..40b407a57 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -35,6 +35,8 @@ interface IConfig { CPFP_INDEXING: boolean; MAX_BLOCKS_BULK_QUERY: number; DISK_CACHE_BLOCK_INTERVAL: number; + MAX_PUSH_TX_SIZE_WEIGHT: number; + ALLOW_UNREACHABLE: boolean; }; ESPLORA: { REST_API_URL: string; @@ -165,6 +167,8 @@ const defaults: IConfig = { 'CPFP_INDEXING': false, 'MAX_BLOCKS_BULK_QUERY': 0, 'DISK_CACHE_BLOCK_INTERVAL': 6, + 'MAX_PUSH_TX_SIZE_WEIGHT': 400000, + 'ALLOW_UNREACHABLE': true, }, 'ESPLORA': { 'REST_API_URL': 'http://127.0.0.1:3000', From 95a8752a0af320eda3ddd781d4d646dd6fccdebb Mon Sep 17 00:00:00 2001 From: junderw Date: Wed, 19 Apr 2023 18:19:27 -0700 Subject: [PATCH 2/5] Fix: Tests for config --- backend/src/__fixtures__/mempool-config.template.json | 6 ++++-- backend/src/__tests__/config.test.ts | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/src/__fixtures__/mempool-config.template.json b/backend/src/__fixtures__/mempool-config.template.json index 62b2e5f45..600c5e430 100644 --- a/backend/src/__fixtures__/mempool-config.template.json +++ b/backend/src/__fixtures__/mempool-config.template.json @@ -30,7 +30,9 @@ "RUST_GBT": false, "CPFP_INDEXING": true, "MAX_BLOCKS_BULK_QUERY": 999, - "DISK_CACHE_BLOCK_INTERVAL": 999 + "DISK_CACHE_BLOCK_INTERVAL": 999, + "MAX_PUSH_TX_SIZE_WEIGHT": "__MEMPOOL_MAX_PUSH_TX_SIZE_WEIGHT__", + "ALLOW_UNREACHABLE": "__MEMPOOL_ALLOW_UNREACHABLE__" }, "CORE_RPC": { "HOST": "__CORE_RPC_HOST__", @@ -120,4 +122,4 @@ "CLIGHTNING": { "SOCKET": "__CLIGHTNING_SOCKET__" } -} \ No newline at end of file +} diff --git a/backend/src/__tests__/config.test.ts b/backend/src/__tests__/config.test.ts index 937011ba2..fdd8a02de 100644 --- a/backend/src/__tests__/config.test.ts +++ b/backend/src/__tests__/config.test.ts @@ -44,6 +44,8 @@ describe('Mempool Backend Config', () => { CPFP_INDEXING: false, MAX_BLOCKS_BULK_QUERY: 0, DISK_CACHE_BLOCK_INTERVAL: 6, + MAX_PUSH_TX_SIZE_WEIGHT: 400000, + ALLOW_UNREACHABLE: true, }); expect(config.ELECTRUM).toStrictEqual({ HOST: '127.0.0.1', PORT: 3306, TLS_ENABLED: true }); From 43d41fca95423ba39f48a29991bc08e5e0d7f835 Mon Sep 17 00:00:00 2001 From: junderw Date: Thu, 13 Jul 2023 13:31:57 +0900 Subject: [PATCH 3/5] Fix: Allow detection of 1 byte annexes --- backend/src/api/common.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index b854c1701..49d2c0458 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -560,7 +560,6 @@ export class Common { const witness = input.witness; // See BIP 341: Script validation rules const hasAnnex = witness.length >= 2 && - witness[witness.length - 1].length > 1 && witness[witness.length - 1][0] === 0x50; const scriptSpendMinLength = hasAnnex ? 3 : 2; const maybeScriptSpend = witness.length >= scriptSpendMinLength; From df70ea05c6543d1ea76b4f30b8e3128a2b47fff8 Mon Sep 17 00:00:00 2001 From: junderw Date: Thu, 13 Jul 2023 13:50:54 +0900 Subject: [PATCH 4/5] Fix: Leaf version validation --- backend/src/api/common.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 49d2c0458..cd9da3d2a 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -566,7 +566,7 @@ export class Common { if (maybeScriptSpend) { const controlBlock = witness[witness.length - scriptSpendMinLength + 1]; - if (controlBlock.length === 0 || (controlBlock[0] & 0xfe) < 0xc0) { + if (controlBlock.length === 0 || !this.isValidLeafVersion(controlBlock[0])) { // Skip this input, it's not taproot return; } @@ -595,6 +595,33 @@ export class Common { // Pass through the input string untouched return txhex; } + + private static isValidLeafVersion(leafVersion: number): boolean { + // See Note 7 in BIP341 + // https://github.com/bitcoin/bips/blob/66a1a8151021913047934ebab3f8883f2f8ca75b/bip-0341.mediawiki#cite_note-7 + // "What constraints are there on the leaf version?" + + // Must be an integer between 0 and 255 + // Since we're parsing a byte + if (Math.floor(leafVersion) !== leafVersion || leafVersion < 0 || leafVersion > 255) { + return false; + } + // "the leaf version cannot be odd" + if ((leafVersion & 0x01) === 1) { + return false; + } + // "The values that comply to this rule are + // the 32 even values between 0xc0 and 0xfe + if (leafVersion >= 0xc0 && leafVersion <= 0xfe) { + return true; + } + // and also 0x66, 0x7e, 0x80, 0x84, 0x96, 0x98, 0xba, 0xbc, 0xbe." + if ([0x66, 0x7e, 0x80, 0x84, 0x96, 0x98, 0xba, 0xbc, 0xbe].includes(leafVersion)) { + return true; + } + // Otherwise, invalid + return false; + } } /** From 222b34993b584a9727a61ffdafd7e6bb32e7c2a5 Mon Sep 17 00:00:00 2001 From: junderw Date: Thu, 13 Jul 2023 14:06:46 +0900 Subject: [PATCH 5/5] Fix: Add new configs to all config instances properly. --- backend/mempool-config.sample.json | 4 +++- backend/src/__fixtures__/mempool-config.template.json | 4 ++-- docker/backend/mempool-config.json | 4 +++- docker/backend/start.sh | 5 +++++ production/mempool-config.mainnet.json | 4 +++- production/mempool-config.signet.json | 4 +++- production/mempool-config.testnet.json | 4 +++- 7 files changed, 22 insertions(+), 7 deletions(-) diff --git a/backend/mempool-config.sample.json b/backend/mempool-config.sample.json index 3371a8587..c0a2d9d62 100644 --- a/backend/mempool-config.sample.json +++ b/backend/mempool-config.sample.json @@ -29,7 +29,9 @@ "ADVANCED_GBT_MEMPOOL": false, "RUST_GBT": false, "CPFP_INDEXING": false, - "DISK_CACHE_BLOCK_INTERVAL": 6 + "DISK_CACHE_BLOCK_INTERVAL": 6, + "MAX_PUSH_TX_SIZE_WEIGHT": 4000000, + "ALLOW_UNREACHABLE": true }, "CORE_RPC": { "HOST": "127.0.0.1", diff --git a/backend/src/__fixtures__/mempool-config.template.json b/backend/src/__fixtures__/mempool-config.template.json index 600c5e430..776f01de1 100644 --- a/backend/src/__fixtures__/mempool-config.template.json +++ b/backend/src/__fixtures__/mempool-config.template.json @@ -31,8 +31,8 @@ "CPFP_INDEXING": true, "MAX_BLOCKS_BULK_QUERY": 999, "DISK_CACHE_BLOCK_INTERVAL": 999, - "MAX_PUSH_TX_SIZE_WEIGHT": "__MEMPOOL_MAX_PUSH_TX_SIZE_WEIGHT__", - "ALLOW_UNREACHABLE": "__MEMPOOL_ALLOW_UNREACHABLE__" + "MAX_PUSH_TX_SIZE_WEIGHT": 4000000, + "ALLOW_UNREACHABLE": true }, "CORE_RPC": { "HOST": "__CORE_RPC_HOST__", diff --git a/docker/backend/mempool-config.json b/docker/backend/mempool-config.json index 45f95a53e..d070d8010 100644 --- a/docker/backend/mempool-config.json +++ b/docker/backend/mempool-config.json @@ -29,6 +29,8 @@ "CPFP_INDEXING": __MEMPOOL_CPFP_INDEXING__, "MAX_BLOCKS_BULK_QUERY": __MEMPOOL_MAX_BLOCKS_BULK_QUERY__, "DISK_CACHE_BLOCK_INTERVAL": __MEMPOOL_DISK_CACHE_BLOCK_INTERVAL__, + "MAX_PUSH_TX_SIZE_WEIGHT": __MEMPOOL_MAX_PUSH_TX_SIZE_WEIGHT__, + "ALLOW_UNREACHABLE": __MEMPOOL_ALLOW_UNREACHABLE__, "POOLS_JSON_TREE_URL": "__MEMPOOL_POOLS_JSON_TREE_URL__", "POOLS_JSON_URL": "__MEMPOOL_POOLS_JSON_URL__" }, @@ -126,4 +128,4 @@ "GEOLITE2_ASN": "__MAXMIND_GEOLITE2_ASN__", "GEOIP2_ISP": "__MAXMIND_GEOIP2_ISP__" } -} \ No newline at end of file +} diff --git a/docker/backend/start.sh b/docker/backend/start.sh index b746512a9..7241444fb 100755 --- a/docker/backend/start.sh +++ b/docker/backend/start.sh @@ -32,6 +32,9 @@ __MEMPOOL_RUST_GBT__=${MEMPOOL_RUST_GBT:=false} __MEMPOOL_CPFP_INDEXING__=${MEMPOOL_CPFP_INDEXING:=false} __MEMPOOL_MAX_BLOCKS_BULK_QUERY__=${MEMPOOL_MAX_BLOCKS_BULK_QUERY:=0} __MEMPOOL_DISK_CACHE_BLOCK_INTERVAL__=${MEMPOOL_DISK_CACHE_BLOCK_INTERVAL:=6} +__MEMPOOL_MAX_PUSH_TX_SIZE_WEIGHT__=${MEMPOOL_MAX_PUSH_TX_SIZE_WEIGHT:=4000000} +__MEMPOOL_ALLOW_UNREACHABLE__=${MEMPOOL_ALLOW_UNREACHABLE:=true} + # CORE_RPC __CORE_RPC_HOST__=${CORE_RPC_HOST:=127.0.0.1} @@ -161,6 +164,8 @@ sed -i "s!__MEMPOOL_ADVANCED_GBT_AUDIT__!${__MEMPOOL_ADVANCED_GBT_AUDIT__}!g" me sed -i "s!__MEMPOOL_CPFP_INDEXING__!${__MEMPOOL_CPFP_INDEXING__}!g" mempool-config.json sed -i "s!__MEMPOOL_MAX_BLOCKS_BULK_QUERY__!${__MEMPOOL_MAX_BLOCKS_BULK_QUERY__}!g" mempool-config.json sed -i "s!__MEMPOOL_DISK_CACHE_BLOCK_INTERVAL__!${__MEMPOOL_DISK_CACHE_BLOCK_INTERVAL__}!g" mempool-config.json +sed -i "s!__MEMPOOL_MAX_PUSH_TX_SIZE_WEIGHT__!${__MEMPOOL_MAX_PUSH_TX_SIZE_WEIGHT__}!g" mempool-config.json +sed -i "s!__MEMPOOL_ALLOW_UNREACHABLE__!${__MEMPOOL_ALLOW_UNREACHABLE__}!g" mempool-config.json sed -i "s!__CORE_RPC_HOST__!${__CORE_RPC_HOST__}!g" mempool-config.json sed -i "s!__CORE_RPC_PORT__!${__CORE_RPC_PORT__}!g" mempool-config.json diff --git a/production/mempool-config.mainnet.json b/production/mempool-config.mainnet.json index 8630f1fcd..a76053913 100644 --- a/production/mempool-config.mainnet.json +++ b/production/mempool-config.mainnet.json @@ -16,7 +16,9 @@ "ADVANCED_GBT_MEMPOOL": true, "RUST_GBT": true, "USE_SECOND_NODE_FOR_MINFEE": true, - "DISK_CACHE_BLOCK_INTERVAL": 1 + "DISK_CACHE_BLOCK_INTERVAL": 1, + "MAX_PUSH_TX_SIZE_WEIGHT": 4000000, + "ALLOW_UNREACHABLE": true }, "SYSLOG" : { "MIN_PRIORITY": "debug" diff --git a/production/mempool-config.signet.json b/production/mempool-config.signet.json index e216ed216..957b36101 100644 --- a/production/mempool-config.signet.json +++ b/production/mempool-config.signet.json @@ -12,7 +12,9 @@ "ADVANCED_GBT_MEMPOOL": true, "RUST_GBT": true, "POLL_RATE_MS": 1000, - "DISK_CACHE_BLOCK_INTERVAL": 1 + "DISK_CACHE_BLOCK_INTERVAL": 1, + "MAX_PUSH_TX_SIZE_WEIGHT": 4000000, + "ALLOW_UNREACHABLE": true }, "SYSLOG" : { "MIN_PRIORITY": "debug" diff --git a/production/mempool-config.testnet.json b/production/mempool-config.testnet.json index 02bf892c1..8943e987f 100644 --- a/production/mempool-config.testnet.json +++ b/production/mempool-config.testnet.json @@ -12,7 +12,9 @@ "ADVANCED_GBT_MEMPOOL": true, "RUST_GBT": true, "POLL_RATE_MS": 1000, - "DISK_CACHE_BLOCK_INTERVAL": 1 + "DISK_CACHE_BLOCK_INTERVAL": 1, + "MAX_PUSH_TX_SIZE_WEIGHT": 4000000, + "ALLOW_UNREACHABLE": true }, "SYSLOG" : { "MIN_PRIORITY": "debug"