Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ COINGECKO_API_KEY=your-coingecko-api-key
COINGECKO_API_URL=https://pro-api.coingecko.com/api/v3
FASTFOREX_API_KEY=your-fastforex-api-key
FASTFOREX_API_URL=https://api.fastforex.io
# Binance public spot ticker (no API key required); primary source for USD<>BRL rates
BINANCE_API_URL=https://api.binance.com
SUBSCAN_API_KEY=your-subscan-api-key

# Price Feed Cache Configuration
Expand Down
47 changes: 46 additions & 1 deletion apps/api/src/api/controllers/ramp.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, expect, it } from "bun:test";
import httpStatus from "http-status";
import { APIError } from "../errors/api-error";
import { classifyApiClientError } from "../observability/errorClassifier";
import { mapProviderFailure } from "./ramp.controller";
import { formatProviderContext, mapProviderFailure } from "./ramp.controller";

describe("mapProviderFailure", () => {
it("maps a 4xx Avenia rejection (e.g. blocked user) to a 422 with a sanitized public message", () => {
Expand Down Expand Up @@ -133,3 +133,48 @@ describe("mapProviderFailure", () => {
expect(logContext).toEqual({});
});
});

describe("formatProviderContext", () => {
it("embeds provider/call/status/body in the message suffix (logger drops metadata objects)", () => {
const { logContext } = mapProviderFailure(
new BrlaApiError({
endpoint: "/v2/account/tickets",
method: "POST",
responseBody: JSON.stringify({ error: "user is blocked" }),
status: 400
})
);

const suffix = formatProviderContext(logContext);

// The whole point of the fix: the failing Avenia call and reason must be in the message.
expect(suffix).toContain("provider=avenia");
expect(suffix).toContain("call=POST /v2/account/tickets");
expect(suffix).toContain("status=400");
expect(suffix).toContain('body={"error":"user is blocked"}');
});

it("returns an empty string for non-provider failures so their log line is unchanged", () => {
expect(formatProviderContext({})).toBe("");
});

it("strips control characters from the provider body so it cannot forge/split log lines", () => {
const { logContext } = mapProviderFailure(
new BrlaApiError({
endpoint: "/v2/account/tickets",
method: "POST",
// Attacker-influenced provider echo trying to inject a fake log line + ANSI escape.
responseBody: 'oops\r\n[fatal] forged line\x1b[31m',
status: 400
})
);

const body = logContext.providerResponseBody as string;
expect(body).not.toMatch(/[\r\n]/);
expect(body).not.toContain("\x1b");

const suffix = formatProviderContext(logContext);
// The whole suffix (and therefore the log line) stays single-line.
expect(suffix).not.toMatch(/[\r\n]/);
});
});
47 changes: 37 additions & 10 deletions apps/api/src/api/controllers/ramp.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ import rampService from "../services/ramp/ramp.service";
*/
const MAX_LOGGED_PROVIDER_BODY_LENGTH = 300;

/**
* Collapse control characters (CR/LF, ESC, etc.) in the untrusted provider response body to a
* single space before it is embedded in a log line. The body is external input echoed from the
* provider's HTTP response, so an embedded newline could split the line or forge a fake log
* entry (CWE-117), and an ESC could inject terminal color codes. The other fields we log
* (provider, endpoint, method, numeric status) are code-defined constants and need no escaping.
*/
function sanitizeProviderBody(body: string): string {
return body.replace(/[\x00-\x1F\x7F]+/g, " ");
}

export function mapProviderFailure(error: unknown): { error: unknown; logContext: Record<string, unknown> } {
if (!(error instanceof ProviderHttpError)) {
return { error, logContext: {} };
Expand All @@ -64,12 +75,31 @@ export function mapProviderFailure(error: unknown): { error: unknown; logContext
provider: error.provider,
providerEndpoint: error.endpoint,
providerMethod: error.method,
providerResponseBody: error.responseBody.slice(0, MAX_LOGGED_PROVIDER_BODY_LENGTH),
providerResponseBody: sanitizeProviderBody(error.responseBody).slice(0, MAX_LOGGED_PROVIDER_BODY_LENGTH),
providerStatus: error.status
}
};
}

/**
* Render the provider log context as a message suffix.
*
* The app logger (`config/logger.ts`) formats only `{ timestamp, level, message, label }` and
* drops any metadata object passed as the second argument. Provider context therefore has to
* live in the message string itself to reach the logs — passing it as metadata (as we did
* before) silently discarded it. Server-side only; the body is already truncated. Returns an
* empty string for non-provider failures so their log line is unchanged.
*/
export function formatProviderContext(logContext: Record<string, unknown>): string {
if (!logContext.provider) {
return "";
}
return (
` — provider=${logContext.provider} call=${logContext.providerMethod} ${logContext.providerEndpoint}` +
` status=${logContext.providerStatus} body=${logContext.providerResponseBody}`
);
}

/**
* Register a new ramping process
* @public
Expand Down Expand Up @@ -109,10 +139,9 @@ export const registerRamp = async (req: Request, res: Response<RampProcess>, nex
res.status(httpStatus.CREATED).json(ramp);
} catch (error) {
const { error: mappedError, logContext } = mapProviderFailure(error);
logger.error("Error registering ramp", {
logger.error(`Error registering ramp${formatProviderContext(logContext)}`, {
errorType: classifyApiClientError(mappedError),
requestId: req.requestId,
...logContext
requestId: req.requestId
});
observeRampFailure(req, "ramp_register", mappedError, { quoteId: req.body?.quoteId || null });
next(mappedError);
Expand Down Expand Up @@ -166,10 +195,9 @@ export const updateRamp = async (
res.status(httpStatus.OK).json(ramp);
} catch (error) {
const { error: mappedError, logContext } = mapProviderFailure(error);
logger.error("Error updating ramp", {
logger.error(`Error updating ramp${formatProviderContext(logContext)}`, {
errorType: classifyApiClientError(mappedError),
requestId: req.requestId,
...logContext
requestId: req.requestId
});
observeRampFailure(req, "ramp_update", mappedError, { rampId: req.body?.rampId || null });
next(mappedError);
Expand Down Expand Up @@ -213,10 +241,9 @@ export const startRamp = async (
res.status(httpStatus.OK).json(ramp);
} catch (error) {
const { error: mappedError, logContext } = mapProviderFailure(error);
logger.error("Error starting ramp", {
logger.error(`Error starting ramp${formatProviderContext(logContext)}`, {
errorType: classifyApiClientError(mappedError),
requestId: req.requestId,
...logContext
requestId: req.requestId
});
observeRampFailure(req, "ramp_start", mappedError, { rampId: req.body?.rampId || null });
next(mappedError);
Expand Down
Loading
Loading