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
1 change: 0 additions & 1 deletion node/src/commands/backend/get.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Command } from "commander";
import axios from "axios";
import {
API,
resolveKey,
Expand Down
14 changes: 7 additions & 7 deletions node/src/commands/backend/run.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Command } from "commander";
import axios from "axios";
import ora from "ora";
import { detectRepo } from "../../utils/git.js";
import {
Expand All @@ -8,8 +7,9 @@ import {
EXIT_GENERAL_ERROR,
EXIT_QUOTA_EXHAUSTED,
EXIT_CONFIRMATION_REQUIRED,
handle403
} from "../../utils/api.js";
handle403,
apiClient,
apiUrl} from "../../utils/api.js";
import { ConfigManager } from "../../core/config-manager.js";
import { loadPolicy } from "../../core/policy-loader.js";
import { askYesNo } from "../../utils/prompt.js";
Expand Down Expand Up @@ -133,8 +133,8 @@ export async function runRemoteScan(opts: RunOpts): Promise<void> {
if (!opts.quiet) {
const spinner = ora("Submitting scan").start();
try {
const { data } = await axios.post(
`${API}/static/scan`,
const { data } = await apiClient.post(
apiUrl("static/scan"),
body,
{ headers: { "x-api-key": key } }
);
Expand All @@ -161,8 +161,8 @@ export async function runRemoteScan(opts: RunOpts): Promise<void> {
}
} else {
try {
const { data } = await axios.post(
`${API}/static/scan`,
const { data } = await apiClient.post(
apiUrl("static/scan"),
body,
{ headers: { "x-api-key": key } }
);
Expand Down
8 changes: 4 additions & 4 deletions node/src/commands/backend/scan-status.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import axios from "axios";
import ora from "ora";
import {
API,
API_TIMEOUT_SHORT_MS,
writePayload,
EXIT_GENERAL_ERROR,
EXIT_SCAN_NOT_FOUND
} from "../../utils/api.js";
EXIT_SCAN_NOT_FOUND,
apiClient,
apiUrl} from "../../utils/api.js";
import { fmt as output } from "../../utils/formatter.js";

/**
Expand Down Expand Up @@ -175,7 +175,7 @@ async function pollUntilReadable(
): Promise<any> {
for (;;) {
try {
const res = await axios.get(`${API}/static/scan`, {
const res = await apiClient.get(apiUrl("static/scan"), {
params: { scan_id, format: fmt },
headers,
// Without this a hung server stalls inside a single request, and the
Expand Down
5 changes: 2 additions & 3 deletions node/src/commands/backend/usage.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { Command } from "commander";
import axios from "axios";
import { API, resolveKey, EXIT_GENERAL_ERROR } from "../../utils/api.js";
import { API, resolveKey, EXIT_GENERAL_ERROR, apiClient, apiUrl} from "../../utils/api.js";

export function createUsageCommand(): Command {
return new Command("usage")
.option("-k, --api-key <key>", "API key or RAFTER_API_KEY env var")
.action(async (opts) => {
const key = resolveKey(opts.apiKey);
try {
const { data } = await axios.get(`${API}/static/usage`, { headers: { "x-api-key": key } });
const { data } = await apiClient.get(apiUrl("static/usage"), { headers: { "x-api-key": key } });
console.log(JSON.stringify(data, null, 2));
} catch (e: any) {
if (e.response?.data) {
Expand Down
5 changes: 2 additions & 3 deletions node/src/commands/issues/from-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
*/
import { Command } from "commander";
import fs from "fs";
import axios from "axios";
import { API, resolveKey, EXIT_GENERAL_ERROR } from "../../utils/api.js";
import { API, resolveKey, EXIT_GENERAL_ERROR, apiClient, apiUrl} from "../../utils/api.js";
import { detectRepo } from "../../utils/git.js";
import { fmt } from "../../utils/formatter.js";
import { createIssue, listOpenIssues } from "./github-client.js";
Expand Down Expand Up @@ -170,7 +169,7 @@ async function draftsFromBackendScan(
apiKey?: string
): Promise<IssueDraft[]> {
const key = resolveKey(apiKey);
const { data } = await axios.get(`${API}/static/scan`, {
const { data } = await apiClient.get(apiUrl("static/scan"), {
params: { scan_id: scanId, format: "json" },
headers: { "x-api-key": key },
});
Expand Down
11 changes: 5 additions & 6 deletions node/src/commands/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@ import { AuditLogger } from "../../core/audit-logger.js";
import { ConfigManager, redactConfigSecrets, isSecretConfigKey, maskSecretValue } from "../../core/config-manager.js";
import { listDocs, resolveDocSelector, fetchDoc } from "../../core/docs-loader.js";
import { writeSuppression } from "../../core/suppression-writer.js";
import { apiUrl } from "../../utils/api.js";
import { apiUrl, apiClient} from "../../utils/api.js";
import { describeSitesError, resolveMcpApiKey } from "../sites/errors.js";
import axios from "axios";
import { createRequire } from "module";

const _require = createRequire(import.meta.url);
Expand Down Expand Up @@ -361,7 +360,7 @@ export function createServer(): Server {
const key = resolveMcpApiKey();
if (!key) return errorResult("No API key configured. Set RAFTER_API_KEY or run 'rafter agent config set backend.apiKey <key>'.");
try {
const { data } = await axios.post(apiUrl("static/sites"), { url }, { headers: { "x-api-key": key } });
const { data } = await apiClient.post(apiUrl("static/sites"), { url }, { headers: { "x-api-key": key } });
return textResult(data);
} catch (e: any) {
return errorResult(describeSitesError(e).message);
Expand All @@ -378,7 +377,7 @@ export function createServer(): Server {
const body: Record<string, unknown> = projectId ? { projectId } : { url };
if (Array.isArray(args?.sections)) body.sections = (args!.sections as unknown[]).map((s) => String(s));
try {
const { data } = await axios.post(apiUrl("static/sites/scan"), body, { headers: { "x-api-key": key } });
const { data } = await apiClient.post(apiUrl("static/sites/scan"), body, { headers: { "x-api-key": key } });
return textResult(data);
} catch (e: any) {
return errorResult(describeSitesError(e).message);
Expand All @@ -393,7 +392,7 @@ export function createServer(): Server {
if (args?.offset !== undefined) params.offset = String(args.offset);
if (args?.include_archived) params.include_archived = "true";
try {
const { data } = await axios.get(apiUrl("static/sites"), { params, headers: { "x-api-key": key } });
const { data } = await apiClient.get(apiUrl("static/sites"), { params, headers: { "x-api-key": key } });
return textResult(data);
} catch (e: any) {
return errorResult(describeSitesError(e).message);
Expand All @@ -406,7 +405,7 @@ export function createServer(): Server {
const key = resolveMcpApiKey();
if (!key) return errorResult("No API key configured. Set RAFTER_API_KEY or run 'rafter agent config set backend.apiKey <key>'.");
try {
const { data } = await axios.get(apiUrl(`static/sites/${encodeURIComponent(id)}`), { headers: { "x-api-key": key } });
const { data } = await apiClient.get(apiUrl(`static/sites/${encodeURIComponent(id)}`), { headers: { "x-api-key": key } });
return textResult(data);
} catch (e: any) {
return errorResult(describeSitesError(e).message);
Expand Down
5 changes: 2 additions & 3 deletions node/src/commands/notify.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Command } from "commander";
import axios from "axios";
import { API, resolveKey, EXIT_GENERAL_ERROR, EXIT_SCAN_NOT_FOUND } from "../utils/api.js";
import { API, resolveKey, EXIT_GENERAL_ERROR, EXIT_SCAN_NOT_FOUND, apiClient, apiUrl} from "../utils/api.js";
import { validateWebhookUrl } from "../core/audit-logger.js";
import { ConfigManager } from "../core/config-manager.js";
import { fmt, isAgentMode } from "../utils/formatter.js";
Expand Down Expand Up @@ -221,7 +220,7 @@ export function createNotifyCommand(): Command {
if (scanId) {
const key = resolveKey(opts?.apiKey as string | undefined);
try {
const { data } = await axios.get(`${API}/static/scan`, {
const { data } = await apiClient.get(apiUrl("static/scan"), {
params: { scan_id: scanId, format: "json" },
headers: { "x-api-key": key },
});
Expand Down
5 changes: 2 additions & 3 deletions node/src/commands/sites/create.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Command } from "commander";
import axios from "axios";
import { apiUrl, resolveKey, writePayload, EXIT_GENERAL_ERROR } from "../../utils/api.js";
import { apiUrl, resolveKey, writePayload, EXIT_GENERAL_ERROR, apiClient} from "../../utils/api.js";
import { describeSitesError, rejectUnsupportedFormat } from "./errors.js";

export interface SitesCreateOpts {
Expand All @@ -14,7 +13,7 @@ export async function runSitesCreate(url: string, opts: SitesCreateOpts): Promis
if (rejectUnsupportedFormat(opts.format)) return EXIT_GENERAL_ERROR;
const key = resolveKey(opts.apiKey);
try {
const { data } = await axios.post(
const { data } = await apiClient.post(
apiUrl("static/sites"),
{ url },
{ headers: { "x-api-key": key } }
Expand Down
5 changes: 2 additions & 3 deletions node/src/commands/sites/get.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Command } from "commander";
import axios from "axios";
import { apiUrl, resolveKey, writePayload, EXIT_GENERAL_ERROR } from "../../utils/api.js";
import { apiUrl, resolveKey, writePayload, EXIT_GENERAL_ERROR, apiClient} from "../../utils/api.js";
import { describeSitesError, rejectUnsupportedFormat } from "./errors.js";

export interface SitesGetOpts {
Expand All @@ -14,7 +13,7 @@ export async function runSitesGet(id: string, opts: SitesGetOpts): Promise<numbe
if (rejectUnsupportedFormat(opts.format)) return EXIT_GENERAL_ERROR;
const key = resolveKey(opts.apiKey);
try {
const { data } = await axios.get(
const { data } = await apiClient.get(
apiUrl(`static/sites/${encodeURIComponent(id)}`),
{ headers: { "x-api-key": key } }
);
Expand Down
5 changes: 2 additions & 3 deletions node/src/commands/sites/list.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Command } from "commander";
import axios from "axios";
import { apiUrl, resolveKey, writePayload, EXIT_GENERAL_ERROR } from "../../utils/api.js";
import { apiUrl, resolveKey, writePayload, EXIT_GENERAL_ERROR, apiClient} from "../../utils/api.js";
import { describeSitesError, rejectUnsupportedFormat } from "./errors.js";

export interface SitesListOpts {
Expand All @@ -22,7 +21,7 @@ export async function runSitesList(opts: SitesListOpts): Promise<number> {
if (opts.includeArchived) params.include_archived = "true";

try {
const { data } = await axios.get(
const { data } = await apiClient.get(
apiUrl("static/sites"),
{ params, headers: { "x-api-key": key } }
);
Expand Down
5 changes: 2 additions & 3 deletions node/src/commands/sites/scan.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Command } from "commander";
import axios from "axios";
import { apiUrl, resolveKey, writePayload, EXIT_GENERAL_ERROR } from "../../utils/api.js";
import { apiUrl, resolveKey, writePayload, EXIT_GENERAL_ERROR, apiClient} from "../../utils/api.js";
import { describeSitesError, rejectUnsupportedFormat } from "./errors.js";

const VALID_SECTIONS = new Set(["flight", "security", "dns"]);
Expand Down Expand Up @@ -42,7 +41,7 @@ export async function runSitesScan(projectIdOrUrl: string, opts: SitesScanOpts):
}

try {
const { data } = await axios.post(
const { data } = await apiClient.post(
apiUrl("static/sites/scan"),
body,
{ headers: { "x-api-key": key } }
Expand Down
45 changes: 45 additions & 0 deletions node/src/utils/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,52 @@
import axios from "axios";
import { ConfigManager } from "../core/config-manager.js";

export const API = "https://rafter.so/api/";

/**
* sable-2s6p — the HTTP client for every authenticated Rafter API call.
*
* `maxRedirects: 0` is the point of it. axios (via follow-redirects) replays
* request headers on a redirect, and unlike `Authorization` the custom
* `x-api-key` header is not stripped when the host changes. Since the API base
* is user-settable (`--rafter-url`, self-hosted installs), a 302 from a
* misconfigured or hostile endpoint would walk the caller's API key to another
* host. Nothing in this CLI needs to follow a redirect, so none of them do.
*
* Use this for anything that sends `x-api-key`. Plain `axios` is fine for
* user-supplied webhooks and other unauthenticated calls.
*/
export const apiClient = axios.create({
maxRedirects: 0,
});

/**
* A redirect target is attacker-controlled if the endpoint is. Header values
* cannot contain CR/LF, but ESC is a legal byte, so an unsanitized Location can
* emit ANSI sequences that rewrite the user's terminal. Strip anything
* non-printable and cap the length.
*/
function safeForTerminal(value: unknown): string {
if (typeof value !== "string") return "";
// eslint-disable-next-line no-control-regex
const printable = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
return printable.length > 200 ? `${printable.slice(0, 200)}…` : printable;
}

// A refused redirect otherwise surfaces as a bare "Request failed with status
// code 302", which tells the user nothing about why. Name the cause.
apiClient.interceptors.response.use(undefined, (error: any) => {
const status = error?.response?.status;
if (status >= 300 && status < 400) {
const target = safeForTerminal(error?.response?.headers?.location) || "another host";
error.message =
`The Rafter API redirected to ${target}, and Rafter does not follow redirects ` +
`on authenticated requests — your API key would be sent to the redirect target. ` +
`If you are pointing Rafter at a self-hosted instance, use its final URL.`;
}
return Promise.reject(error);
});

/** Join API with a path segment without producing a double slash, regardless of leading/trailing slashes on either side. */
export function apiUrl(path: string): string {
return `${API.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
Expand Down
Loading
Loading