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
41 changes: 41 additions & 0 deletions apps/cli/src/core/__tests__/user-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { installUserAgent, isOurHost, USER_AGENT } from "../user-agent.js";
import { PKG_VERSION } from "../version.js";

function fakeFetch() {
const seen: { url: string; ua: string | null }[] = [];
const target = {
fetch: (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
seen.push({ url, ua: new Headers(init?.headers).get("user-agent") });
return new Response("ok");
}) as typeof fetch,
};
return { target, seen };
}

describe("user agent", () => {
it("names the CLI and its version in a form the nightly report parses", () => {
expect(USER_AGENT.startsWith(`threatcrush/${PKG_VERSION} `)).toBe(true);
});

it("only matches our own hosts", () => {
expect(isOurHost("https://threatcrush.com/api/ingest", "https://threatcrush.com")).toBe(true);
expect(isOurHost("https://supabase.threatcrush.com/rest/v1/x", "https://threatcrush.com")).toBe(true);
expect(isOurHost("http://localhost:3000/api/ingest", "http://localhost:3000")).toBe(true);
expect(isOurHost("https://evilthreatcrush.com/", "https://threatcrush.com")).toBe(false);
expect(isOurHost("https://feodotracker.abuse.ch/x", "https://threatcrush.com")).toBe(false);
expect(isOurHost("not a url", "https://threatcrush.com")).toBe(false);
});

it("adds the agent to our API, keeps other headers, leaves third parties and explicit agents alone", async () => {
const { target, seen } = fakeFetch();
installUserAgent(target);
installUserAgent(target); // idempotent: no double wrap
await target.fetch("https://threatcrush.com/api/ingest", { method: "POST", headers: { authorization: "Bearer x" } });
await target.fetch(new URL("https://threatcrush.com/api/auth/me"));
await target.fetch("https://discord.com/api/webhooks/1");
await target.fetch("https://threatcrush.com/api/x", { headers: { "User-Agent": "custom" } });
expect(seen.map((s) => s.ua)).toEqual([USER_AGENT, USER_AGENT, null, "custom"]);
});
});
47 changes: 47 additions & 0 deletions apps/cli/src/core/user-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { apiUrl } from './cli-config.js';
import { PKG_VERSION } from './version.js';

// Node's fetch says "node" and nothing else, so threatcrush.com cannot tell a
// CLI on 0.13.40 from one on 0.13.51, or the CLI from any other Node script.
// The CLI and the daemon both call installUserAgent() once at startup; every
// request to our own API that does not set a User-Agent then carries
// `threatcrush/<version>`. Third-party hosts (abuse.ch, Discord, PagerDuty, a
// scan target) are left alone: they get Node's default, as before.

export const USER_AGENT = `threatcrush/${PKG_VERSION} (node ${process.version}; ${process.platform})`;

/** True for the configured API host and threatcrush.com or any of its subdomains. */
export function isOurHost(url: string, api = apiUrl()): boolean {
let host: string;
try {
host = new URL(url).hostname.toLowerCase();
} catch {
return false;
}
let apiHost = '';
try {
apiHost = new URL(api).hostname.toLowerCase();
} catch {}
return host === apiHost || host === 'threatcrush.com' || host.endsWith('.threatcrush.com');
}

function urlOf(input: Parameters<typeof fetch>[0]): string {
if (typeof input === 'string') return input;
if (input instanceof URL) return input.href;
return input.url;
}

/** Wraps `target.fetch` so requests to our API carry the CLI's user agent. Idempotent. */
export function installUserAgent(target: { fetch: typeof fetch } = globalThis): void {
const base = target.fetch as typeof fetch & { __threatcrushUA?: true };
if (!base || base.__threatcrushUA) return;
const wrapped = ((input: Parameters<typeof fetch>[0], init?: RequestInit) => {
if (!isOurHost(urlOf(input))) return base(input, init);
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
if (headers.has('user-agent')) return base(input, init);
headers.set('user-agent', USER_AGENT);
return base(input, { ...init, headers });
}) as typeof fetch & { __threatcrushUA?: true };
wrapped.__threatcrushUA = true;
target.fetch = wrapped;
}
3 changes: 3 additions & 0 deletions apps/cli/src/daemon-entry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
#!/usr/bin/env node
import { runDaemon } from './daemon/index.js';
import { installUserAgent } from './core/user-agent.js';

installUserAgent();

runDaemon().catch((err) => {
console.error('threatcrushd failed to start:', err);
Expand Down
3 changes: 3 additions & 0 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ import {
import { IpcClient } from "./core/ipc-client.js";
import { findRunningDaemon } from "./daemon/pidfile.js";
import { PKG_VERSION } from "./core/version.js";
import { installUserAgent } from "./core/user-agent.js";

installUserAgent();

const LOGO = `
${chalk.green(" ████████╗██╗ ██╗██████╗ ███████╗ █████╗ ████████╗")}
Expand Down
Loading