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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## Unreleased

### Features

- Prevent cross-organization trace continuation (#7705)
- By default, the SDK now extracts the organization ID from the DSN (e.g. `o123.ingest.sentry.io`) and compares it with the `sentry-org_id` value in incoming baggage headers. When the two differ, the SDK starts a fresh trace instead of continuing the foreign one. This guards against accidentally linking traces across organizations.
- New option `strictTraceContinuation` (default `false`): when enabled, both the SDK's org ID **and** the incoming baggage org ID must be present and match for a trace to be continued. Traces with a missing org ID on either side are rejected.
- New option `orgId`: allows explicitly setting the organization ID for self-hosted and Relay setups where it cannot be extracted from the DSN.

## 9.9.0

### Features
Expand Down
16 changes: 16 additions & 0 deletions Sources/Sentry/Public/SentryBaggage.h
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ NS_SWIFT_NAME(Baggage)

@property (nullable, nonatomic, strong) NSString *replayId;

/**
* The organization ID extracted from the DSN or configured explicitly.
*/
@property (nullable, nonatomic, readonly) NSString *orgId;

- (instancetype)initWithTraceId:(SentryId *)traceId
publicKey:(NSString *)publicKey
releaseName:(nullable NSString *)releaseName
Expand All @@ -78,6 +83,17 @@ NS_SWIFT_NAME(Baggage)
sampled:(nullable NSString *)sampled
replayId:(nullable NSString *)replayId;

- (instancetype)initWithTraceId:(SentryId *)traceId
publicKey:(NSString *)publicKey
releaseName:(nullable NSString *)releaseName
environment:(nullable NSString *)environment
transaction:(nullable NSString *)transaction
sampleRate:(nullable NSString *)sampleRate
sampleRand:(nullable NSString *)sampleRand
sampled:(nullable NSString *)sampled
replayId:(nullable NSString *)replayId
orgId:(nullable NSString *)orgId;

- (NSString *)toHTTPHeaderWithOriginalBaggage:(NSDictionary *_Nullable)originalBaggage;

@end
Expand Down
5 changes: 5 additions & 0 deletions Sources/Sentry/Public/SentryTraceContext.h
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ NS_SWIFT_NAME(TraceContext)
*/
@property (nullable, nonatomic, readonly) NSString *replayId;

/**
* The organization ID extracted from the DSN or configured explicitly.
*/
@property (nullable, nonatomic, readonly) NSString *orgId;

/**
* Create a SentryBaggage with the information of this SentryTraceContext.
*/
Expand Down
31 changes: 30 additions & 1 deletion Sources/Sentry/SentryBaggage.m
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
sampleRate:sampleRate
sampleRand:nil
sampled:sampled
replayId:replayId];
replayId:replayId
orgId:nil];
}

- (instancetype)initWithTraceId:(SentryId *)traceId
Expand All @@ -38,6 +39,29 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
sampled:(nullable NSString *)sampled
replayId:(nullable NSString *)replayId
{
return [self initWithTraceId:traceId
publicKey:publicKey
releaseName:releaseName
environment:environment
transaction:transaction
sampleRate:sampleRate
sampleRand:sampleRand
sampled:sampled
replayId:replayId
orgId:nil];
}

- (instancetype)initWithTraceId:(SentryId *)traceId
publicKey:(NSString *)publicKey
releaseName:(nullable NSString *)releaseName
environment:(nullable NSString *)environment
transaction:(nullable NSString *)transaction
sampleRate:(nullable NSString *)sampleRate
sampleRand:(nullable NSString *)sampleRand
sampled:(nullable NSString *)sampled
replayId:(nullable NSString *)replayId
orgId:(nullable NSString *)orgId
{

if (self = [super init]) {
_traceId = traceId;
Expand All @@ -49,6 +73,7 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
_sampleRand = sampleRand;
_sampled = sampled;
_replayId = replayId;
_orgId = orgId;
}

return self;
Expand Down Expand Up @@ -90,6 +115,10 @@ - (NSString *)toHTTPHeaderWithOriginalBaggage:(NSDictionary *_Nullable)originalB
[information setValue:_replayId forKey:@"sentry-replay_id"];
}

if (_orgId != nil) {
[information setValue:_orgId forKey:@"sentry-org_id"];
}

return [SentryBaggageSerialization encodeDictionary:information];
}

Expand Down
7 changes: 7 additions & 0 deletions Sources/Sentry/SentryOptionsInternal.m
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,13 @@ + (BOOL)validateOptions:(NSDictionary<NSString *, id> *)options
block:^(BOOL value) { sentryOptions.enableMetricKitRawPayload = value; }];
#endif // SENTRY_HAS_METRIC_KIT

[self setBool:options[@"strictTraceContinuation"]
block:^(BOOL value) { sentryOptions.strictTraceContinuation = value; }];

if ([options[@"orgId"] isKindOfClass:[NSString class]]) {
sentryOptions.orgId = SENTRY_UNWRAP_NULLABLE(NSString, options[@"orgId"]);
}

[self setBool:options[@"enableSpotlight"]
block:^(BOOL value) { sentryOptions.enableSpotlight = value; }];

Expand Down
43 changes: 38 additions & 5 deletions Sources/Sentry/SentryTraceContext.m
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
sampleRate:sampleRate
sampleRand:nil
sampled:sampled
replayId:replayId];
replayId:replayId
orgId:nil];
}

- (instancetype)initWithTraceId:(SentryId *)traceId
Expand All @@ -45,6 +46,29 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
sampleRand:(nullable NSString *)sampleRand
sampled:(nullable NSString *)sampled
replayId:(nullable NSString *)replayId
{
return [self initWithTraceId:traceId
publicKey:publicKey
releaseName:releaseName
environment:environment
transaction:transaction
sampleRate:sampleRate
sampleRand:sampleRand
sampled:sampled
replayId:replayId
orgId:nil];
}

- (instancetype)initWithTraceId:(SentryId *)traceId
publicKey:(NSString *)publicKey
releaseName:(nullable NSString *)releaseName
environment:(nullable NSString *)environment
transaction:(nullable NSString *)transaction
sampleRate:(nullable NSString *)sampleRate
sampleRand:(nullable NSString *)sampleRand
sampled:(nullable NSString *)sampled
replayId:(nullable NSString *)replayId
orgId:(nullable NSString *)orgId
{
if (self = [super init]) {
_traceId = traceId;
Expand All @@ -56,6 +80,7 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
_sampleRate = sampleRate;
_sampled = sampled;
_replayId = replayId;
_orgId = orgId;
}
return self;
}
Expand Down Expand Up @@ -102,7 +127,8 @@ - (nullable instancetype)initWithTracer:(SentryTracer *)tracer
sampleRate:serializedSampleRate
sampleRand:serializedSampleRand
sampled:sampled
replayId:scope.replayId];
replayId:scope.replayId
orgId:options.effectiveOrgId];
}

- (instancetype)initWithTraceId:(SentryId *)traceId
Expand All @@ -118,7 +144,8 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
sampleRate:nil
sampleRand:nil
sampled:nil
replayId:replayId];
replayId:replayId
orgId:options.effectiveOrgId];
}

- (nullable instancetype)initWithDict:(NSDictionary<NSString *, id> *)dictionary
Expand All @@ -143,7 +170,8 @@ - (nullable instancetype)initWithDict:(NSDictionary<NSString *, id> *)dictionary
sampleRate:dictionary[@"sample_rate"]
sampleRand:dictionary[@"sample_rand"]
sampled:dictionary[@"sampled"]
replayId:dictionary[@"replay_id"]];
replayId:dictionary[@"replay_id"]
orgId:dictionary[@"org_id"]];
}

- (SentryBaggage *)toBaggage
Expand All @@ -156,7 +184,8 @@ - (SentryBaggage *)toBaggage
sampleRate:_sampleRate
sampleRand:_sampleRand
sampled:_sampled
replayId:_replayId];
replayId:_replayId
orgId:_orgId];
return result;
}

Expand Down Expand Up @@ -193,6 +222,10 @@ - (SentryBaggage *)toBaggage
[result setValue:_replayId forKey:@"replay_id"];
}

if (_orgId != nil) {
[result setValue:_orgId forKey:@"org_id"];
}

return result;
}

Expand Down
14 changes: 14 additions & 0 deletions Sources/Sentry/include/SentryTraceContext+Private.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ NS_ASSUME_NONNULL_BEGIN
sampled:(nullable NSString *)sampled
replayId:(nullable NSString *)replayId;

/**
* Initializes a SentryTraceContext with given properties including org ID.
*/
- (instancetype)initWithTraceId:(SentryId *)traceId
publicKey:(NSString *)publicKey
releaseName:(nullable NSString *)releaseName
environment:(nullable NSString *)environment
transaction:(nullable NSString *)transaction
sampleRate:(nullable NSString *)sampleRate
sampleRand:(nullable NSString *)sampleRand
sampled:(nullable NSString *)sampled
replayId:(nullable NSString *)replayId
orgId:(nullable NSString *)orgId;

/**
* Initializes a SentryTraceContext with data from scope and options.
*/
Expand Down
29 changes: 29 additions & 0 deletions Sources/Swift/Options.swift
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,35 @@
/// https://spotlightjs.com/
@objc public var spotlightUrl = "http://localhost:8969/stream"

/// If set to `true`, the SDK will only continue a trace if the organization ID of the incoming
/// trace found in the baggage header matches the organization ID of the current Sentry client.
///
/// The client's organization ID is extracted from the DSN or can be set with the `orgId` option.
///
/// If the organization IDs do not match, the SDK will start a new trace instead of continuing
/// the incoming one. This is useful to prevent traces of unknown third-party services from being
/// continued in your application.
///
/// @note Default value is @c false.
@objc public var strictTraceContinuation: Bool = false

/// The organization ID for your Sentry project.
///
/// The SDK will try to extract the organization ID from the DSN. If it cannot be found, or if
/// you need to override it, you can provide the ID with this option. The organization ID is used
/// for trace propagation and for features like `strictTraceContinuation`.
@objc public var orgId: String?

/// Returns the effective organization ID, preferring the explicit `orgId` option over the
/// DSN-extracted value.
@_spi(Private) @objc
public var effectiveOrgId: String? {
if let orgId = orgId, !orgId.isEmpty {
return orgId
}
return parsedDsn?.orgId
}

/// Options for experimental features that are subject to change.
@objc public var experimental = SentryExperimentalOptions()

Expand Down
17 changes: 17 additions & 0 deletions Sources/Swift/SentryDsn.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,23 @@ public final class SentryDsn: NSObject {
return endpoint
}

private static let orgIdRegex = try? NSRegularExpression(pattern: "^o(\\d+)\\.")

/// Extracts the organization ID from the DSN host.
///
/// For example, given a DSN with host `o123.ingest.sentry.io`, this returns `"123"`.
/// Returns `nil` if the host does not match the expected pattern.
@_spi(Private) @objc
public var orgId: String? {
guard let host = url.host,
let regex = SentryDsn.orgIdRegex,
let match = regex.firstMatch(in: host, range: NSRange(host.startIndex..., in: host)),
let range = Range(match.range(at: 1), in: host) else {
return nil
}
return String(host[range])
}

/// Returns the base API endpoint URL for this DSN.
/// - Returns: The base endpoint URL.
private func getBaseEndpoint() -> URL {
Expand Down
58 changes: 56 additions & 2 deletions Sources/Swift/State/SentryPropagationContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
@objc public var traceHeader: TraceHeader {
TraceHeader(trace: traceId, spanId: spanId, sampled: .no)
}

@objc public override init() {
self.traceId = SentryId()
self.spanId = SpanId()
Expand All @@ -17,12 +17,66 @@
self.traceId = traceId
self.spanId = spanId
}

@objc public func traceContextForEvent() -> [String: String] {
[
"span_id": spanId.sentrySpanIdString,
"trace_id": traceId.sentryIdString
]
}

/// Determines whether a trace should be continued based on the incoming baggage org ID
/// and the SDK options.
///
/// This method is intentionally not called from the Cocoa SDK's own production code because
/// the Cocoa SDK is a mobile client SDK that does not receive incoming HTTP requests with
/// trace headers. It is exposed as a public utility for:
/// - Hybrid SDKs (React Native, Flutter, Capacitor) that handle inbound trace validation
/// in their JS/Dart layer and use the Cocoa SDK for options storage and outbound propagation
/// - Any consumer that needs to validate incoming traces against org ID
///
/// Decision matrix:
/// | Baggage org | SDK org | strict=false | strict=true |
/// |-------------|---------|-------------|-------------|
/// | 1 | 1 | Continue | Continue |
/// | None | 1 | Continue | New trace |
/// | 1 | None | Continue | New trace |
/// | None | None | Continue | Continue |
/// | 1 | 2 | New trace | New trace |
@objc public static func shouldContinueTrace(
options: Options,
baggageOrgId: String?
) -> Bool {
let sdkOrgId = options.effectiveOrgId

// Mismatched org IDs always reject regardless of strict mode
if let sdkOrgId = sdkOrgId,
let baggageOrgId = baggageOrgId,
sdkOrgId != baggageOrgId {
SentrySDKLog.debug(
"Won't continue trace because org IDs don't match "
+ "(incoming baggage: \(baggageOrgId), SDK options: \(sdkOrgId))"
)
return false
}

if options.strictTraceContinuation {
// With strict continuation both must be present and match,
// unless both are missing
if sdkOrgId == nil && baggageOrgId == nil {
return true
}
if sdkOrgId == nil || baggageOrgId == nil {
SentrySDKLog.debug(
"Starting new trace because strict trace continuation is enabled "
+ "but one org ID is missing (incoming baggage: "
+ "\(baggageOrgId ?? "nil"), SDK: \(sdkOrgId ?? "nil"))"
)
return false
}
}

return true
}
}
// swiftlint:enable missing_docs
Loading
Loading