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
6 changes: 6 additions & 0 deletions .changeset/brave-wolves-hunt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@spotlightjs/spotlight": minor
---

Add `--allowed-origin` / `-A` CLI option and `allowedOrigins` API option for configuring additional CORS origins. Supports both full origins (e.g., `https://ngrok.io:443`) for strict matching and plain domains (e.g., `myapp.local`) for permissive matching. Fixes [#1171](https://github.com/getsentry/spotlight/issues/1171).

16 changes: 14 additions & 2 deletions packages/spotlight/src/server/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ const PARSE_ARGS_CONFIG = {
short: "f",
default: "human",
},
"allowed-origin": {
type: "string",
short: "A",
multiple: true,
default: [] as string[],
},
// Deprecated -- use the positional `mcp` argument instead
"stdio-mcp": {
type: "boolean",
Expand All @@ -54,6 +60,7 @@ export type CLIArgs = {
format: FormatterType;
cmd: string | undefined;
cmdArgs: string[];
allowedOrigins: string[];
};

export function parseCLIArgs(): CLIArgs {
Expand Down Expand Up @@ -102,13 +109,18 @@ export function parseCLIArgs(): CLIArgs {
process.exit(1);
}

// Parse allowed origins - supports both repeatable flags and comma-separated values
const allowedOriginInput = values["allowed-origin"] as string[];
const allowedOrigins = allowedOriginInput.flatMap(origin => origin.split(",").map(o => o.trim())).filter(Boolean);

const result: CLIArgs = {
debug: values.debug as boolean,
help: values.help as boolean,
format: format as FormatterType,
port,
cmd: positionals[0],
cmdArgs: cmdArgs ?? positionals.slice(1),
allowedOrigins,
};

return result;
Expand All @@ -126,7 +138,7 @@ export async function main({
basePath,
filesToServe,
}: { basePath?: CLIHandlerOptions["basePath"]; filesToServe?: CLIHandlerOptions["filesToServe"] } = {}) {
let { cmd, cmdArgs, help, port, debug, format } = parseCLIArgs();
let { cmd, cmdArgs, help, port, debug, format, allowedOrigins } = parseCLIArgs();
if (debug || process.env.SPOTLIGHT_DEBUG) {
enableDebugLogging(true);
}
Expand All @@ -140,5 +152,5 @@ export async function main({

const handler = CLI_CMD_MAP.get(cmd) || showHelp;

return await handler({ cmd, cmdArgs, port, help, debug, format, basePath, filesToServe });
return await handler({ cmd, cmdArgs, port, help, debug, format, basePath, filesToServe, allowedOrigins });
}
8 changes: 8 additions & 0 deletions packages/spotlight/src/server/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ Options:
-d, --debug Enable debug logging
-f, --format <format> Output format for tail command (default: human)
Available formats: ${[...AVAILABLE_FORMATTERS].join(", ")}
-A, --allowed-origin <origin>
Additional origins to allow for CORS requests.
Can be specified multiple times or comma-separated.
Accepts full origins (https://example.com:443) for
strict matching or plain domains (myapp.local) to
allow any protocol/port.
-h, --help Show this help message

Examples:
Expand All @@ -30,6 +36,8 @@ Examples:
spotlight mcp # Start in MCP mode
spotlight --port 3000 # Start on port 3000
spotlight -p 3000 -d # Start on port 3000 with debug logging
spotlight -A myapp.local # Allow requests from myapp.local
spotlight -A https://tunnel.ngrok.io -A dev.local # Multiple origins
`);
process.exit(0);
}
20 changes: 14 additions & 6 deletions packages/spotlight/src/server/cli/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,21 @@ import { logger } from "../logger.ts";
import { setShutdownHandlers, startServer } from "../main.ts";
import { createMCPInstance } from "../mcp/mcp.ts";
import type { CLIHandlerOptions } from "../types/cli.ts";
import type { NormalizedAllowedOrigins } from "../types/utils.ts";
import { normalizeAllowedOrigins } from "../utils/cors.ts";
import { isSidecarRunning } from "../utils/extras.ts";

async function startServerWithStdioMCP(
port: CLIHandlerOptions["port"],
basePath: CLIHandlerOptions["basePath"],
filesToServe: CLIHandlerOptions["filesToServe"],
normalizedAllowedOrigins: NormalizedAllowedOrigins | undefined,
) {
const serverInstance = await startServer({
port,
basePath,
filesToServe,
normalizedAllowedOrigins,
});
setShutdownHandlers(serverInstance);

Expand All @@ -38,6 +42,7 @@ async function startMCPStdioHTTPProxy(
port: CLIHandlerOptions["port"],
basePath: CLIHandlerOptions["basePath"],
filesToServe: CLIHandlerOptions["filesToServe"],
normalizedAllowedOrigins: NormalizedAllowedOrigins | undefined,
) {
let intentionalShutdown = false;
let client: Client | null = null;
Expand Down Expand Up @@ -108,26 +113,29 @@ async function startMCPStdioHTTPProxy(
process.stdin.resume();

try {
await startMCPStdioHTTPProxy(port, basePath, filesToServe);
await startMCPStdioHTTPProxy(port, basePath, filesToServe, normalizedAllowedOrigins);
logger.info("Connection restored");
} catch (_err) {
try {
return await startServerWithStdioMCP(port, basePath, filesToServe);
return await startServerWithStdioMCP(port, basePath, filesToServe, normalizedAllowedOrigins);
} catch (_err2) {
logger.error("Failed to restart sidecar server after MCP stdio proxy closed.");
captureException(_err2);
await startMCPStdioHTTPProxy(port, basePath, filesToServe);
await startMCPStdioHTTPProxy(port, basePath, filesToServe, normalizedAllowedOrigins);
}
}
};
}

export default async function mcp({ port, basePath, filesToServe }: CLIHandlerOptions) {
export default async function mcp({ port, basePath, filesToServe, allowedOrigins }: CLIHandlerOptions) {
// Normalize allowed origins once at startup
const normalizedAllowedOrigins = allowedOrigins ? normalizeAllowedOrigins(allowedOrigins) : undefined;

if (port > 0 && (await isSidecarRunning(port))) {
logger.info("Connecting to existing MCP instance with stdio proxy...");
await startMCPStdioHTTPProxy(port, basePath, filesToServe);
await startMCPStdioHTTPProxy(port, basePath, filesToServe, normalizedAllowedOrigins);
logger.info(`Connected to existing MCP instance on port ${port}`);
} else {
return await startServerWithStdioMCP(port, basePath, filesToServe);
return await startServerWithStdioMCP(port, basePath, filesToServe, normalizedAllowedOrigins);
}
}
11 changes: 9 additions & 2 deletions packages/spotlight/src/server/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,14 @@ function createLogEnvelope(level: "info" | "error", body: string, timestamp: num
return Buffer.from(`${parts.join("\n")}\n`, "utf-8");
}

export default async function run({ port, cmdArgs, basePath, filesToServe, format }: CLIHandlerOptions) {
export default async function run({
port,
cmdArgs,
basePath,
filesToServe,
format,
allowedOrigins,
}: CLIHandlerOptions) {
let relayStdioAsLogs = true;

const fuzzySearcher = new Searcher([] as string[], {
Expand Down Expand Up @@ -147,7 +154,7 @@ export default async function run({ port, cmdArgs, basePath, filesToServe, forma
return true;
};

const serverInstance = await tail({ port, cmdArgs: [], basePath, filesToServe, format }, logChecker);
const serverInstance = await tail({ port, cmdArgs: [], basePath, filesToServe, format, allowedOrigins }, logChecker);
if (!serverInstance) {
logger.error("Failed to start Spotlight sidecar server.");
logger.error(`The port ${port} might already be in use — most likely by another Spotlight instance.`);
Expand Down
3 changes: 2 additions & 1 deletion packages/spotlight/src/server/cli/server.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { setupSpotlight } from "../main.ts";
import type { CLIHandlerOptions } from "../types/cli.ts";

export default async function server({ port, basePath, filesToServe }: CLIHandlerOptions) {
export default async function server({ port, basePath, filesToServe, allowedOrigins }: CLIHandlerOptions) {
return await setupSpotlight({
port,
basePath,
filesToServe,
isStandalone: true,
allowedOrigins,
});
}
4 changes: 2 additions & 2 deletions packages/spotlight/src/server/cli/tail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const connectUpstream = async (port: number) =>
});

export default async function tail(
{ port, cmdArgs, basePath, filesToServe, format = "logfmt" }: CLIHandlerOptions,
{ port, cmdArgs, basePath, filesToServe, format = "logfmt", allowedOrigins }: CLIHandlerOptions,
onItem?: OnItemCallback,
): Promise<ServerType | undefined> {
const eventTypes = cmdArgs.length > 0 ? cmdArgs.map(arg => arg.toLowerCase()) : ["everything"];
Expand Down Expand Up @@ -114,7 +114,7 @@ export default async function tail(
}
}

const serverInstance = await setupSpotlight({ port, filesToServe, basePath, isStandalone: true });
const serverInstance = await setupSpotlight({ port, filesToServe, basePath, isStandalone: true, allowedOrigins });

// Subscribe the onEnvelope callback to the message buffer
// This ensures it gets called whenever any envelope is added to the buffer
Expand Down
22 changes: 19 additions & 3 deletions packages/spotlight/src/server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ import { serveFilesHandler } from "./handlers/index.ts";
import { activateLogger, logger } from "./logger.ts";
import routes from "./routes/index.ts";
import type { HonoEnv, SideCarOptions, StartServerOptions } from "./types/index.ts";
import { getBuffer, isAllowedOrigin, isSidecarRunning, isValidPort, logSpotlightUrl } from "./utils/index.ts";
import {
getBuffer,
isAllowedOrigin,
isSidecarRunning,
isValidPort,
logSpotlightUrl,
normalizeAllowedOrigins,
} from "./utils/index.ts";

let portInUseRetryTimeout: NodeJS.Timeout | null = null;

Expand All @@ -32,7 +39,7 @@ export async function startServer(options: StartServerOptions): Promise<Server>

const app = new Hono<HonoEnv>().use(
cors({
origin: async origin => ((await isAllowedOrigin(origin)) ? origin : null),
origin: async origin => ((await isAllowedOrigin(origin, options.normalizedAllowedOrigins)) ? origin : null),
}),
);

Expand Down Expand Up @@ -136,7 +143,15 @@ export async function startServer(options: StartServerOptions): Promise<Server>
}

export async function setupSpotlight(
{ port, logger: customLogger, basePath, filesToServe, incomingPayload, isStandalone }: SideCarOptions = {
{
port,
logger: customLogger,
basePath,
filesToServe,
incomingPayload,
isStandalone,
allowedOrigins,
}: SideCarOptions = {
port: DEFAULT_PORT,
},
): Promise<Server | undefined> {
Expand Down Expand Up @@ -165,6 +180,7 @@ export async function setupSpotlight(
basePath,
filesToServe,
incomingPayload,
normalizedAllowedOrigins: allowedOrigins ? normalizeAllowedOrigins(allowedOrigins) : undefined,
});
setShutdownHandlers(serverInstance);
return serverInstance;
Expand Down
86 changes: 85 additions & 1 deletion packages/spotlight/src/server/routes/__tests__/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Hono } from "hono";
import { cors } from "hono/cors";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { envelopeReactClientSideError } from "../../formatters/md/__tests__/test_envelopes.ts";
import { clearDnsCache, getDnsCacheSize, isAllowedOrigin } from "../../utils/cors.ts";
import { clearDnsCache, getDnsCacheSize, isAllowedOrigin, normalizeAllowedOrigins } from "../../utils/cors.ts";
import routes from "../index.ts";

// Create test app with async CORS middleware
Expand Down Expand Up @@ -322,6 +322,90 @@ describe("CORS origin validation", () => {
});
});

describe("isAllowedOrigin function - custom allowedOrigins", () => {
it("should allow origins matching full origin entries (exact match)", async () => {
const normalized = normalizeAllowedOrigins(["https://ngrok.io:443", "http://tunnel.localtunnel.me:8080"]);

// Exact match should work
await expect(isAllowedOrigin("https://ngrok.io:443", normalized)).resolves.toBe(true);
await expect(isAllowedOrigin("http://tunnel.localtunnel.me:8080", normalized)).resolves.toBe(true);
});

it("should reject origins that don't exactly match full origin entries", async () => {
const normalized = normalizeAllowedOrigins(["https://ngrok.io:443"]);

// Different port
await expect(isAllowedOrigin("https://ngrok.io:8443", normalized)).resolves.toBe(false);
// Different protocol
await expect(isAllowedOrigin("http://ngrok.io:443", normalized)).resolves.toBe(false);
// No port (default)
await expect(isAllowedOrigin("https://ngrok.io", normalized)).resolves.toBe(false);
});

it("should allow origins matching plain domain entries (any protocol/port)", async () => {
const normalized = normalizeAllowedOrigins(["myapp.local", "dev.company.internal"]);

// Any protocol and port should work for plain domains
await expect(isAllowedOrigin("http://myapp.local", normalized)).resolves.toBe(true);
await expect(isAllowedOrigin("https://myapp.local", normalized)).resolves.toBe(true);
await expect(isAllowedOrigin("http://myapp.local:3000", normalized)).resolves.toBe(true);
await expect(isAllowedOrigin("https://myapp.local:8443", normalized)).resolves.toBe(true);
await expect(isAllowedOrigin("http://dev.company.internal:5000", normalized)).resolves.toBe(true);
});

it("should reject origins with hostnames not in plain domain entries", async () => {
const normalized = normalizeAllowedOrigins(["myapp.local"]);

await expect(isAllowedOrigin("http://other.local", normalized)).resolves.toBe(false);
await expect(isAllowedOrigin("http://myapp.local.evil.com", normalized)).resolves.toBe(false);
await expect(isAllowedOrigin("http://subdomain.myapp.local", normalized)).resolves.toBe(false);
});

it("should handle mixed allowed origins (both full origins and plain domains)", async () => {
const normalized = normalizeAllowedOrigins(["https://strict.tunnel.io:443", "permissive.local"]);

// Full origin - strict match required
await expect(isAllowedOrigin("https://strict.tunnel.io:443", normalized)).resolves.toBe(true);
await expect(isAllowedOrigin("http://strict.tunnel.io:443", normalized)).resolves.toBe(false);

// Plain domain - permissive match
await expect(isAllowedOrigin("http://permissive.local", normalized)).resolves.toBe(true);
await expect(isAllowedOrigin("https://permissive.local:8080", normalized)).resolves.toBe(true);
});

it("should be case-insensitive for both origin types", async () => {
const normalized = normalizeAllowedOrigins(["https://NGROK.IO:443", "MYAPP.LOCAL"]);

await expect(isAllowedOrigin("https://ngrok.io:443", normalized)).resolves.toBe(true);
await expect(isAllowedOrigin("https://NGROK.IO:443", normalized)).resolves.toBe(true);
await expect(isAllowedOrigin("http://myapp.local", normalized)).resolves.toBe(true);
await expect(isAllowedOrigin("http://MYAPP.LOCAL:3000", normalized)).resolves.toBe(true);
});

it("should handle empty allowedOrigins array", async () => {
const normalized = normalizeAllowedOrigins([]);
// Empty normalized should not affect default behavior
await expect(isAllowedOrigin("http://localhost", normalized)).resolves.toBe(true);
await expect(isAllowedOrigin("https://spotlightjs.com", normalized)).resolves.toBe(true);
await expect(isAllowedOrigin("https://evil.com", normalized)).resolves.toBe(false);
});

it("should handle undefined allowedOrigins", async () => {
// Undefined should not affect default behavior
await expect(isAllowedOrigin("http://localhost", undefined)).resolves.toBe(true);
await expect(isAllowedOrigin("https://spotlightjs.com", undefined)).resolves.toBe(true);
await expect(isAllowedOrigin("https://evil.com", undefined)).resolves.toBe(false);
});

it("should normalize trailing slashes in full origin matching", async () => {
const normalized = normalizeAllowedOrigins(["https://ngrok.io/"]);

// Both with and without trailing slash should match
await expect(isAllowedOrigin("https://ngrok.io", normalized)).resolves.toBe(true);
await expect(isAllowedOrigin("https://ngrok.io/", normalized)).resolves.toBe(true);
});
});

describe("isAllowedOrigin function - caching", () => {
it("should bypass cache for localhost (special-cased)", async () => {
// Cache should be empty after clearDnsCache() in beforeEach
Expand Down
1 change: 1 addition & 0 deletions packages/spotlight/src/server/types/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type CLIHandlerOptions = {
format?: FormatterType;
basePath?: SideCarOptions["basePath"];
filesToServe?: SideCarOptions["filesToServe"];
allowedOrigins?: SideCarOptions["allowedOrigins"];
};
export type CLIHandlerReturnType = Promise<any> | any;
export type CLIHandler = ((options: CLIHandlerOptions) => CLIHandlerReturnType) | (() => CLIHandlerReturnType);
13 changes: 13 additions & 0 deletions packages/spotlight/src/server/types/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ParsedEnvelope } from "../parser/processEnvelope.ts";
import type { NormalizedAllowedOrigins } from "../utils/cors.ts";

export type IncomingPayloadCallback = (body: string) => void;
export type OnEnvelopeCallback = (envelope: ParsedEnvelope["envelope"]) => void;
Expand Down Expand Up @@ -48,8 +49,20 @@ export type SideCarOptions = {
isStandalone?: boolean;

stdioMCP?: boolean;

/**
* Additional origins to allow for CORS requests.
* Useful for custom local domains, tunnels, etc.
*
* Accepts two formats:
* - Full origins (e.g., "https://ngrok.io:443") for strict matching
* - Plain domains (e.g., "myapp.local") to allow any protocol/port
*/
allowedOrigins?: string[];
};

export type StartServerOptions = Pick<SideCarOptions, "basePath" | "filesToServe" | "incomingPayload"> & {
port: number;
/** Pre-normalized allowed origins for CORS (use normalizeAllowedOrigins() to create) */
normalizedAllowedOrigins?: NormalizedAllowedOrigins;
};
Loading
Loading