Skip to content

Commit 95ddd2d

Browse files
committed
Mask session keys in cache events to protect sensitive information
1 parent c28dbb4 commit 95ddd2d

File tree

2 files changed

+170
-9
lines changed

2 files changed

+170
-9
lines changed

src/Sentry/Laravel/Features/CacheIntegration.php

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,13 @@ public function handleCacheEventsForBreadcrumbs(Events\CacheEvent $event): void
8686
return;
8787
}
8888

89+
$displayKey = $this->replaceSessionKey($event->key);
90+
8991
Integration::addBreadcrumb(new Breadcrumb(
9092
Breadcrumb::LEVEL_INFO,
9193
Breadcrumb::TYPE_DEFAULT,
9294
'cache',
93-
"{$message}: {$event->key}",
95+
"{$message}: {$displayKey}",
9496
$event->tags ? ['tags' => $event->tags] : []
9597
));
9698
}
@@ -109,15 +111,17 @@ public function handleCacheEventsForTracing(Events\CacheEvent $event): void
109111
: $event->keys
110112
);
111113

114+
$displayKeys = $this->replaceSessionKeys($keys);
115+
112116
$this->pushSpan(
113117
$parentSpan->startChild(
114118
SpanContext::make()
115119
->setOp('cache.get')
116120
->setData([
117-
'cache.key' => $keys,
121+
'cache.key' => $displayKeys,
118122
])
119123
->setOrigin('auto.cache')
120-
->setDescription(implode(', ', $keys))
124+
->setDescription(implode(', ', $displayKeys))
121125
)
122126
);
123127
}
@@ -129,30 +133,34 @@ public function handleCacheEventsForTracing(Events\CacheEvent $event): void
129133
: $event->keys
130134
);
131135

136+
$displayKeys = $this->replaceSessionKeys($keys);
137+
132138
$this->pushSpan(
133139
$parentSpan->startChild(
134140
SpanContext::make()
135141
->setOp('cache.put')
136142
->setData([
137-
'cache.key' => $keys,
143+
'cache.key' => $displayKeys,
138144
'cache.ttl' => $event->seconds,
139145
])
140146
->setOrigin('auto.cache')
141-
->setDescription(implode(', ', $keys))
147+
->setDescription(implode(', ', $displayKeys))
142148
)
143149
);
144150
}
145151

146152
if ($event instanceof Events\ForgettingKey) {
153+
$displayKey = $this->replaceSessionKey($event->key);
154+
147155
$this->pushSpan(
148156
$parentSpan->startChild(
149157
SpanContext::make()
150158
->setOp('cache.remove')
151159
->setData([
152-
'cache.key' => [$event->key],
160+
'cache.key' => [$displayKey],
153161
])
154162
->setOrigin('auto.cache')
155-
->setDescription($event->key)
163+
->setDescription($displayKey)
156164
)
157165
);
158166
}
@@ -177,7 +185,7 @@ public function handleRedisCommands(RedisEvents\CommandExecuted $event): void
177185
// If the first parameter is a string and does not contain a newline we use it as the description since it's most likely a key
178186
// This is not a perfect solution but it's the best we can do without understanding the command that was executed
179187
if (!empty($event->parameters[0]) && is_string($event->parameters[0]) && !Str::contains($event->parameters[0], "\n")) {
180-
$keyForDescription = $event->parameters[0];
188+
$keyForDescription = $this->replaceSessionKey($event->parameters[0]);
181189
}
182190

183191
$context->setDescription(rtrim(strtoupper($event->command) . ' ' . $keyForDescription));
@@ -189,7 +197,12 @@ public function handleRedisCommands(RedisEvents\CommandExecuted $event): void
189197
];
190198

191199
if ($this->shouldSendDefaultPii()) {
192-
$data['db.redis.parameters'] = $event->parameters;
200+
// Replace session keys in parameters if present
201+
$parameters = $event->parameters;
202+
if (!empty($parameters[0]) && is_string($parameters[0])) {
203+
$parameters[0] = $this->replaceSessionKey($parameters[0]);
204+
}
205+
$data['db.redis.parameters'] = $parameters;
193206
}
194207

195208
if ($this->isTracingFeatureEnabled('redis_origin')) {
@@ -245,6 +258,73 @@ private function maybeHandleCacheEventAsEndOfSpan(Events\CacheEvent $event): boo
245258
return false;
246259
}
247260

261+
/**
262+
* Check if a cache key is the current session key.
263+
*
264+
* @param string $key
265+
*
266+
* @return bool
267+
*/
268+
private function isSessionKey(string $key): bool
269+
{
270+
// Check if the container has a bound request and session
271+
if (!$this->container()->bound('request')) {
272+
return false;
273+
}
274+
275+
try {
276+
$request = $this->container()->make('request');
277+
278+
// Check if the request has a session
279+
if (!$request->hasSession()) {
280+
return false;
281+
}
282+
283+
$session = $request->session();
284+
285+
// Don't start the session if it hasn't been started yet
286+
if (!$session->isStarted()) {
287+
return false;
288+
}
289+
290+
// Get the session ID and check if the cache key matches
291+
$sessionId = $session->getId();
292+
293+
// Check if the key equals the session ID or contains it
294+
// This handles cases where the cache key might be prefixed
295+
return $key === $sessionId || Str::endsWith($key, $sessionId);
296+
} catch (\Exception $e) {
297+
// If anything goes wrong, we assume it's not a session key
298+
return false;
299+
}
300+
}
301+
302+
/**
303+
* Replace a session key with a placeholder.
304+
*
305+
* @param string $key
306+
*
307+
* @return string
308+
*/
309+
private function replaceSessionKey(string $key): string
310+
{
311+
return $this->isSessionKey($key) ? '{sessionKey}' : $key;
312+
}
313+
314+
/**
315+
* Replace session keys in an array of keys with placeholders.
316+
*
317+
* @param array $keys
318+
*
319+
* @return array
320+
*/
321+
private function replaceSessionKeys(array $keys): array
322+
{
323+
return array_map(function ($key) {
324+
return $this->replaceSessionKey($key);
325+
}, $keys);
326+
}
327+
248328
/**
249329
* Normalize the array of keys to a array of only strings.
250330
*

test/Sentry/Features/CacheIntegrationTest.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,33 @@ public function testCacheBreadcrumbIsNotRecordedWhenDisabled(): void
5151
$this->assertEmpty($this->getCurrentSentryBreadcrumbs());
5252
}
5353

54+
public function testCacheBreadcrumbReplacesSessionKeyWithPlaceholder(): void
55+
{
56+
// Start a session
57+
$this->app['request']->setLaravelSession($session = $this->app['session.store']);
58+
$session->start();
59+
$sessionId = $session->getId();
60+
61+
// Use the session ID as a cache key
62+
Cache::put($sessionId, 'session-data');
63+
64+
$breadcrumb = $this->getLastSentryBreadcrumb();
65+
$this->assertEquals("Written: {sessionKey}", $breadcrumb->getMessage());
66+
67+
Cache::get($sessionId);
68+
69+
$breadcrumb = $this->getLastSentryBreadcrumb();
70+
$this->assertEquals("Read: {sessionKey}", $breadcrumb->getMessage());
71+
}
72+
73+
public function testCacheBreadcrumbDoesNotReplaceNonSessionKeys(): void
74+
{
75+
Cache::put('regular-key', 'value');
76+
77+
$breadcrumb = $this->getLastSentryBreadcrumb();
78+
$this->assertEquals("Written: regular-key", $breadcrumb->getMessage());
79+
}
80+
5481
public function testCacheGetSpanIsRecorded(): void
5582
{
5683
$this->markSkippedIfTracingEventsNotAvailable();
@@ -147,6 +174,60 @@ public function testCacheRemoveSpanIsRecorded(): void
147174
$this->assertEquals(['foo'], $span->getData()['cache.key']);
148175
}
149176

177+
public function testCacheSpanReplacesSessionKeyWithPlaceholder(): void
178+
{
179+
$this->markSkippedIfTracingEventsNotAvailable();
180+
181+
// Start a session
182+
$this->app['request']->setLaravelSession($session = $this->app['session.store']);
183+
$session->start();
184+
$sessionId = $session->getId();
185+
186+
$span = $this->executeAndReturnMostRecentSpan(function () use ($sessionId) {
187+
Cache::get($sessionId);
188+
});
189+
190+
$this->assertEquals('cache.get', $span->getOp());
191+
$this->assertEquals('{sessionKey}', $span->getDescription());
192+
$this->assertEquals(['{sessionKey}'], $span->getData()['cache.key']);
193+
}
194+
195+
public function testCacheSpanReplacesMultipleSessionKeysWithPlaceholder(): void
196+
{
197+
$this->markSkippedIfTracingEventsNotAvailable();
198+
199+
// Start a session
200+
$this->app['request']->setLaravelSession($session = $this->app['session.store']);
201+
$session->start();
202+
$sessionId = $session->getId();
203+
204+
$span = $this->executeAndReturnMostRecentSpan(function () use ($sessionId) {
205+
Cache::get([$sessionId, 'regular-key', $sessionId . '_another']);
206+
});
207+
208+
$this->assertEquals('cache.get', $span->getOp());
209+
$this->assertEquals('{sessionKey}, regular-key, {sessionKey}', $span->getDescription());
210+
$this->assertEquals(['{sessionKey}', 'regular-key', '{sessionKey}'], $span->getData()['cache.key']);
211+
}
212+
213+
public function testCacheOperationDoesNotStartSessionPrematurely(): void
214+
{
215+
$this->markSkippedIfTracingEventsNotAvailable();
216+
217+
// Make sure session is not started
218+
$this->assertFalse($this->app['session.store']->isStarted());
219+
220+
$span = $this->executeAndReturnMostRecentSpan(function () {
221+
Cache::get('some-key');
222+
});
223+
224+
// Session should still not be started
225+
$this->assertFalse($this->app['session.store']->isStarted());
226+
227+
// And the key should not be replaced
228+
$this->assertEquals('some-key', $span->getDescription());
229+
}
230+
150231
private function markSkippedIfTracingEventsNotAvailable(): void
151232
{
152233
if (class_exists(RetrievingKey::class)) {

0 commit comments

Comments
 (0)