Validate and bound the QR endpoint (B-67)

/qr/{address} is reachable without auth (the dashboard renders it with a
plain <img> tag, which cannot carry a bearer token), and it did two things
it should not: it accepted any plm1-prefixed string matching a shape regex,
without checking the bech32 checksum, and it ran qrcode.make on the event
loop — a free CPU amplifier that also stalled the scheduler and the
listener for the duration of every request.

Validation now goes through is_valid_plm_address, the same check
withdrawals and the admin fee_address validator use, behind a length guard
so an oversized path never reaches embit. The render is memoized per
address in a bounded LRU (valid addresses are cheap to generate, so an
unbounded cache would just move the amplification to memory) and pushed
off the loop with run_in_threadpool, and the response carries a
Cache-Control so a browser stops re-asking for an image that never changes.
The rejection now uses the structured error contract (invalid_address),
which already has its i18n key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 14:18:22 +02:00
co-authored by Claude Opus 5
parent 23d58796b6
commit 5f6abe5b32
3 changed files with 147 additions and 27 deletions
+46 -10
View File
@@ -1,22 +1,58 @@
"""PNG QR codes for PLM addresses.
Deliberately unauthenticated: the dashboard renders it with a plain `<img>`
tag, which cannot carry the bearer token, and the payload is an address the
caller already has. What the endpoint must not be is a free CPU amplifier
(B-67), so two things bound the work an anonymous caller can ask for:
- the address is validated for real (bech32 checksum + PLM HRP) via
`is_valid_plm_address`, the same check withdrawals and the admin
`fee_address` validator use, instead of a shape-only regex that happily
rendered a QR for any `plm1`-prefixed junk string;
- the render itself is memoized per address and pushed off the event loop, so
a repeat request costs a dict lookup and a first one never blocks the
scheduler, the listener or any other request.
"""
import io
import re
from functools import lru_cache
import qrcode
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import Response
from app.api.errors import http_error
from app.wallet.address import is_valid_plm_address
router = APIRouter(tags=["qr"])
# PLM P2WPKH addresses: bech32 HRP "plm" + separator + witness program.
_ADDRESS_RE = re.compile(r"^plm1[a-z0-9]{10,90}$")
# Long enough for any bech32 address, short enough that a multi-kilobyte path
# is rejected before embit ever looks at it.
_MAX_ADDRESS_LENGTH = 100
# Bounded on purpose: valid addresses are cheap to generate, so an unbounded
# cache would just move the amplification from CPU to memory.
_CACHE_SIZE = 512
@lru_cache(maxsize=_CACHE_SIZE)
def _render_png(address: str) -> bytes:
image = qrcode.make(address)
buf = io.BytesIO()
image.save(buf, format="PNG")
return buf.getvalue()
@router.get("/qr/{address}")
async def address_qr(address: str) -> Response:
if not _ADDRESS_RE.match(address):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid address")
if len(address) > _MAX_ADDRESS_LENGTH or not is_valid_plm_address(address):
raise http_error(400, "invalid_address", "not a valid PLM bech32 address")
image = qrcode.make(address)
buf = io.BytesIO()
image.save(buf, format="PNG")
return Response(content=buf.getvalue(), media_type="image/png")
png = await run_in_threadpool(_render_png, address)
# An address' QR never changes; let the browser stop asking for it.
return Response(
content=png,
media_type="image/png",
headers={"Cache-Control": "private, max-age=86400, immutable"},
)