If you have found a security vulnerability, please do not open a GitHub issue: an issue is public from the moment it is filed, and so is the window between filing it and a fix being released.
Report it privately instead, by opening a security advisory. Only the maintainers can see it, the discussion stays private until an advisory is published, and a CVE can be requested from it if the vulnerability warrants one.
If you have no GitHub account, or would rather not use it for this, responsible disclosure by email to security at btclib dot org is equally welcome.
A report is acknowledged within 7 days, and a fix or a published advisory follows within 90 days.
A report triaged as easy to rediscover is fixed as soon as possible, since anyone can find it in the meantime, and its advisory is published with the fix.
This project is one HTTP client. What belongs here is what it does with a credential and with a reply:
- the credential: how it is built, where it is read from, and where it
can end up. A credential in a url, in a
__repr__, in an exception message or in a traceback is a defect here - where a request is sent: the scheme, the redirect that is refused, the proxy that is not taken from the environment
- what is read back: the bound on the body, what is parsed and what is refused, and whether a reply can be attributed to the request that asked for it
- the distributions published to PyPI and their provenance
What an exception, a repr or a log line may say about a credential is the organization's standard's A refusal carries no secret.
What a method does is Bitcoin Core's, and belongs to bitcoin/bitcoin. So does the node's own authentication.
Report it wherever you found it, though: routing a report is the maintainers' job, not the reporter's, and a doubt about which project owns a flaw is not a reason to keep it to yourself.
The latest security review is dated 2026-09-30. pmazzocchi did it against the assurance case, and issue 585 records it.
The review's scope is the tree at commit 002e57e0a6d6.
What changed since
is that commit's comparison with main.
Only the latest release is supported. A fix is published as a new release, and nothing is backported: an ordinary dependency bump is what carries it to an installed package.
Wheels and sdist are published to PyPI with PEP 740 attestations, through a workflow that no long-lived token can authenticate for (PyPI Trusted Publishing), so a distribution can be traced back to the workflow run and the commit it was built from.
The same files are attached to the GitHub release, and those copies carry a build provenance attestation of their own, signed in the run that built them:
signer=btclib-org/.github/.github/workflows/reusable-build.yml
id=$(gh api repos/btclib-org/bitcoin-core-rpc --jq .id)
cert='.[].verificationResult.signature.certificate'gh attestation verify <file> --owner btclib-org \
--signer-workflow "${signer:?}" \
--source-ref refs/tags/v<version> --format json \
--jq "${cert:?}.sourceRepositoryIdentifier" | grep -qx "${id:?}"The exit code is 0 only where a verified attestation names this
repository. The repository is compared by its id, which a rename keeps:
--repo compares the current name and refuses every release signed
before a rename.
<file> is a distribution file from the release.
--signer-workflow names the workflow that signed, and narrows what
passes to it. For a release built by the organization's
reusable-build.yml, which release.yml calls, that is that workflow: an
attestation made inside a called workflow names the callee as its signer.
Without the flag, any attestation this repository has passes, including
one from a workflow it calls in btclib-org/.github. --source-ref is
what keeps a build of a branch from passing as the release. A release
from v2026.9.24 on, made before this repository called
reusable-build.yml, was signed by reusable-attest.yml, named the same
way, with the same --source-ref. From v2026.8.12 through v2026.9.3 the
signer is release.yml itself, so for those releases signer is
btclib-org/bitcoin-core-rpc/.github/workflows/release.yml. An earlier
release carries no build provenance attestation. No path verifies a
release another signed. The PEP 740 attestations on PyPI name
release.yml, the job that uploads there being its own rather than a
called workflow's. The signed statement for the GitHub release is
attached to it as well, as <tag>.intoto.jsonl, or as
<tag>.attestation.jsonl on a release that carries that name instead, so
--bundle <that file> runs the same check reading it from disk instead of
asking GitHub for it. One attestation covers the wheel, the sdist and the
bill of materials; the bundle is that attestation and is not among its
subjects.
A CycloneDX 1.6 bill of materials is attached beside them,
bitcoin_core_rpc-<version>.cdx.json: the two files with their SHA-256,
the licence, and one component per dependency the wheel's metadata
declares. It is generated from the built wheel rather than from the
source tree, so it describes the files it is attached to, and it is
covered by the same attestation — a bill of materials whose provenance
nobody can check says only what whoever wrote it wanted said. This
package declares no dependency, so components is an empty list, and the
signed emptiness is what the document is for here: without one a consumer
has to go and establish it somewhere else.
These are known and inherent.
- Basic authentication is cleartext over plain HTTP, that being what
Core's rpc speaks. Core
dropped
-rpcsslto remove OpenSSL from the daemon. Its rpc does not use encryption and has not been hardened to withstand arbitrary Internet traffic, so it is not to be exposed to the Internet. This client adds no TLS of its own: the url's scheme decides. On loopback, which is whatfrom_chainbuilds, the cleartext stays between one process and the node beside it. Anhttpsurl gets the certificate and the host name verified, unless acontextthe caller gives says otherwise (below). For a node anywhere else overhttp, the cleartext is on the wire, and rpc credentials authorise every wallet command that node has; anhttpsurl or a tunnel (SSH, stunnel or a VPN) keeps them off it. - A credential handed to this client lives in Python objects, which
are immutable and not zeroized: it stays in process memory until
garbage collection, and may have been copied by the interpreter
meanwhile.
cookie_pathis the mitigation that exists — the file is read at each call rather than held, so the credential lives for the duration of one call and the node rotates it at every restart. SSL_CERT_FILEandSSL_CERT_DIRchoose the trust store of anhttpsconnection given nocontext. The default TLS context verifies the certificate and the host name against the system's default certificates, which OpenSSL reads from those two variables when they are set, so whoever controls this process's environment chooses which authorities it trusts.- A
contextthe caller gives decides what is verified.urlopen_transportandSessionTransporttake anssl.SSLContextascontext, and use it as given: one that trusts a node's own authority reaches that node without the environment, and one with verification off verifies nothing. It keeps its key log too: one built byssl.create_default_context()whileSSLKEYLOGFILEwas set writes the session keys to that file. - A caller's own
transportis outside all of this. It does its own I/O, so what it holds in memory, whether it follows a redirect and where it sends theAuthorizationheader are its author's to answer for. The two arguments it is handed are the whole of what this module can say about it. getblockchaininfois not asked on the caller's behalf. Nothing here checks which chain a node serves, so a client pointed at a testnet node answers testnet questions without saying so. Ask it, and read the answer throughnetwork_from_chain.- There is no retry, and that is deliberate, but it means a
503from a full work queue is a failure the caller has to decide about. The reason the decision is theirs is thatcallcarries any method and a timeout is not a deadline: a node that stopped answering may still be executing the call, so a client re-sending a wallet command of its own accord can execute it twice.