Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b437853
Add session replay capturing
tustanivsky Jun 18, 2026
205b288
Handle replay envelope upload internally without public interface
tustanivsky Jun 29, 2026
f9a6b08
Fix replay dir path
tustanivsky Jun 29, 2026
0e6246a
Fix json property names
tustanivsky Jun 29, 2026
da1371b
Limit replay envelope to native daemon
tustanivsky Jun 29, 2026
8895c83
Clean up
tustanivsky Jun 29, 2026
cbab847
Fix lint
tustanivsky Jun 29, 2026
8e27cb0
Fix tests
tustanivsky Jun 29, 2026
f461222
Inline helper functions
tustanivsky Jun 29, 2026
cb3fc58
Clean up
tustanivsky Jun 29, 2026
772dcbb
Fix lint
tustanivsky Jun 29, 2026
148c0a1
Add replay files cleanup upon successfull envelope creation
tustanivsky Jun 30, 2026
be68d1b
Classify `replay_video` under the replay data/rate-limit category
tustanivsky Jun 30, 2026
998e92d
Remove redundant videoFilename check
tustanivsky Jul 1, 2026
0106e1f
Fix comment
tustanivsky Jul 1, 2026
52b905b
Fix issue with uploading orphan replays if crash wasn't captured
tustanivsky Jul 1, 2026
85a3cec
Clean up
tustanivsky Jul 1, 2026
50526b0
Guard replay_recording assembly against string-builder failure
tustanivsky Jul 1, 2026
acecbb8
Add helper allowing to check if there's staged replay
tustanivsky Jul 2, 2026
3299782
Reuse crash envelope path var and check if replay is staged during da…
tustanivsky Jul 2, 2026
d4dce27
Check replay dir content
tustanivsky Jul 2, 2026
706d491
Check if replay dir hold mp4 clip
tustanivsky Jul 2, 2026
b1ab19c
Add integration tests
tustanivsky Jul 2, 2026
6cabda1
Guard buffer add
tustanivsky Jul 2, 2026
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
24 changes: 24 additions & 0 deletions src/backends/native/sentry_crash_daemon.c
Original file line number Diff line number Diff line change
Expand Up @@ -3571,6 +3571,30 @@ sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc)
#endif

cleanup:
// Send the staged session-replay envelope same-session, enriched from the
// crash event (`<run>/__sentry-event`) so it shares the crash's
// tags/contexts/trace.
if (options && options->transport) {
sentry_value_t crash_event = sentry_value_new_null();
if (run_folder) {
sentry_path_t *sentry_event_path
= sentry__path_join_str(run_folder, "__sentry-event");
if (sentry_event_path) {
size_t ev_len = 0;
char *ev_json
= sentry__path_read_to_buffer(sentry_event_path, &ev_len);
if (ev_json) {
crash_event = sentry__value_from_json(ev_json, ev_len);
sentry_free(ev_json);
}
sentry__path_free(sentry_event_path);
}
Comment thread
jpnurmi marked this conversation as resolved.
Outdated
}
sentry__session_replay_flush_pending(
options, options->transport, crash_event);
sentry_value_decref(crash_event);
}
Comment thread
tustanivsky marked this conversation as resolved.

// Send all other envelopes from run folder (logs, etc.) before cleanup
if (run_folder && options && options->transport && options->run) {
SENTRY_DEBUG("Checking for additional envelopes in run folder");
Expand Down
14 changes: 14 additions & 0 deletions src/sentry_session_replay.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,18 @@
*/
sentry_path_t *sentry__session_replay_get_path(const sentry_options_t *options);

/**
* Build and send the session-replay envelope(s) the embedder staged in
* `<database>/replays/` (a `replay-<id>.json` sidecar next to its mp4). Native-
* daemon-only: called out-of-process by the crash daemon, so it runs only on a
* crash and delivers same-session. Sources are left on disk for the embedder to
* clear.
*
* `scope_source` is the crash event (`<run>/__sentry-event`); its scope fields
* and trace id are copied onto the replay, and its timestamp ends the replay
* window. Null skips enrichment.
*/
void sentry__session_replay_flush_pending(const sentry_options_t *options,

Check notice on line 38 in src/sentry_session_replay.h

View check run for this annotation

@sentry/warden / warden: security-review

[LKH-48X] Missing path containment on `videoFilename` from replay sidecar enables traversal/file read into uploaded envelope (additional location)

`sentry__session_replay_flush_pending` reads `videoFilename` from an embedder-staged JSON sidecar in `<database>/replays/` and passes it straight to `sentry__path_join_str` with no `../` containment check, so a crafted sidecar (`"videoFilename":"../../etc/passwd"`) makes the crash daemon read an arbitrary file and embed it as the `replay_video` payload uploaded to the Sentry DSN. Normalize/validate the filename (reject separators and `..`, or confine to the replays dir) before reading.
sentry_transport_t *transport, sentry_value_t scope_source);

#endif
334 changes: 334 additions & 0 deletions src/session_replay/sentry_session_replay.c
Original file line number Diff line number Diff line change
@@ -1,7 +1,341 @@
#include "sentry_session_replay.h"

#include "sentry_core.h"
#include "sentry_database.h"
#include "sentry_envelope.h"
#include "sentry_json.h"
#include "sentry_string.h"
#include "sentry_utils.h"
#include "sentry_value.h"

#if defined(_MSC_VER)
# pragma warning(push)
# pragma warning(disable : 4127) // conditional expression is constant
# if defined(__clang__) // clang-cl
# pragma clang diagnostic push
# pragma clang diagnostic ignored "-Wdocumentation"
# pragma clang diagnostic ignored "-Wpre-c11-compat"
# endif
#elif defined(__clang__)
# pragma clang diagnostic push
# pragma clang diagnostic ignored "-Wstatic-in-inline"
#endif

#include "../vendor/mpack.h"
Comment thread
jpnurmi marked this conversation as resolved.

#if defined(_MSC_VER)
# pragma warning(pop)
# ifdef __clang__ // clang-cl
# pragma clang diagnostic pop
# endif
#elif defined(__clang__)
# pragma clang diagnostic pop
#endif

#include <stdio.h>

sentry_path_t *
sentry__session_replay_get_path(const sentry_options_t *options)
{
return sentry__path_join_str(options->run->run_path, "session-replay.mp4");
}

static sentry_path_t *
session_replay_dir(const sentry_options_t *options)
{
if (!options->run || !options->run->run_path) {
return NULL;
}
sentry_path_t *db_dir = sentry__path_dir(options->run->run_path);
if (!db_dir) {
return NULL;
}
sentry_path_t *replays = sentry__path_join_str(db_dir, "replays");
sentry__path_free(db_dir);
return replays;
}

// Build the replay_event from the recorder's metadata. When `scope_source` (the
// crash event) is non-null, its scope fields and trace id are copied onto the
// replay so it shares the crash's context. `error_ids` is omitted (deprecated).
static sentry_value_t
build_replay_event(sentry_value_t meta, const char *replay_id, double start_sec,
double end_sec, int32_t segment_id, sentry_value_t scope_source)
{
const char *replay_type
= sentry_value_as_string(sentry_value_get_by_key(meta, "replayType"));

sentry_value_t event = sentry_value_new_object();
sentry_value_set_by_key(
event, "type", sentry_value_new_string("replay_event"));
sentry_value_set_by_key(event, "replay_type",
sentry_value_new_string(
replay_type && replay_type[0] ? replay_type : "buffer"));
sentry_value_set_by_key(
event, "segment_id", sentry_value_new_int32(segment_id));
sentry_value_set_by_key(
event, "replay_id", sentry_value_new_string(replay_id));
sentry_value_set_by_key(
event, "event_id", sentry_value_new_string(replay_id));
sentry_value_set_by_key(
event, "platform", sentry_value_new_string("native"));
sentry_value_set_by_key(
event, "timestamp", sentry_value_new_double(end_sec));
sentry_value_set_by_key(
event, "replay_start_timestamp", sentry_value_new_double(start_sec));
sentry_value_set_by_key(event, "urls", sentry_value_new_list());

if (!sentry_value_is_null(scope_source)) {
static const char *const scope_keys[] = { "tags", "contexts", "release",
"environment", "dist", "user", "sdk" };
for (size_t i = 0; i < sizeof(scope_keys) / sizeof(scope_keys[0]);
i++) {
sentry_value_t v
= sentry_value_get_by_key(scope_source, scope_keys[i]);
if (!sentry_value_is_null(v)) {
sentry_value_incref(v);
sentry_value_set_by_key(event, scope_keys[i], v);
}
}

sentry_value_t trace_id = sentry_value_get_by_key(
sentry_value_get_by_key(
sentry_value_get_by_key(scope_source, "contexts"), "trace"),
"trace_id");
sentry_value_t trace_ids = sentry_value_new_list();
if (!sentry_value_is_null(trace_id)) {
sentry_value_incref(trace_id);
sentry_value_append(trace_ids, trace_id);
}
sentry_value_set_by_key(event, "trace_ids", trace_ids);
}
return event;
}

// Build the rrweb recording list (meta event + video event) describing the
// clip.
static sentry_value_t
build_replay_recording(sentry_value_t meta, double start_sec,
int32_t segment_id, double size_bytes, double duration_ms)
{
const int32_t width
= sentry_value_as_int32(sentry_value_get_by_key(meta, "width"));
const int32_t height
= sentry_value_as_int32(sentry_value_get_by_key(meta, "height"));
const double ts_ms = start_sec * 1000.0;

sentry_value_t meta_data = sentry_value_new_object();
sentry_value_set_by_key(meta_data, "href", sentry_value_new_string(""));
sentry_value_set_by_key(meta_data, "width", sentry_value_new_int32(width));
sentry_value_set_by_key(
meta_data, "height", sentry_value_new_int32(height));
sentry_value_t meta_event = sentry_value_new_object();
sentry_value_set_by_key(meta_event, "type", sentry_value_new_int32(4));
sentry_value_set_by_key(
meta_event, "timestamp", sentry_value_new_double(ts_ms));
sentry_value_set_by_key(meta_event, "data", meta_data);

sentry_value_t payload = sentry_value_new_object();
sentry_value_set_by_key(
payload, "segmentId", sentry_value_new_int32(segment_id));
sentry_value_set_by_key(
payload, "size", sentry_value_new_double(size_bytes));
sentry_value_set_by_key(
payload, "duration", sentry_value_new_double(duration_ms));
sentry_value_set_by_key(
payload, "encoding", sentry_value_new_string("h264"));
sentry_value_set_by_key(
payload, "container", sentry_value_new_string("mp4"));
sentry_value_set_by_key(payload, "height", sentry_value_new_int32(height));
sentry_value_set_by_key(payload, "width", sentry_value_new_int32(width));
sentry_value_set_by_key(payload, "left", sentry_value_new_int32(0));
sentry_value_set_by_key(payload, "top", sentry_value_new_int32(0));
sentry_value_set_by_key(payload, "frameCount",
sentry_value_new_int32(sentry_value_as_int32(
sentry_value_get_by_key(meta, "frameCount"))));
sentry_value_set_by_key(payload, "frameRate",
sentry_value_new_int32(
sentry_value_as_int32(sentry_value_get_by_key(meta, "frameRate"))));
sentry_value_set_by_key(
payload, "frameRateType", sentry_value_new_string("variable"));
sentry_value_t video_data = sentry_value_new_object();
sentry_value_set_by_key(
video_data, "tag", sentry_value_new_string("video"));
sentry_value_set_by_key(video_data, "payload", payload);
sentry_value_t video_event = sentry_value_new_object();
sentry_value_set_by_key(video_event, "type", sentry_value_new_int32(5));
sentry_value_set_by_key(
video_event, "timestamp", sentry_value_new_double(ts_ms));
sentry_value_set_by_key(video_event, "data", video_data);

sentry_value_t recording = sentry_value_new_list();
sentry_value_append(recording, meta_event);
sentry_value_append(recording, video_event);
return recording;
}

// Build the replay_video envelope from the parsed sidecar + its mp4. `end_sec`
// is the window end (crash time); <= 0 falls back to the sidecar's. NULL on
// failure.
static sentry_envelope_t *
build_replay_envelope(const sentry_options_t *options, sentry_value_t meta,
const sentry_path_t *mp4_path, double end_sec, sentry_value_t scope_source)
{
if (sentry_value_is_null(meta) || !mp4_path) {
return NULL;
}

const char *replay_id
= sentry_value_as_string(sentry_value_get_by_key(meta, "replayId"));
if (!replay_id || !replay_id[0]) {
return NULL;
}

size_t video_len = 0;
char *video = sentry__path_read_to_buffer(mp4_path, &video_len);
if (!video || video_len == 0) {
sentry_free(video);
return NULL;
}

const double duration_ms
= sentry_value_as_double(sentry_value_get_by_key(meta, "durationMs"));
if (end_sec <= 0.0) {
end_sec = sentry_value_as_double(
sentry_value_get_by_key(meta, "endTimestampSec"));
}
const double start_sec = end_sec - duration_ms / 1000.0;
Comment thread
tustanivsky marked this conversation as resolved.
const int32_t segment_id
= sentry_value_as_int32(sentry_value_get_by_key(meta, "segmentId"));

sentry_value_t event = build_replay_event(
meta, replay_id, start_sec, end_sec, segment_id, scope_source);
sentry_value_t recording = build_replay_recording(
meta, start_sec, segment_id, (double)video_len, duration_ms);

sentry_envelope_t *envelope = NULL;

size_t event_len = 0;
char *event_json = sentry__value_to_json(event, &event_len);
size_t rrweb_len = 0;
char *rrweb_json = sentry__value_to_json(recording, &rrweb_len);

if (event_json && rrweb_json) {
// replay_recording = `{"segment_id":N}\n` + the rrweb array.
char hdr[48];
int hdr_len
= snprintf(hdr, sizeof(hdr), "{\"segment_id\":%d}\n", segment_id);

sentry_stringbuilder_t rb;
sentry__stringbuilder_init(&rb);
sentry__stringbuilder_append_buf(&rb, hdr, (size_t)hdr_len);
sentry__stringbuilder_append_buf(&rb, rrweb_json, rrweb_len);
Comment thread
sentry[bot] marked this conversation as resolved.
Outdated
size_t recording_len = sentry__stringbuilder_len(&rb);
char *recording_buf = sentry__stringbuilder_into_string(&rb);

// replay_video item body: msgpack map of three raw blobs.
mpack_writer_t writer;
char *body = NULL;
size_t body_len = 0;
mpack_writer_init_growable(&writer, &body, &body_len);
mpack_start_map(&writer, 3);
mpack_write_cstr(&writer, "replay_event");
mpack_write_bin(&writer, event_json, (uint32_t)event_len);
mpack_write_cstr(&writer, "replay_recording");
mpack_write_bin(&writer, recording_buf, (uint32_t)recording_len);
mpack_write_cstr(&writer, "replay_video");
mpack_write_bin(&writer, video, (uint32_t)video_len);
mpack_finish_map(&writer);
bool body_ok = mpack_writer_destroy(&writer) == mpack_ok;

sentry_free(recording_buf);

if (body_ok && body) {
envelope = sentry__envelope_new();
if (envelope) {
sentry__envelope_set_header(
envelope, "event_id", sentry_value_new_string(replay_id));
const char *dsn = sentry_options_get_dsn(options);
if (dsn && dsn[0]) {
sentry__envelope_set_header(
envelope, "dsn", sentry_value_new_string(dsn));
}
Comment thread
cursor[bot] marked this conversation as resolved.
sentry__envelope_add_from_buffer(
envelope, body, body_len, "replay_video");
}
}
sentry_free(body);
}

sentry_free(rrweb_json);
sentry_free(event_json);
sentry_value_decref(event);
sentry_value_decref(recording);
sentry_free(video);
return envelope;
}

void
sentry__session_replay_flush_pending(const sentry_options_t *options,
sentry_transport_t *transport, sentry_value_t scope_source)
{
if (!options || !transport) {
return;
}

sentry_path_t *dir = session_replay_dir(options);
if (!dir) {
return;
}
if (!sentry__path_is_dir(dir)) {
sentry__path_free(dir);
return;
}

// End the replay window at the crash time (from the crash event); falls
// back to the sidecar's own end timestamp in build_replay_envelope.
double end_sec = 0.0;
if (!sentry_value_is_null(scope_source)) {
const char *ts = sentry_value_as_string(
sentry_value_get_by_key(scope_source, "timestamp"));
Comment thread
tustanivsky marked this conversation as resolved.
if (ts && ts[0]) {
uint64_t usec = sentry__iso8601_to_usec(ts);
if (usec) {
end_sec = (double)usec / 1000000.0;
}
}
}

sentry_pathiter_t *iter = sentry__path_iter_directory(dir);
const sentry_path_t *file;
while (iter && (file = sentry__pathiter_next(iter)) != NULL) {
if (!sentry__path_ends_with(file, ".json")) {
continue;
}

size_t json_len = 0;
char *json = sentry__path_read_to_buffer(file, &json_len);
if (!json) {
continue;
}
sentry_value_t meta = sentry__value_from_json(json, json_len);
sentry_free(json);

const char *video_filename = sentry_value_as_string(
sentry_value_get_by_key(meta, "videoFilename"));

Check notice on line 325 in src/session_replay/sentry_session_replay.c

View check run for this annotation

@sentry/warden / warden: security-review

Missing path containment on `videoFilename` from replay sidecar enables traversal/file read into uploaded envelope

`sentry__session_replay_flush_pending` reads `videoFilename` from an embedder-staged JSON sidecar in `<database>/replays/` and passes it straight to `sentry__path_join_str` with no `../` containment check, so a crafted sidecar (`"videoFilename":"../../etc/passwd"`) makes the crash daemon read an arbitrary file and embed it as the `replay_video` payload uploaded to the Sentry DSN. Normalize/validate the filename (reject separators and `..`, or confine to the replays dir) before reading.
sentry_path_t *mp4_path = (video_filename && video_filename[0])
? sentry__path_join_str(dir, video_filename)
: NULL;
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Outdated

sentry_envelope_t *envelope = build_replay_envelope(
options, meta, mp4_path, end_sec, scope_source);
if (envelope) {
sentry__capture_envelope(transport, envelope, options);
}

sentry__path_free(mp4_path);
sentry_value_decref(meta);
}
sentry__pathiter_free(iter);
sentry__path_free(dir);
}
Loading