Skip to content
Open
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
124 changes: 58 additions & 66 deletions AGENTS.md

Large diffs are not rendered by default.

20 changes: 17 additions & 3 deletions src/commands/cli/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@
import type { SentryContext } from "../../context.js";
import { buildCommand } from "../../lib/command.js";
import { normalizeUrl } from "../../lib/constants.js";
import { parseCustomHeaders } from "../../lib/custom-headers.js";
import {
clearAllDefaults,
type DefaultsState,
getAllDefaults,
getDefaultHeaders,
getDefaultOrganization,
getDefaultProject,
getDefaultUrl,
getTelemetryPreference,
setDefaultHeaders,
setDefaultOrganization,
setDefaultProject,
setDefaultUrl,
Expand All @@ -44,7 +47,7 @@ import { computeTelemetryEffective } from "../../lib/telemetry.js";
// ---------------------------------------------------------------------------

/** Canonical key names matching DefaultsState fields */
type DefaultKey = "organization" | "project" | "telemetry" | "url";
type DefaultKey = "organization" | "project" | "telemetry" | "url" | "headers";

/** Handler for reading, writing, and clearing a single default */
type DefaultHandler = {
Expand Down Expand Up @@ -119,6 +122,15 @@ const DEFAULTS_REGISTRY: Record<DefaultKey, DefaultHandler> = {
},
clear: () => setDefaultUrl(null),
},
headers: {
get: getDefaultHeaders,
set: (value) => {
// Validate the header string by parsing it — throws ConfigError on bad input
parseCustomHeaders(value);
setDefaultHeaders(value);
},
clear: () => setDefaultHeaders(null),
},
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -183,6 +195,7 @@ export const defaultsCommand = buildCommand({
"sentry cli defaults project my-proj # Set default project\n" +
"sentry cli defaults telemetry off # Disable telemetry\n" +
"sentry cli defaults url https://... # Set Sentry URL (self-hosted)\n" +
"sentry cli defaults headers 'X-IAP: t' # Set custom headers (self-hosted)\n" +
"sentry cli defaults org --clear # Clear a specific default\n" +
"sentry cli defaults --clear --yes # Clear all defaults\n" +
"```\n\n" +
Expand All @@ -192,7 +205,8 @@ export const defaultsCommand = buildCommand({
"| `org` | Default organization slug |\n" +
"| `project` | Default project slug |\n" +
"| `telemetry` | Telemetry preference (on/off, yes/no, true/false, 1/0) |\n" +
"| `url` | Sentry instance URL (for self-hosted installations) |",
"| `url` | Sentry instance URL (for self-hosted installations) |\n" +
"| `headers` | Custom HTTP headers for self-hosted proxies (semicolon-separated `Name: Value`) |",
},
output: {
human: formatDefaultsResult,
Expand Down Expand Up @@ -246,7 +260,7 @@ export const defaultsCommand = buildCommand({
guardNonInteractive(flags);
if (!isConfirmationBypassed(flags)) {
const confirmed = await log.prompt(
"This will clear all defaults (organization, project, telemetry, URL). Continue?",
"This will clear all defaults (organization, project, telemetry, URL, headers). Continue?",
{ type: "confirm" }
);
if (confirmed !== true) {
Expand Down
7 changes: 4 additions & 3 deletions src/lib/api/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { listAnOrganization_sIssues } from "@sentry/api";

import type { SentryIssue } from "../../types/index.js";

import { applyCustomHeaders } from "../custom-headers.js";
import { ApiError } from "../errors.js";
import { resolveOrgRegion } from "../region.js";

Expand Down Expand Up @@ -425,9 +426,9 @@ export async function getSharedIssue(
shareId: string
): Promise<{ groupID: string }> {
const url = `${baseUrl}/api/0/shared/issues/${encodeURIComponent(shareId)}/`;
const response = await fetch(url, {
headers: { "Content-Type": "application/json" },
});
const headers = new Headers({ "Content-Type": "application/json" });
applyCustomHeaders(headers);
const response = await fetch(url, { headers });

if (!response.ok) {
if (response.status === 404) {
Expand Down
220 changes: 220 additions & 0 deletions src/lib/custom-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
/**
* Custom Headers for Self-Hosted Sentry
*
* Parses `SENTRY_CUSTOM_HEADERS` env var (or `defaults.headers` from SQLite)
* and injects user-specified HTTP headers into all requests to self-hosted
* Sentry instances. Designed for environments behind reverse proxies
* (e.g., Google IAP, Cloudflare Access) that require extra headers.
*
* Format: semicolon-separated `Name: Value` pairs (newlines also accepted).
*
* @example
* ```bash
* # Single header
* SENTRY_CUSTOM_HEADERS="X-IAP-Token: abc123"
*
* # Multiple headers
* SENTRY_CUSTOM_HEADERS="X-IAP-Token: abc123; X-Forwarded-For: 10.0.0.1"
*
* # Via defaults command
* sentry cli defaults headers "X-IAP-Token: abc123"
* ```
*/

import { getConfiguredSentryUrl } from "./constants.js";
import { getDefaultHeaders } from "./db/defaults.js";
import { getEnv } from "./env.js";
import { ConfigError } from "./errors.js";
import { logger } from "./logger.js";
import { isSentrySaasUrl } from "./sentry-urls.js";

const log = logger.withTag("custom-headers");

/**
* Header names that must not be overridden via custom headers.
* These are managed by the CLI's own request pipeline and overriding
* them would break authentication, content negotiation, or tracing.
*/
const FORBIDDEN_HEADER_NAMES = new Set([
"authorization",
"host",
"content-type",
"content-length",
"user-agent",
"sentry-trace",
"baggage",
]);

/**
* RFC 7230 token characters for header field names.
* Header names consist of visible ASCII characters except delimiters.
*/
const VALID_HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~\w]+$/;

/** Splits on semicolons and newlines (both valid header separators). */
const HEADER_SEPARATOR_RE = /[;\n]/;

/** Strips trailing carriage return from a line (Windows line endings). */
const TRAILING_CR_RE = /\r$/;

/** Cached parsed headers (from env var or defaults). `undefined` = not yet parsed. */
let cachedHeaders: readonly [string, string][] | undefined;

/** Tracks the raw source string that produced `cachedHeaders`, for invalidation. */
let cachedRawSource: string | undefined;

/** Whether the SaaS warning has already been logged this session. */
let saasWarningLogged = false;

/**
* Parse a raw custom headers string into validated name/value pairs.
*
* Accepts semicolon-separated or newline-separated `Name: Value` entries.
* Empty segments and whitespace-only segments are silently skipped.
*
* @param raw - Raw header string (from env var or defaults)
* @returns Array of `[name, value]` tuples in declaration order
* @throws {ConfigError} On malformed segments or forbidden header names
*/
export function parseCustomHeaders(raw: string): readonly [string, string][] {
const results: [string, string][] = [];

// Split on semicolons and newlines
const segments = raw.split(HEADER_SEPARATOR_RE);

for (const segment of segments) {
const trimmed = segment.replace(TRAILING_CR_RE, "").trim();
if (!trimmed) {
continue;
}

const colonIndex = trimmed.indexOf(":");
if (colonIndex === -1) {
throw new ConfigError(
`Invalid header in SENTRY_CUSTOM_HEADERS: '${trimmed}'. Expected 'Name: Value' format.`
);
}

const name = trimmed.slice(0, colonIndex).trim();
const value = trimmed.slice(colonIndex + 1).trim();

if (!name) {
throw new ConfigError(
`Invalid header in SENTRY_CUSTOM_HEADERS: empty header name in '${trimmed}'.`
);
}

if (!VALID_HEADER_NAME_RE.test(name)) {
throw new ConfigError(
`Invalid header name '${name}' in SENTRY_CUSTOM_HEADERS. Header names must contain only alphanumeric characters, hyphens, and RFC 7230 token characters.`
);
}

if (FORBIDDEN_HEADER_NAMES.has(name.toLowerCase())) {
throw new ConfigError(
`Cannot override reserved header '${name}' in SENTRY_CUSTOM_HEADERS. This header is managed by the CLI.`
);
}

results.push([name, value]);
}

return results;
}

/**
* Check whether the current target is a self-hosted Sentry instance.
*
* Self-hosted = `SENTRY_HOST` or `SENTRY_URL` is set to a non-SaaS URL.
* Returns false if no custom URL is configured (implying SaaS) or if the
* configured URL points to `*.sentry.io`.
*/
function isSelfHosted(): boolean {
const configured = getConfiguredSentryUrl();
if (!configured) {
return false;
}
return !isSentrySaasUrl(configured);
}

/**
* Resolve the raw custom headers string from env var or SQLite defaults.
*
* Priority: `SENTRY_CUSTOM_HEADERS` env var > `defaults.headers` in SQLite.
* Returns undefined when no headers are configured.
*/
function resolveRawHeaders(): string | undefined {
const envValue = getEnv().SENTRY_CUSTOM_HEADERS;
if (envValue?.trim()) {
return envValue.trim();
}

const dbValue = getDefaultHeaders();
if (dbValue?.trim()) {
return dbValue.trim();
}

return;
}

/**
* Get the parsed custom headers for the current session.
*
* Returns an empty array when:
* - No custom headers are configured (env var or defaults)
* - The target is not a self-hosted instance (warns once if headers are set)
*
* Parsed results are cached; the self-hosted guard is re-evaluated per call
* because `SENTRY_HOST` can be set dynamically by URL argument parsing.
*/
export function getCustomHeaders(): readonly [string, string][] {
const raw = resolveRawHeaders();
if (!raw) {
return [];
}

// Self-hosted guard: warn once and skip on SaaS
if (!isSelfHosted()) {
if (!saasWarningLogged) {
saasWarningLogged = true;
log.warn(
"SENTRY_CUSTOM_HEADERS is set but no self-hosted Sentry instance is configured. Headers will be ignored."
);
}
return [];
}

// Return cached result if the raw source hasn't changed
if (cachedHeaders !== undefined && cachedRawSource === raw) {
return cachedHeaders;
}

cachedHeaders = parseCustomHeaders(raw);
cachedRawSource = raw;
return cachedHeaders;
}

/**
* Apply custom headers to a `Headers` instance.
*
* Reads from the env var or SQLite defaults, validates, and sets each header.
* No-op when no custom headers are configured or when targeting SaaS.
*
* @param headers - The `Headers` instance to modify in-place
*/
export function applyCustomHeaders(headers: Headers): void {
const customHeaders = getCustomHeaders();
for (const [name, value] of customHeaders) {
headers.set(name, value);
}
}

/**
* Reset module-level caches. Exported for testing only.
* @internal
*/
export function _resetCustomHeadersCache(): void {
cachedHeaders = undefined;
cachedRawSource = undefined;
saasWarningLogged = false;
}
28 changes: 28 additions & 0 deletions src/lib/db/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ const DEFAULTS_ORG = "defaults.org";
const DEFAULTS_PROJECT = "defaults.project";
const DEFAULTS_TELEMETRY = "defaults.telemetry";
const DEFAULTS_URL = "defaults.url";
const DEFAULTS_HEADERS = "defaults.headers";

/** All metadata keys used for defaults (for bulk operations) */
const ALL_DEFAULTS_KEYS = [
DEFAULTS_ORG,
DEFAULTS_PROJECT,
DEFAULTS_TELEMETRY,
DEFAULTS_URL,
DEFAULTS_HEADERS,
];

/** State of all persistent defaults */
Expand All @@ -34,6 +36,8 @@ export type DefaultsState = {
telemetry: "on" | "off" | null;
/** Default Sentry instance URL, or null if unset */
url: string | null;
/** Custom HTTP headers for self-hosted proxy auth, or null if unset */
headers: string | null;
};

/** Parse a raw telemetry metadata value to a typed "on" | "off" | null. */
Expand Down Expand Up @@ -91,6 +95,16 @@ export function getDefaultUrl(): string | null {
return m.get(DEFAULTS_URL) ?? null;
}

/**
* Get the default custom headers string, or null if not set.
* Format: semicolon-separated `Name: Value` pairs.
*/
export function getDefaultHeaders(): string | null {
const db = getDatabase();
const m = getMetadata(db, [DEFAULTS_HEADERS]);
return m.get(DEFAULTS_HEADERS) ?? null;
}

/**
* Get all persistent defaults as a structured object.
* Used by the `sentry cli defaults` show mode and JSON output.
Expand All @@ -104,6 +118,7 @@ export function getAllDefaults(): DefaultsState {
project: m.get(DEFAULTS_PROJECT) ?? null,
telemetry: parseTelemetryValue(telVal),
url: m.get(DEFAULTS_URL) ?? null,
headers: m.get(DEFAULTS_HEADERS) ?? null,
Comment thread
cursor[bot] marked this conversation as resolved.
};
}

Expand Down Expand Up @@ -154,6 +169,19 @@ export function setDefaultUrl(url: string | null): void {
}
}

/**
* Set or clear the default custom headers. Pass `null` to clear.
* Value should be semicolon-separated `Name: Value` pairs.
*/
export function setDefaultHeaders(value: string | null): void {
const db = getDatabase();
if (value === null) {
clearMetadata(db, [DEFAULTS_HEADERS]);
} else {
setMetadata(db, { [DEFAULTS_HEADERS]: value });
}
}

// ---------------------------------------------------------------------------
// Bulk operations
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading