This commit is contained in:
parent
94a995d814
commit
aaa7a8ce99
10 changed files with 543 additions and 8 deletions
|
|
@ -75,6 +75,13 @@ std::string gCssHref = "/styles.css";
|
|||
PaymentRails gRails;
|
||||
std::string gRedirectBase = "https://catcrafts.net";
|
||||
|
||||
// Where the bunq callback writes, and the secret path that authorises it.
|
||||
// Both empty means the endpoint does not exist at all — an unconfigured
|
||||
// webhook must not answer, or the shop would carry a public write-ish endpoint
|
||||
// nobody asked for.
|
||||
std::filesystem::path gCreditsPath;
|
||||
std::string gWebhookPath;
|
||||
|
||||
// The bank-derived aggregates for /financials live in Catcrafts.Server-
|
||||
// Financials.cpp, which owns their file. They are read through
|
||||
// CurrentFinancials() per request rather than cached: unlike the content
|
||||
|
|
@ -637,6 +644,65 @@ std::string NowRfc2822() {
|
|||
// the POST/redirect/GET pattern, and it matters for a real form: a rendered
|
||||
// POST response means reloading re-submits, and the back button re-posts. The
|
||||
// redirect leaves the browser on a GET it can safely repeat.
|
||||
// The bunq callback. See ParseBunqCallback for the security model; the short
|
||||
// version is that bunq does NOT sign these, so this endpoint's only defences
|
||||
// are the ones around it: a secret path segment, a source-IP allowlist in
|
||||
// Caddy for bunq's published range, and the fact that no parcel leaves without
|
||||
// a human. It therefore does the least it possibly can — decode one payment
|
||||
// and append it to the credits file — and makes no settlement decision at all.
|
||||
// The reconciler settles from that file exactly as it does from a pulled one,
|
||||
// so a forged callback can at worst manufacture a credit line, never bypass
|
||||
// the reference match or the covering-amount rule.
|
||||
HTTPResponse HandleBunqCallback(const HTTPRequest& req) {
|
||||
HTTPResponse res;
|
||||
res.headers["content-type"] = "text/plain; charset=utf-8";
|
||||
// Never cache, never index, and say nothing useful in the body: this URL
|
||||
// is a shared secret, so every answer is the same two characters.
|
||||
res.headers["cache-control"] = "no-store";
|
||||
res.headers["x-robots-tag"] = "noindex, nofollow";
|
||||
|
||||
if (req.method != "POST") {
|
||||
res.status = "405";
|
||||
res.headers["allow"] = "POST";
|
||||
res.body = "no\n";
|
||||
return res;
|
||||
}
|
||||
|
||||
const std::optional<BankCredit> credit = ParseBunqCallback(req.body);
|
||||
if (!credit) {
|
||||
// A callback shape this cannot decode is NOT an error to shout about
|
||||
// with a 4xx: bunq sends several notification categories, and only
|
||||
// some carry a Payment. Answer 200 so bunq stops retrying something
|
||||
// that will never decode, and log it so a genuinely new shape is
|
||||
// visible rather than silently dropped.
|
||||
std::println(std::cerr, "bunq callback: no usable payment in body ({} bytes)",
|
||||
req.body.size());
|
||||
res.status = "200";
|
||||
res.body = "ok\n";
|
||||
return res;
|
||||
}
|
||||
|
||||
// Outgoing money cannot pay for an order, and the matcher ignores it
|
||||
// anyway — so refuse to write the shop's own supplier payments into a file
|
||||
// that lives on a public-facing host.
|
||||
if (credit->amountMinor <= 0) {
|
||||
res.status = "200";
|
||||
res.body = "ok\n";
|
||||
return res;
|
||||
}
|
||||
|
||||
if (AppendCreditTo(gCreditsPath, *credit)) {
|
||||
std::println(std::cerr, "bunq callback: credited {} via {} (id {})",
|
||||
Money::FormatMinor(credit->amountMinor), credit->method,
|
||||
credit->id);
|
||||
}
|
||||
// 200 even on a duplicate or a write we skipped: a duplicate IS success
|
||||
// from bunq's side, and making it retry would achieve nothing.
|
||||
res.status = "200";
|
||||
res.body = "ok\n";
|
||||
return res;
|
||||
}
|
||||
|
||||
HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
||||
HTTPResponse res;
|
||||
|
||||
|
|
@ -1064,6 +1130,29 @@ void ConfigurePayments(PaymentRails rails, std::string redirectBase) {
|
|||
if (!redirectBase.empty()) gRedirectBase = std::move(redirectBase);
|
||||
}
|
||||
|
||||
void ConfigureBunqCallback(std::filesystem::path creditsPath, std::string secretPath) {
|
||||
// Both or neither. A secret with nowhere to write, or a path to write with
|
||||
// no secret guarding it, are each worse than not having the endpoint: the
|
||||
// first answers requests it cannot act on, the second answers everyone.
|
||||
if (creditsPath.empty() || secretPath.empty()) return;
|
||||
// A short secret is not a secret. Refuse rather than serve a guessable
|
||||
// write endpoint just because someone set the variable to "hook".
|
||||
if (secretPath.size() < 24) {
|
||||
std::println(std::cerr,
|
||||
"bunq callback: BUNQ_WEBHOOK_PATH is too short to be secret "
|
||||
"({} chars, want 24+) — the endpoint is NOT enabled",
|
||||
secretPath.size());
|
||||
return;
|
||||
}
|
||||
if (secretPath.front() != '/') secretPath.insert(secretPath.begin(), '/');
|
||||
gCreditsPath = std::move(creditsPath);
|
||||
gWebhookPath = std::move(secretPath);
|
||||
// Deliberately does NOT log the path: it would land in the journal, and
|
||||
// from there in any log shipping or analytics that reads it.
|
||||
std::println(std::cerr, "bunq callback: enabled on a secret path ({} chars)",
|
||||
gWebhookPath.size());
|
||||
}
|
||||
|
||||
bool CryptoPaymentAvailable() { return gRails.crypto != nullptr; }
|
||||
|
||||
bool BankPaymentAvailable() { return gRails.bank != nullptr; }
|
||||
|
|
@ -1280,6 +1369,31 @@ int Serve(std::uint16_t port) {
|
|||
};
|
||||
|
||||
auto fallback = [](const HTTPRequest& req) -> HTTPResponse {
|
||||
// The bunq callback, before anything else looks at the path. It is
|
||||
// matched here rather than added to the shared route table on purpose:
|
||||
// ParseRoute is shared with the wasm frontend, and a secret URL has no
|
||||
// business being compiled into a bundle served to browsers.
|
||||
//
|
||||
// Compared in CONSTANT TIME. This is a shared secret in a URL, so a
|
||||
// timing oracle on a byte-by-byte compare would let it be recovered
|
||||
// one character at a time — and unlike a password there is no rate
|
||||
// limit or lockout behind it.
|
||||
// POST only, and non-POST deliberately falls through to ordinary page
|
||||
// handling rather than answering 405. A distinctive answer here would
|
||||
// be an oracle: a GET returning 405 where every other unknown URL
|
||||
// returns 404 confirms a guessed path is the right one, which is
|
||||
// exactly the signal a secret-in-the-URL scheme cannot afford to give.
|
||||
// bunq only ever POSTs, so nothing legitimate is lost.
|
||||
if (!gWebhookPath.empty() && req.method == "POST") {
|
||||
const std::string_view path = PathWithoutQueryHTTP(req.path);
|
||||
if (path.size() == gWebhookPath.size()) {
|
||||
unsigned char diff = 0;
|
||||
for (std::size_t i = 0; i < path.size(); ++i) {
|
||||
diff |= static_cast<unsigned char>(path[i] ^ gWebhookPath[i]);
|
||||
}
|
||||
if (diff == 0) return HandleBunqCallback(req);
|
||||
}
|
||||
}
|
||||
// A POST to a product page is a checkout submission.
|
||||
if (req.method == "POST") {
|
||||
const Route route = ParseRoute(PathWithoutQueryHTTP(req.path));
|
||||
|
|
|
|||
Loading…
Reference in a new issue