Skip to content

Commit 26b033d

Browse files
authored
feat(custom-headers): add SENTRY_CUSTOM_HEADERS for self-hosted proxy auth (#761)
## Summary Adds support for injecting custom HTTP headers into all requests to self-hosted Sentry instances behind reverse proxies (e.g., Google IAP, Cloudflare Access). - **`SENTRY_CUSTOM_HEADERS` env var** — for CI/scripting overrides - **`sentry cli defaults headers`** — for persistent local configuration Format: semicolon-separated `Name: Value` pairs (newlines also accepted as separators). ### Self-hosted only Custom headers are only applied when targeting a self-hosted instance (`SENTRY_HOST`/`SENTRY_URL` is set to a non-`*.sentry.io` URL). When configured on SaaS, headers are ignored with a one-time warning. ### Injection points Headers are injected at all three request paths to the Sentry server: 1. **Authenticated API requests** — `prepareHeaders()` in `sentry-client.ts` 2. **OAuth device flow** — `fetchWithConnectionError()` in `oauth.ts` (fixes the login error from the issue) 3. **Shared issue resolution** — `getSharedIssue()` in `api/issues.ts` ### Resolution priority `SENTRY_CUSTOM_HEADERS` env var > `defaults.headers` in SQLite > none ### Reserved headers `Authorization`, `Host`, `Content-Type`, `Content-Length`, `User-Agent`, `sentry-trace`, `baggage` — these are managed by the CLI and cannot be overridden. ### Usage ```bash # Set via defaults (persistent) sentry cli defaults headers "X-IAP-Token: abc123" # Or via env var (CI/scripting) SENTRY_CUSTOM_HEADERS="X-IAP-Token: abc123; X-Forwarded-For: 10.0.0.1" # View current headers sentry cli defaults headers # Clear sentry cli defaults headers --clear ``` Closes #759
1 parent 0258b03 commit 26b033d

12 files changed

Lines changed: 828 additions & 101 deletions

File tree

‎AGENTS.md‎

Lines changed: 58 additions & 66 deletions
Large diffs are not rendered by default.

‎src/commands/cli/defaults.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,17 @@
1313
import type { SentryContext } from "../../context.js";
1414
import { buildCommand } from "../../lib/command.js";
1515
import { normalizeUrl } from "../../lib/constants.js";
16+
import { parseCustomHeaders } from "../../lib/custom-headers.js";
1617
import {
1718
clearAllDefaults,
1819
type DefaultsState,
1920
getAllDefaults,
21+
getDefaultHeaders,
2022
getDefaultOrganization,
2123
getDefaultProject,
2224
getDefaultUrl,
2325
getTelemetryPreference,
26+
setDefaultHeaders,
2427
setDefaultOrganization,
2528
setDefaultProject,
2629
setDefaultUrl,
@@ -44,7 +47,7 @@ import { computeTelemetryEffective } from "../../lib/telemetry.js";
4447
// ---------------------------------------------------------------------------
4548

4649
/** Canonical key names matching DefaultsState fields */
47-
type DefaultKey = "organization" | "project" | "telemetry" | "url";
50+
type DefaultKey = "organization" | "project" | "telemetry" | "url" | "headers";
4851

4952
/** Handler for reading, writing, and clearing a single default */
5053
type DefaultHandler = {
@@ -119,6 +122,15 @@ const DEFAULTS_REGISTRY: Record<DefaultKey, DefaultHandler> = {
119122
},
120123
clear: () => setDefaultUrl(null),
121124
},
125+
headers: {
126+
get: getDefaultHeaders,
127+
set: (value) => {
128+
// Validate the header string by parsing it — throws ConfigError on bad input
129+
parseCustomHeaders(value);
130+
setDefaultHeaders(value);
131+
},
132+
clear: () => setDefaultHeaders(null),
133+
},
122134
};
123135

124136
// ---------------------------------------------------------------------------
@@ -183,6 +195,7 @@ export const defaultsCommand = buildCommand({
183195
"sentry cli defaults project my-proj # Set default project\n" +
184196
"sentry cli defaults telemetry off # Disable telemetry\n" +
185197
"sentry cli defaults url https://... # Set Sentry URL (self-hosted)\n" +
198+
"sentry cli defaults headers 'X-IAP: t' # Set custom headers (self-hosted)\n" +
186199
"sentry cli defaults org --clear # Clear a specific default\n" +
187200
"sentry cli defaults --clear --yes # Clear all defaults\n" +
188201
"```\n\n" +
@@ -192,7 +205,8 @@ export const defaultsCommand = buildCommand({
192205
"| `org` | Default organization slug |\n" +
193206
"| `project` | Default project slug |\n" +
194207
"| `telemetry` | Telemetry preference (on/off, yes/no, true/false, 1/0) |\n" +
195-
"| `url` | Sentry instance URL (for self-hosted installations) |",
208+
"| `url` | Sentry instance URL (for self-hosted installations) |\n" +
209+
"| `headers` | Custom HTTP headers for self-hosted proxies (semicolon-separated `Name: Value`) |",
196210
},
197211
output: {
198212
human: formatDefaultsResult,
@@ -246,7 +260,7 @@ export const defaultsCommand = buildCommand({
246260
guardNonInteractive(flags);
247261
if (!isConfirmationBypassed(flags)) {
248262
const confirmed = await log.prompt(
249-
"This will clear all defaults (organization, project, telemetry, URL). Continue?",
263+
"This will clear all defaults (organization, project, telemetry, URL, headers). Continue?",
250264
{ type: "confirm" }
251265
);
252266
if (confirmed !== true) {

‎src/lib/api/issues.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { listAnOrganization_sIssues } from "@sentry/api";
99

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

12+
import { applyCustomHeaders } from "../custom-headers.js";
1213
import { ApiError } from "../errors.js";
1314
import { resolveOrgRegion } from "../region.js";
1415

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

432433
if (!response.ok) {
433434
if (response.status === 404) {

‎src/lib/custom-headers.ts‎

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
/**
2+
* Custom Headers for Self-Hosted Sentry
3+
*
4+
* Parses `SENTRY_CUSTOM_HEADERS` env var (or `defaults.headers` from SQLite)
5+
* and injects user-specified HTTP headers into all requests to self-hosted
6+
* Sentry instances. Designed for environments behind reverse proxies
7+
* (e.g., Google IAP, Cloudflare Access) that require extra headers.
8+
*
9+
* Format: semicolon-separated `Name: Value` pairs (newlines also accepted).
10+
*
11+
* @example
12+
* ```bash
13+
* # Single header
14+
* SENTRY_CUSTOM_HEADERS="X-IAP-Token: abc123"
15+
*
16+
* # Multiple headers
17+
* SENTRY_CUSTOM_HEADERS="X-IAP-Token: abc123; X-Forwarded-For: 10.0.0.1"
18+
*
19+
* # Via defaults command
20+
* sentry cli defaults headers "X-IAP-Token: abc123"
21+
* ```
22+
*/
23+
24+
import { getConfiguredSentryUrl } from "./constants.js";
25+
import { getDefaultHeaders } from "./db/defaults.js";
26+
import { getEnv } from "./env.js";
27+
import { ConfigError } from "./errors.js";
28+
import { logger } from "./logger.js";
29+
import { isSentrySaasUrl } from "./sentry-urls.js";
30+
31+
const log = logger.withTag("custom-headers");
32+
33+
/**
34+
* Header names that must not be overridden via custom headers.
35+
* These are managed by the CLI's own request pipeline and overriding
36+
* them would break authentication, content negotiation, or tracing.
37+
*/
38+
const FORBIDDEN_HEADER_NAMES = new Set([
39+
"authorization",
40+
"host",
41+
"content-type",
42+
"content-length",
43+
"user-agent",
44+
"sentry-trace",
45+
"baggage",
46+
]);
47+
48+
/**
49+
* RFC 7230 token characters for header field names.
50+
* Header names consist of visible ASCII characters except delimiters.
51+
*/
52+
const VALID_HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~\w]+$/;
53+
54+
/** Splits on semicolons and newlines (both valid header separators). */
55+
const HEADER_SEPARATOR_RE = /[;\n]/;
56+
57+
/** Strips trailing carriage return from a line (Windows line endings). */
58+
const TRAILING_CR_RE = /\r$/;
59+
60+
/** Cached parsed headers (from env var or defaults). `undefined` = not yet parsed. */
61+
let cachedHeaders: readonly [string, string][] | undefined;
62+
63+
/** Tracks the raw source string that produced `cachedHeaders`, for invalidation. */
64+
let cachedRawSource: string | undefined;
65+
66+
/** Whether the SaaS warning has already been logged this session. */
67+
let saasWarningLogged = false;
68+
69+
/**
70+
* Parse a raw custom headers string into validated name/value pairs.
71+
*
72+
* Accepts semicolon-separated or newline-separated `Name: Value` entries.
73+
* Empty segments and whitespace-only segments are silently skipped.
74+
*
75+
* @param raw - Raw header string (from env var or defaults)
76+
* @returns Array of `[name, value]` tuples in declaration order
77+
* @throws {ConfigError} On malformed segments or forbidden header names
78+
*/
79+
export function parseCustomHeaders(raw: string): readonly [string, string][] {
80+
const results: [string, string][] = [];
81+
82+
// Split on semicolons and newlines
83+
const segments = raw.split(HEADER_SEPARATOR_RE);
84+
85+
for (const segment of segments) {
86+
const trimmed = segment.replace(TRAILING_CR_RE, "").trim();
87+
if (!trimmed) {
88+
continue;
89+
}
90+
91+
const colonIndex = trimmed.indexOf(":");
92+
if (colonIndex === -1) {
93+
throw new ConfigError(
94+
`Invalid header in SENTRY_CUSTOM_HEADERS: '${trimmed}'. Expected 'Name: Value' format.`
95+
);
96+
}
97+
98+
const name = trimmed.slice(0, colonIndex).trim();
99+
const value = trimmed.slice(colonIndex + 1).trim();
100+
101+
if (!name) {
102+
throw new ConfigError(
103+
`Invalid header in SENTRY_CUSTOM_HEADERS: empty header name in '${trimmed}'.`
104+
);
105+
}
106+
107+
if (!VALID_HEADER_NAME_RE.test(name)) {
108+
throw new ConfigError(
109+
`Invalid header name '${name}' in SENTRY_CUSTOM_HEADERS. Header names must contain only alphanumeric characters, hyphens, and RFC 7230 token characters.`
110+
);
111+
}
112+
113+
if (FORBIDDEN_HEADER_NAMES.has(name.toLowerCase())) {
114+
throw new ConfigError(
115+
`Cannot override reserved header '${name}' in SENTRY_CUSTOM_HEADERS. This header is managed by the CLI.`
116+
);
117+
}
118+
119+
results.push([name, value]);
120+
}
121+
122+
return results;
123+
}
124+
125+
/**
126+
* Check whether the current target is a self-hosted Sentry instance.
127+
*
128+
* Self-hosted = `SENTRY_HOST` or `SENTRY_URL` is set to a non-SaaS URL.
129+
* Returns false if no custom URL is configured (implying SaaS) or if the
130+
* configured URL points to `*.sentry.io`.
131+
*/
132+
function isSelfHosted(): boolean {
133+
const configured = getConfiguredSentryUrl();
134+
if (!configured) {
135+
return false;
136+
}
137+
return !isSentrySaasUrl(configured);
138+
}
139+
140+
/**
141+
* Resolve the raw custom headers string from env var or SQLite defaults.
142+
*
143+
* Priority: `SENTRY_CUSTOM_HEADERS` env var > `defaults.headers` in SQLite.
144+
* Returns undefined when no headers are configured.
145+
*/
146+
function resolveRawHeaders(): string | undefined {
147+
const envValue = getEnv().SENTRY_CUSTOM_HEADERS;
148+
if (envValue?.trim()) {
149+
return envValue.trim();
150+
}
151+
152+
const dbValue = getDefaultHeaders();
153+
if (dbValue?.trim()) {
154+
return dbValue.trim();
155+
}
156+
157+
return;
158+
}
159+
160+
/**
161+
* Get the parsed custom headers for the current session.
162+
*
163+
* Returns an empty array when:
164+
* - No custom headers are configured (env var or defaults)
165+
* - The target is not a self-hosted instance (warns once if headers are set)
166+
*
167+
* Parsed results are cached; the self-hosted guard is re-evaluated per call
168+
* because `SENTRY_HOST` can be set dynamically by URL argument parsing.
169+
*/
170+
export function getCustomHeaders(): readonly [string, string][] {
171+
const raw = resolveRawHeaders();
172+
if (!raw) {
173+
return [];
174+
}
175+
176+
// Self-hosted guard: warn once and skip on SaaS
177+
if (!isSelfHosted()) {
178+
if (!saasWarningLogged) {
179+
saasWarningLogged = true;
180+
log.warn(
181+
"SENTRY_CUSTOM_HEADERS is set but no self-hosted Sentry instance is configured. Headers will be ignored."
182+
);
183+
}
184+
return [];
185+
}
186+
187+
// Return cached result if the raw source hasn't changed
188+
if (cachedHeaders !== undefined && cachedRawSource === raw) {
189+
return cachedHeaders;
190+
}
191+
192+
cachedHeaders = parseCustomHeaders(raw);
193+
cachedRawSource = raw;
194+
return cachedHeaders;
195+
}
196+
197+
/**
198+
* Apply custom headers to a `Headers` instance.
199+
*
200+
* Reads from the env var or SQLite defaults, validates, and sets each header.
201+
* No-op when no custom headers are configured or when targeting SaaS.
202+
*
203+
* @param headers - The `Headers` instance to modify in-place
204+
*/
205+
export function applyCustomHeaders(headers: Headers): void {
206+
const customHeaders = getCustomHeaders();
207+
for (const [name, value] of customHeaders) {
208+
headers.set(name, value);
209+
}
210+
}
211+
212+
/**
213+
* Reset module-level caches. Exported for testing only.
214+
* @internal
215+
*/
216+
export function _resetCustomHeadersCache(): void {
217+
cachedHeaders = undefined;
218+
cachedRawSource = undefined;
219+
saasWarningLogged = false;
220+
}

‎src/lib/db/defaults.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@ const DEFAULTS_ORG = "defaults.org";
1515
const DEFAULTS_PROJECT = "defaults.project";
1616
const DEFAULTS_TELEMETRY = "defaults.telemetry";
1717
const DEFAULTS_URL = "defaults.url";
18+
const DEFAULTS_HEADERS = "defaults.headers";
1819

1920
/** All metadata keys used for defaults (for bulk operations) */
2021
const ALL_DEFAULTS_KEYS = [
2122
DEFAULTS_ORG,
2223
DEFAULTS_PROJECT,
2324
DEFAULTS_TELEMETRY,
2425
DEFAULTS_URL,
26+
DEFAULTS_HEADERS,
2527
];
2628

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

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

98+
/**
99+
* Get the default custom headers string, or null if not set.
100+
* Format: semicolon-separated `Name: Value` pairs.
101+
*/
102+
export function getDefaultHeaders(): string | null {
103+
const db = getDatabase();
104+
const m = getMetadata(db, [DEFAULTS_HEADERS]);
105+
return m.get(DEFAULTS_HEADERS) ?? null;
106+
}
107+
94108
/**
95109
* Get all persistent defaults as a structured object.
96110
* Used by the `sentry cli defaults` show mode and JSON output.
@@ -104,6 +118,7 @@ export function getAllDefaults(): DefaultsState {
104118
project: m.get(DEFAULTS_PROJECT) ?? null,
105119
telemetry: parseTelemetryValue(telVal),
106120
url: m.get(DEFAULTS_URL) ?? null,
121+
headers: m.get(DEFAULTS_HEADERS) ?? null,
107122
};
108123
}
109124

@@ -154,6 +169,19 @@ export function setDefaultUrl(url: string | null): void {
154169
}
155170
}
156171

172+
/**
173+
* Set or clear the default custom headers. Pass `null` to clear.
174+
* Value should be semicolon-separated `Name: Value` pairs.
175+
*/
176+
export function setDefaultHeaders(value: string | null): void {
177+
const db = getDatabase();
178+
if (value === null) {
179+
clearMetadata(db, [DEFAULTS_HEADERS]);
180+
} else {
181+
setMetadata(db, { [DEFAULTS_HEADERS]: value });
182+
}
183+
}
184+
157185
// ---------------------------------------------------------------------------
158186
// Bulk operations
159187
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)