From 8398c9ea83010948f8dd6673eebf98a3369cd313 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 18:41:40 -0700 Subject: [PATCH 1/7] Check the resolved endpoint against the allowed hosts, not only the base normalizeBaseUrl validated PAYABLI_API_BASE_URL and payabliApi then resolved a path against it and sent the bearer token to whatever came out. Resolution can move the origin, so validating the base alone leaves the credential reachable at a host the allow-list was meant to exclude. The check is now a function rather than a body inside normalizeBaseUrl, and payabliApi applies it to the endpoint it is about to call. The Android demo server has applied it at both points for this reason; this closes the gap in the direction of the stricter one. Not reachable from the routes as they stand: the paths are literals and the one interpolated value is encodeURIComponent'd. It is the guard that was partial, rather than a live path. --- Example/PayabliDemo/LocalTokenServer/server.mjs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Example/PayabliDemo/LocalTokenServer/server.mjs b/Example/PayabliDemo/LocalTokenServer/server.mjs index 5c56809..d793680 100644 --- a/Example/PayabliDemo/LocalTokenServer/server.mjs +++ b/Example/PayabliDemo/LocalTokenServer/server.mjs @@ -188,6 +188,7 @@ async function payabliApi(path, { method = "GET", body = null, options = {} } = const apiBaseUrl = normalizeBaseUrl(stringValue(options.apiBaseUrl) || defaultApiBaseUrl); const token = await resolveAccessToken(options); const endpoint = new URL(path.replace(/^\/+/, ""), ensureTrailingSlash(apiBaseUrl)); + assertAllowedEndpoint(endpoint, "The resolved API endpoint"); const upstream = await fetch(endpoint, { method, @@ -439,15 +440,22 @@ function normalizeBaseUrl(url) { const trimmed = url.trim(); const normalized = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; const parsed = new URL(normalized); + assertAllowedEndpoint(parsed, "PAYABLI_API_BASE_URL"); + return parsed.toString(); +} +// Checks a URL that is about to receive the credentials. Applied to the configured base and, more +// importantly, to the endpoint actually resolved from base + path: a path can steer that resolution +// onto another origin, so validating the base alone leaves the credential reachable. +function assertAllowedEndpoint(parsed, label) { if (parsed.protocol !== "https:" && process.env.PAYABLI_ALLOW_INSECURE_UPSTREAM !== "true") { - throw new LocalTokenServerError(400, "PAYABLI_API_BASE_URL must use https."); + throw new LocalTokenServerError(400, `${label} must use https.`); } if (!allowedApiHosts.has(parsed.hostname.toLowerCase())) { throw new LocalTokenServerError( 400, - `PAYABLI_API_BASE_URL host is not allowed. Allowed hosts: ${Array.from(allowedApiHosts).join(", ")}` + `${label} host is not allowed. Allowed hosts: ${Array.from(allowedApiHosts).join(", ")}` ); } From 8a2329a0e41ecf999133e545b1ffde527e6c5663 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 18:41:56 -0700 Subject: [PATCH 2/7] Let the token server serve a second environment without an edit The server loaded .env by name, so pointing it at another environment meant editing the credential file in place and editing it back. PAYABLI_ENV_FILE picks the file instead, and .gitignore already covers .env.* so a second one stays untracked. A named file that does not exist exits rather than falling through to the built-in sandbox defaults, which is the case that would otherwise run against the wrong upstream and report nothing. The startup banner prints the upstream, the env file and the entry point. Without them two runs on two environments are indistinguishable in the log, and a refusal from the wrong one reads as a bad entry point. Matches the Android demo server, so the two stay one procedure. --- .../PayabliDemo/LocalTokenServer/.env.example | 14 +++++++++++++ .../PayabliDemo/LocalTokenServer/server.mjs | 20 +++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Example/PayabliDemo/LocalTokenServer/.env.example b/Example/PayabliDemo/LocalTokenServer/.env.example index 3e2f62a..b637b8f 100644 --- a/Example/PayabliDemo/LocalTokenServer/.env.example +++ b/Example/PayabliDemo/LocalTokenServer/.env.example @@ -1,3 +1,17 @@ +# Selecting a second environment +# +# This file is loaded by name. PAYABLI_ENV_FILE picks a different one, so sandbox is a second file +# rather than an edit to this one, and both can sit side by side: +# +# cp .env.example .env.sandbox # then set the sandbox base URL and its own client id/secret +# PAYABLI_ENV_FILE=.env.sandbox node server.mjs +# +# .gitignore already covers .env.* so a second file stays untracked. A PAYABLI_ENV_FILE naming a file +# that does not exist is fatal rather than a silent fall back to the defaults below. +# +# An entry point exists in one environment, so PAYABLI_API_BASE_URL, the credential and the app's +# -Ppayabli.demo.environment all move together. + PORT=8787 PAYABLI_LOCAL_TOKEN_SERVER_HOST=127.0.0.1 diff --git a/Example/PayabliDemo/LocalTokenServer/server.mjs b/Example/PayabliDemo/LocalTokenServer/server.mjs index d793680..01e8b8a 100644 --- a/Example/PayabliDemo/LocalTokenServer/server.mjs +++ b/Example/PayabliDemo/LocalTokenServer/server.mjs @@ -1,11 +1,20 @@ import { createServer } from "node:http"; import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; const serverDir = dirname(fileURLToPath(import.meta.url)); -loadEnv(join(serverDir, ".env")); +// PAYABLI_ENV_FILE picks the file, so a second environment is a second file rather than an edit to +// this one. A relative name resolves beside this server. An explicitly named file that is not there +// is fatal: the alternative is falling back to the sandbox defaults below and reporting nothing. +const envFileName = (process.env.PAYABLI_ENV_FILE || ".env").trim(); +const envFilePath = isAbsolute(envFileName) ? envFileName : join(serverDir, envFileName); +if (process.env.PAYABLI_ENV_FILE && !existsSync(envFilePath)) { + console.error(`PAYABLI_ENV_FILE=${envFileName} does not exist at ${envFilePath}`); + process.exit(1); +} +loadEnv(envFilePath); const port = Number.parseInt(process.env.PORT || "8787", 10); const bindHost = stringValue(process.env.PAYABLI_LOCAL_TOKEN_SERVER_HOST) || "127.0.0.1"; @@ -99,6 +108,13 @@ async function handleRequest(req, res) { server.listen(port, bindHost, () => { console.log(`Payabli local token server listening on http://${bindHost}:${port}`); + // The upstream and the file it came from. Without these, two runs on two environments are + // indistinguishable in the log, and a refusal from the wrong one reads as a bad entry point. + console.log(`Upstream: ${defaultApiBaseUrl}`); + console.log(`Env file: ${envFilePath}`); + if (defaultEntry) { + console.log(`Entry point: ${defaultEntry}`); + } console.log(`Access token endpoint: http://${bindHost}:${port}/payabli/access-token`); console.log(`Tap to Pay devices: http://${bindHost}:${port}/payabli/devices`); console.log(`Activation code: http://${bindHost}:${port}/payabli/activation-code`); From 4d899fd07f20c31c3e468bb62130bb8dcc00833e Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 18:52:05 -0700 Subject: [PATCH 3/7] Name the launch argument this app actually parses The note added in this branch told a reader to pass -Ppayabli.demo.environment, which is the Android Gradle property. DemoConfiguration parses -PayabliEnvironment, so following it left the app on its previous environment while the server switched upstream, which is the mismatch the note exists to prevent. --- Example/PayabliDemo/LocalTokenServer/.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Example/PayabliDemo/LocalTokenServer/.env.example b/Example/PayabliDemo/LocalTokenServer/.env.example index b637b8f..e765c09 100644 --- a/Example/PayabliDemo/LocalTokenServer/.env.example +++ b/Example/PayabliDemo/LocalTokenServer/.env.example @@ -10,7 +10,7 @@ # that does not exist is fatal rather than a silent fall back to the defaults below. # # An entry point exists in one environment, so PAYABLI_API_BASE_URL, the credential and the app's -# -Ppayabli.demo.environment all move together. +# -PayabliEnvironment launch argument all move together. PORT=8787 PAYABLI_LOCAL_TOKEN_SERVER_HOST=127.0.0.1 From 86921f9bf506eb031d6611c6fa357c4788098974 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 18:52:05 -0700 Subject: [PATCH 4/7] Check the token endpoint, and stop following redirects with the credential exchangeCredentials resolved its endpoint and posted clientId and clientSecret to it without checking it against the allowed hosts, and neither fetch in this file set a redirect mode. The redirect half is live. A 307 or 308 replays the method and body, so an allowed host answering with a Location on another origin is handed the client id and secret, and the check cannot see it because a redirect target only exists after the request. Both fetches now use redirect: "manual" and report a 3xx with its target. The endpoint check is defence in depth rather than a reachable escape. A tokenPath of //host survives normalizeTokenPath, but the leading slashes are stripped before resolution: measured, //evil.example.com/steal resolves to api-qa.payabli.com/api/evil.example.com/steal, inside the allowed host. The Android demo server checks at this point and this one did not, which is the divergence being closed. --- .../PayabliDemo/LocalTokenServer/server.mjs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Example/PayabliDemo/LocalTokenServer/server.mjs b/Example/PayabliDemo/LocalTokenServer/server.mjs index 01e8b8a..7c844a0 100644 --- a/Example/PayabliDemo/LocalTokenServer/server.mjs +++ b/Example/PayabliDemo/LocalTokenServer/server.mjs @@ -159,8 +159,17 @@ async function exchangeCredentials(options = {}, { forceRefresh = false } = {}) } const endpoint = new URL(tokenPath.replace(/^\/+/, ""), ensureTrailingSlash(apiBaseUrl)); + assertAllowedEndpoint(endpoint, "The resolved token endpoint"); + + // redirect: "manual" so a 3xx comes back as a response instead of being followed. fetch follows + // redirects by default, and a 307 or 308 replays the method and body, so an allowed host answering + // with a Location on another origin would hand it the client id and secret. The check above cannot + // see that: it runs before the request, and a redirect target only exists afterwards. "manual" + // rather than "error" because it keeps the target readable, where a bare fetch rejection reports + // "fetch failed" and cannot be told apart from the host being down. const upstream = await fetch(endpoint, { method: "POST", + redirect: "manual", headers: { "Accept": "application/json", "Content-Type": "application/json" @@ -168,6 +177,15 @@ async function exchangeCredentials(options = {}, { forceRefresh = false } = {}) body: JSON.stringify({ clientId, clientSecret }) }); + if (upstream.status >= 300 && upstream.status < 400) { + throw new LocalTokenServerError( + 502, + `Token exchange to ${endpoint.origin} answered HTTP ${upstream.status} redirecting to ` + + `${upstream.headers.get("location") || "an unnamed target"}. The redirect was not followed, ` + + "because the credential would be sent to the target." + ); + } + const text = await upstream.text(); let payload; try { @@ -206,8 +224,11 @@ async function payabliApi(path, { method = "GET", body = null, options = {} } = const endpoint = new URL(path.replace(/^\/+/, ""), ensureTrailingSlash(apiBaseUrl)); assertAllowedEndpoint(endpoint, "The resolved API endpoint"); + // redirect: "manual", as the credential exchange does and for the same reason: a 307 or 308 replays + // the method, body and Authorization header to whatever origin the Location names. const upstream = await fetch(endpoint, { method, + redirect: "manual", headers: { "Accept": "application/json", "Content-Type": "application/json", From e6aa267ae8955e78393bd6f89969c88d30baf4c5 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 18:52:05 -0700 Subject: [PATCH 5/7] Pass the routes their own fields, not the whole request body Both card-present routes forwarded the request body into an options object that reaches payabliApi, where apiBaseUrl, accessToken, clientId, clientSecret and tokenPath are all honoured, so a caller chose the upstream. Measured on the Android demo server, which had the same routes: posting an apiBaseUrl of api-sandbox sent a qa credential to sandbox, which answered InvalidCredentials. The routes now pass what they document: the entry for the device list, the entry and deviceId for the activation code. Found by sweeping for the shape a review found on the sibling, not reported here. --- Example/PayabliDemo/LocalTokenServer/server.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Example/PayabliDemo/LocalTokenServer/server.mjs b/Example/PayabliDemo/LocalTokenServer/server.mjs index 7c844a0..fb0cc66 100644 --- a/Example/PayabliDemo/LocalTokenServer/server.mjs +++ b/Example/PayabliDemo/LocalTokenServer/server.mjs @@ -92,14 +92,21 @@ async function handleRequest(req, res) { if (url.pathname === "/payabli/devices" && ["GET", "POST"].includes(req.method || "")) { const body = req.method === "POST" ? await readJsonBody(req) : {}; const entry = stringValue(body.entry) || stringValue(url.searchParams.get("entry")) || defaultEntry; - const devices = await listTapToPayDevices(entry, body); + // The entry only. Anything else on the body would reach payabliApi as upstream options, where + // apiBaseUrl, accessToken, clientId and clientSecret are all honoured, so a caller could spend + // the env file's credential against any allowed host, production included. + const devices = await listTapToPayDevices(entry, {}); sendJson(res, 200, { entry, devices }); return; } if (url.pathname === "/payabli/activation-code" && req.method === "POST") { const body = await readJsonBody(req); - sendJson(res, 200, await requestActivationCode(body)); + // The two fields this route documents, for the reason above. + sendJson(res, 200, await requestActivationCode({ + entry: stringValue(body.entry), + deviceId: stringValue(body.deviceId) + })); return; } From 25be489023dacef75f2ebc0f46ed96f5e6f8a6c8 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 19:22:26 -0700 Subject: [PATCH 6/7] Refuse an activation response that carries no code The route promises an activation code. An upstream envelope that reports success with no responseData.code was returned as HTTP 200 with code: "", so a caller reads an unusable response as issuance and the device is never activated. An empty code is now a 502 naming the device and the entry point, which is the same shape the decline path already uses. Found by review on the sibling, payabli/sdk-android#37, not reported here. Co-Authored-By: Claude Opus 5 (1M context) --- Example/PayabliDemo/LocalTokenServer/server.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Example/PayabliDemo/LocalTokenServer/server.mjs b/Example/PayabliDemo/LocalTokenServer/server.mjs index fb0cc66..d283d9d 100644 --- a/Example/PayabliDemo/LocalTokenServer/server.mjs +++ b/Example/PayabliDemo/LocalTokenServer/server.mjs @@ -378,11 +378,21 @@ async function requestActivationCode(options = {}) { } const data = payload.responseData || {}; + // An envelope that reports success and carries no code is an upstream fault, not an activation. + // Returned as 200 with an empty code it reads as issuance, and the device is never activated. + const code = stringValue(data.code); + if (!code) { + throw new LocalTokenServerError( + 502, + `Activation challenge for ${deviceId} on ${entry} reported success and returned no code.` + ); + } + return { entry, deviceId, resolvedFrom, - code: stringValue(data.code), + code, expiresAt: stringValue(data.expiresAt), alreadyIssued: Boolean(data.alreadyIssued) }; From 265477f3e9e55492ee5f569add6bf90b1b99316d Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 19:38:10 -0700 Subject: [PATCH 7/7] Say which env file is missing the entry point, and what was skipped Both found by review on the sibling, payabli/sdk-android#37, not reported here. The message telling someone to set PAYABLI_ENTRY named .env even when PAYABLI_ENV_FILE had selected another file, so following it edited the environment that was not running. It names the file that was loaded. A per-device lookup that came back declined was turned into null and the device left the list with nothing said, so a decline for provisioning or authorisation was indistinguishable from a device that is not there. The response carries an unavailable list of deviceId, code and text. Which codes mean a stale row is documented nowhere this server can read, so it reports what it skipped rather than deciding which declines are benign. The README here already documents the activation request shape, which was the third finding on the sibling and does not apply. Co-Authored-By: Claude Opus 5 (1M context) --- .../PayabliDemo/LocalTokenServer/server.mjs | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/Example/PayabliDemo/LocalTokenServer/server.mjs b/Example/PayabliDemo/LocalTokenServer/server.mjs index d283d9d..6cd7c32 100644 --- a/Example/PayabliDemo/LocalTokenServer/server.mjs +++ b/Example/PayabliDemo/LocalTokenServer/server.mjs @@ -95,8 +95,8 @@ async function handleRequest(req, res) { // The entry only. Anything else on the body would reach payabliApi as upstream options, where // apiBaseUrl, accessToken, clientId and clientSecret are all honoured, so a caller could spend // the env file's credential against any allowed host, production included. - const devices = await listTapToPayDevices(entry, {}); - sendJson(res, 200, { entry, devices }); + const { devices, unavailable } = await listTapToPayDevices(entry, {}); + sendJson(res, 200, { entry, devices, unavailable }); return; } @@ -292,7 +292,12 @@ async function describeDevice(entry, deviceId, options = {}) { `/Device/get/${encodeURIComponent(entry)}/${encodeURIComponent(deviceId)}`, { options } ); - return envelopeDecline(payload) ? null : payload.responseData || null; + // Reported rather than dropped. Returning null removed the device from the list with nothing + // said, so a lookup declined for provisioning or authorisation looked the same as a device that + // is not there. Which decline codes mean a stale row is documented nowhere this server can read, + // so it names what it skipped instead of deciding. + const decline = envelopeDecline(payload); + return decline ? { deviceId, decline } : { deviceId, device: payload.responseData || null }; } // `/Device/list` omits pending devices, which are the only ones that can be @@ -300,7 +305,10 @@ async function describeDevice(entry, deviceId, options = {}) { // described individually to get its status. async function listTapToPayDevices(entry, options = {}) { if (!entry) { - throw new LocalTokenServerError(400, "Set PAYABLI_ENTRY in .env, or pass entry in the request."); + throw new LocalTokenServerError( + 400, + `Set PAYABLI_ENTRY in ${envFilePath}, or pass entry in the request.` + ); } const payload = await payabliApi(`/Cloud/list/${encodeURIComponent(entry)}`, { options }); @@ -318,7 +326,12 @@ async function listTapToPayDevices(entry, options = {}) { described.push(...batch); } - return described + const unavailable = described + .filter((row) => row.decline) + .map((row) => ({ deviceId: row.deviceId, code: row.decline.code, text: row.decline.text })); + + const devices = described + .map((row) => row.device) .filter((device) => device && stringValue(device.deviceType).toLowerCase() === "softpos") .map((device) => ({ deviceId: device.deviceId, @@ -331,6 +344,8 @@ async function listTapToPayDevices(entry, options = {}) { updatedAt: device.updatedAt })) .sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || ""))); + + return { devices, unavailable }; } // Requests the activation code for a pending device. Idempotent upstream: an @@ -338,7 +353,10 @@ async function listTapToPayDevices(entry, options = {}) { async function requestActivationCode(options = {}) { const entry = stringValue(options.entry) || defaultEntry; if (!entry) { - throw new LocalTokenServerError(400, "Set PAYABLI_ENTRY in .env, or pass entry in the request."); + throw new LocalTokenServerError( + 400, + `Set PAYABLI_ENTRY in ${envFilePath}, or pass entry in the request.` + ); } let deviceId = stringValue(options.deviceId); @@ -349,7 +367,7 @@ async function requestActivationCode(options = {}) { // deviceId does. Falling back to the newest pending device is a convenience // for a single-device QA setup, and reports itself as such. if (!deviceId) { - const devices = await listTapToPayDevices(entry, options); + const { devices } = await listTapToPayDevices(entry, options); const pending = devices.filter((device) => device.deviceStatus === DEVICE_STATUS_PENDING); if (pending.length === 0) {