diff --git a/.size-limit.js b/.size-limit.js index 24772d8380f5..215a40d1bf17 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -82,7 +82,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '85 KB', + limit: '85.5 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', @@ -243,7 +243,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '42 KB', + limit: '42.5 KB', }, // Node-Core SDK (ESM) { @@ -261,7 +261,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '162 KB', + limit: '162.5 KB', }, { name: '@sentry/node - without tracing', diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata-async/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata-async/page.tsx new file mode 100644 index 000000000000..03201cdccf60 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata-async/page.tsx @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/nextjs'; + +function fetchPost() { + return Promise.resolve({ id: '1', title: 'Post 1' }); +} + +export async function generateMetadata() { + const { id } = await fetchPost(); + const product = `Product: ${id}`; + + return { + title: product, + }; +} + +export default function Page() { + return ( + <> +

This will be pre-rendered

+ + + ); +} + +async function DynamicContent() { + const getTodos = async () => { + return Sentry.startSpan({ name: 'getTodos', op: 'get.todos' }, async () => { + 'use cache'; + await new Promise(resolve => setTimeout(resolve, 100)); + return [1, 2, 3, 4, 5]; + }); + }; + + const todos = await getTodos(); + + return
Todos fetched: {todos.length}
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata/page.tsx new file mode 100644 index 000000000000..7bcdbd0474e0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata/page.tsx @@ -0,0 +1,35 @@ +import * as Sentry from '@sentry/nextjs'; + +/** + * Tests generateMetadata function with cache components, this calls the propagation context to be set + * Which will generate and set a trace id in the propagation context, which should trigger the random API error if unpatched + * See: https://github.com/getsentry/sentry-javascript/issues/18392 + */ +export function generateMetadata() { + return { + title: 'Cache Components Metadata Test', + }; +} + +export default function Page() { + return ( + <> +

This will be pre-rendered

+ + + ); +} + +async function DynamicContent() { + const getTodos = async () => { + return Sentry.startSpan({ name: 'getTodos', op: 'get.todos' }, async () => { + 'use cache'; + await new Promise(resolve => setTimeout(resolve, 100)); + return [1, 2, 3, 4, 5]; + }); + }; + + const todos = await getTodos(); + + return
Todos fetched: {todos.length}
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts index 9f7b0ca559be..9a60ac59cd8f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts @@ -26,3 +26,29 @@ test('Should render suspense component', async ({ page }) => { expect(serverTx.spans?.filter(span => span.op === 'get.todos').length).toBeGreaterThan(0); await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); }); + +test('Should generate metadata', async ({ page }) => { + const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server'; + }); + + await page.goto('/metadata'); + const serverTx = await serverTxPromise; + + expect(serverTx.spans?.filter(span => span.op === 'get.todos')).toHaveLength(0); + await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); + await expect(page).toHaveTitle('Cache Components Metadata Test'); +}); + +test('Should generate metadata async', async ({ page }) => { + const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server'; + }); + + await page.goto('/metadata-async'); + const serverTx = await serverTxPromise; + + expect(serverTx.spans?.filter(span => span.op === 'get.todos')).toHaveLength(0); + await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); + await expect(page).toHaveTitle('Product: 1'); +}); diff --git a/packages/core/.eslintrc.js b/packages/core/.eslintrc.js index 5a021c016763..5ce5d0f72cd2 100644 --- a/packages/core/.eslintrc.js +++ b/packages/core/.eslintrc.js @@ -1,4 +1,15 @@ module.exports = { extends: ['../../.eslintrc.js'], ignorePatterns: ['rollup.npm.config.mjs'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', + }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index aad363905a68..56b382a2860e 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -45,6 +45,7 @@ import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc'; import { parseSampleRate } from './utils/parseSampleRate'; import { prepareEvent } from './utils/prepareEvent'; import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer'; +import { safeMathRandom } from './utils/randomSafeContext'; import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span'; import { showSpanDropWarning } from './utils/spanUtils'; import { rejectedSyncPromise } from './utils/syncpromise'; @@ -1288,7 +1289,7 @@ export abstract class Client { // 0.0 === 0% events are sent // Sampling for transaction happens somewhere else const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate); - if (isError && typeof parsedSampleRate === 'number' && Math.random() > parsedSampleRate) { + if (isError && typeof parsedSampleRate === 'number' && safeMathRandom() > parsedSampleRate) { this.recordDroppedEvent('sample_rate', 'error'); return rejectedSyncPromise( _makeDoNotSendEventError( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e4b48f24de2f..e74d67866f24 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -514,3 +514,9 @@ export type { UnstableRollupPluginOptions, UnstableWebpackPluginOptions, } from './build-time-plugins/buildTimeOptionsBase'; +export { + withRandomSafeContext as _INTERNAL_withRandomSafeContext, + type RandomSafeContextRunner as _INTERNAL_RandomSafeContextRunner, + safeMathRandom as _INTERNAL_safeMathRandom, + safeDateNow as _INTERNAL_safeDateNow, +} from './utils/randomSafeContext'; diff --git a/packages/core/src/integrations/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts index 22517306c7cb..3567ec382cdf 100644 --- a/packages/core/src/integrations/mcp-server/correlation.ts +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -46,6 +46,7 @@ export function storeSpanForRequest(transport: MCPTransport, requestId: RequestI spanMap.set(requestId, { span, method, + // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis startTime: Date.now(), }); } diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 0639cdb845f1..3d65149facb1 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -22,6 +22,7 @@ import { isPlainObject } from './utils/is'; import { merge } from './utils/merge'; import { uuid4 } from './utils/misc'; import { generateTraceId } from './utils/propagationContext'; +import { safeMathRandom } from './utils/randomSafeContext'; import { _getSpanForScope, _setSpanForScope } from './utils/spanOnScope'; import { truncate } from './utils/string'; import { dateTimestampInSeconds } from './utils/time'; @@ -168,7 +169,7 @@ export class Scope { this._sdkProcessingMetadata = {}; this._propagationContext = { traceId: generateTraceId(), - sampleRand: Math.random(), + sampleRand: safeMathRandom(), }; } @@ -550,7 +551,10 @@ export class Scope { this._session = undefined; _setSpanForScope(this, undefined); this._attachments = []; - this.setPropagationContext({ traceId: generateTraceId(), sampleRand: Math.random() }); + this.setPropagationContext({ + traceId: generateTraceId(), + sampleRand: safeMathRandom(), + }); this._notifyScopeListeners(); return this; diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index b147bb92fa63..28a5bccd4147 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -17,6 +17,7 @@ import { handleCallbackErrors } from '../utils/handleCallbackErrors'; import { hasSpansEnabled } from '../utils/hasSpansEnabled'; import { parseSampleRate } from '../utils/parseSampleRate'; import { generateTraceId } from '../utils/propagationContext'; +import { safeMathRandom } from '../utils/randomSafeContext'; import { _getSpanForScope, _setSpanForScope } from '../utils/spanOnScope'; import { addChildSpanToSpan, getRootSpan, spanIsSampled, spanTimeInputToSeconds, spanToJSON } from '../utils/spanUtils'; import { propagationContextFromHeaders, shouldContinueTrace } from '../utils/tracing'; @@ -293,7 +294,7 @@ export function startNewTrace(callback: () => T): T { return withScope(scope => { scope.setPropagationContext({ traceId: generateTraceId(), - sampleRand: Math.random(), + sampleRand: safeMathRandom(), }); DEBUG_BUILD && debug.log(`Starting a new trace with id ${scope.getPropagationContext().traceId}`); return withActiveSpan(null, callback); diff --git a/packages/core/src/utils/misc.ts b/packages/core/src/utils/misc.ts index 69cd217345b8..86ddd52b05c3 100644 --- a/packages/core/src/utils/misc.ts +++ b/packages/core/src/utils/misc.ts @@ -3,6 +3,7 @@ import type { Exception } from '../types-hoist/exception'; import type { Mechanism } from '../types-hoist/mechanism'; import type { StackFrame } from '../types-hoist/stackframe'; import { addNonEnumerableProperty } from './object'; +import { safeMathRandom, withRandomSafeContext } from './randomSafeContext'; import { snipLine } from './string'; import { GLOBAL_OBJ } from './worldwide'; @@ -24,7 +25,7 @@ function getCrypto(): CryptoInternal | undefined { let emptyUuid: string | undefined; function getRandomByte(): number { - return Math.random() * 16; + return safeMathRandom() * 16; } /** @@ -35,7 +36,8 @@ function getRandomByte(): number { export function uuid4(crypto = getCrypto()): string { try { if (crypto?.randomUUID) { - return crypto.randomUUID().replace(/-/g, ''); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return withRandomSafeContext(() => crypto.randomUUID!()).replace(/-/g, ''); } } catch { // some runtimes can crash invoking crypto diff --git a/packages/core/src/utils/randomSafeContext.ts b/packages/core/src/utils/randomSafeContext.ts new file mode 100644 index 000000000000..ce4bf5a8f16d --- /dev/null +++ b/packages/core/src/utils/randomSafeContext.ts @@ -0,0 +1,43 @@ +import { GLOBAL_OBJ } from './worldwide'; + +export type RandomSafeContextRunner = (callback: () => T) => T; + +// undefined = not yet resolved, null = no runner found, function = runner found +let RESOLVED_RUNNER: RandomSafeContextRunner | null | undefined; + +/** + * Simple wrapper that allows SDKs to *secretly* set context wrapper to generate safe random IDs in cache components contexts + */ +export function withRandomSafeContext(cb: () => T): T { + // Skips future symbol lookups if we've already resolved (or attempted to resolve) the runner once + if (RESOLVED_RUNNER !== undefined) { + return RESOLVED_RUNNER ? RESOLVED_RUNNER(cb) : cb(); + } + + const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); + const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: RandomSafeContextRunner } = GLOBAL_OBJ; + + if (sym in globalWithSymbol && typeof globalWithSymbol[sym] === 'function') { + RESOLVED_RUNNER = globalWithSymbol[sym]; + return RESOLVED_RUNNER(cb); + } + + RESOLVED_RUNNER = null; + return cb(); +} + +/** + * Identical to Math.random() but wrapped in withRandomSafeContext + * to ensure safe random number generation in certain contexts (e.g., Next.js Cache Components). + */ +export function safeMathRandom(): number { + return withRandomSafeContext(() => Math.random()); +} + +/** + * Identical to Date.now() but wrapped in withRandomSafeContext + * to ensure safe time value generation in certain contexts (e.g., Next.js Cache Components). + */ +export function safeDateNow(): number { + return withRandomSafeContext(() => Date.now()); +} diff --git a/packages/core/src/utils/ratelimit.ts b/packages/core/src/utils/ratelimit.ts index 4cb8cb9d07a5..606969d88858 100644 --- a/packages/core/src/utils/ratelimit.ts +++ b/packages/core/src/utils/ratelimit.ts @@ -1,5 +1,6 @@ import type { DataCategory } from '../types-hoist/datacategory'; import type { TransportMakeRequestResponse } from '../types-hoist/transport'; +import { safeDateNow } from './randomSafeContext'; // Intentionally keeping the key broad, as we don't know for sure what rate limit headers get returned from backend export type RateLimits = Record; @@ -12,7 +13,7 @@ export const DEFAULT_RETRY_AFTER = 60 * 1000; // 60 seconds * @param now current unix timestamp * */ -export function parseRetryAfterHeader(header: string, now: number = Date.now()): number { +export function parseRetryAfterHeader(header: string, now: number = safeDateNow()): number { const headerDelay = parseInt(`${header}`, 10); if (!isNaN(headerDelay)) { return headerDelay * 1000; @@ -40,7 +41,7 @@ export function disabledUntil(limits: RateLimits, dataCategory: DataCategory): n /** * Checks if a category is rate limited */ -export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, now: number = Date.now()): boolean { +export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, now: number = safeDateNow()): boolean { return disabledUntil(limits, dataCategory) > now; } @@ -52,7 +53,7 @@ export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, no export function updateRateLimits( limits: RateLimits, { statusCode, headers }: TransportMakeRequestResponse, - now: number = Date.now(), + now: number = safeDateNow(), ): RateLimits { const updatedRateLimits: RateLimits = { ...limits, diff --git a/packages/core/src/utils/time.ts b/packages/core/src/utils/time.ts index ecaca1ea9e9b..10a5103b2fc1 100644 --- a/packages/core/src/utils/time.ts +++ b/packages/core/src/utils/time.ts @@ -1,3 +1,4 @@ +import { safeDateNow, withRandomSafeContext } from './randomSafeContext'; import { GLOBAL_OBJ } from './worldwide'; const ONE_SECOND_IN_MS = 1000; @@ -21,7 +22,7 @@ interface Performance { * Returns a timestamp in seconds since the UNIX epoch using the Date API. */ export function dateTimestampInSeconds(): number { - return Date.now() / ONE_SECOND_IN_MS; + return safeDateNow() / ONE_SECOND_IN_MS; } /** @@ -50,7 +51,7 @@ function createUnixTimestampInSecondsFunc(): () => number { // See: https://github.com/mdn/content/issues/4713 // See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6 return () => { - return (timeOrigin + performance.now()) / ONE_SECOND_IN_MS; + return (timeOrigin + withRandomSafeContext(() => performance.now())) / ONE_SECOND_IN_MS; }; } @@ -92,8 +93,8 @@ function getBrowserTimeOrigin(): number | undefined { } const threshold = 300_000; // 5 minutes in milliseconds - const performanceNow = performance.now(); - const dateNow = Date.now(); + const performanceNow = withRandomSafeContext(() => performance.now()); + const dateNow = safeDateNow(); const timeOrigin = performance.timeOrigin; if (typeof timeOrigin === 'number') { diff --git a/packages/core/src/utils/tracing.ts b/packages/core/src/utils/tracing.ts index aa5a15153674..25e3295118f8 100644 --- a/packages/core/src/utils/tracing.ts +++ b/packages/core/src/utils/tracing.ts @@ -7,6 +7,7 @@ import { baggageHeaderToDynamicSamplingContext } from './baggage'; import { extractOrgIdFromClient } from './dsn'; import { parseSampleRate } from './parseSampleRate'; import { generateSpanId, generateTraceId } from './propagationContext'; +import { safeMathRandom } from './randomSafeContext'; // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- RegExp is used for readability here export const TRACEPARENT_REGEXP = new RegExp( @@ -65,7 +66,7 @@ export function propagationContextFromHeaders( if (!traceparentData?.traceId) { return { traceId: generateTraceId(), - sampleRand: Math.random(), + sampleRand: safeMathRandom(), }; } @@ -133,12 +134,12 @@ function getSampleRandFromTraceparentAndDsc( if (parsedSampleRate && traceparentData?.parentSampled !== undefined) { return traceparentData.parentSampled ? // Returns a sample rand with positive sampling decision [0, sampleRate) - Math.random() * parsedSampleRate + safeMathRandom() * parsedSampleRate : // Returns a sample rand with negative sampling decision [sampleRate, 1) - parsedSampleRate + Math.random() * (1 - parsedSampleRate); + parsedSampleRate + safeMathRandom() * (1 - parsedSampleRate); } else { // If nothing applies, return a random sample rand. - return Math.random(); + return safeMathRandom(); } } diff --git a/packages/eslint-plugin-sdk/src/index.js b/packages/eslint-plugin-sdk/src/index.js index 24cc9c4cc00c..c23a1afcd373 100644 --- a/packages/eslint-plugin-sdk/src/index.js +++ b/packages/eslint-plugin-sdk/src/index.js @@ -15,5 +15,6 @@ module.exports = { 'no-regexp-constructor': require('./rules/no-regexp-constructor'), 'no-focused-tests': require('./rules/no-focused-tests'), 'no-skipped-tests': require('./rules/no-skipped-tests'), + 'no-unsafe-random-apis': require('./rules/no-unsafe-random-apis'), }, }; diff --git a/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js b/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js new file mode 100644 index 000000000000..8a9a27795481 --- /dev/null +++ b/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js @@ -0,0 +1,147 @@ +'use strict'; + +/** + * @fileoverview Rule to enforce wrapping random/time APIs with withRandomSafeContext + * + * This rule detects uses of APIs that generate random values or time-based values + * and ensures they are wrapped with `withRandomSafeContext()` to ensure safe + * random number generation in certain contexts (e.g., React Server Components with caching). + */ + +// APIs that should be wrapped with withRandomSafeContext, with their specific messages +const UNSAFE_MEMBER_CALLS = [ + { + object: 'Date', + property: 'now', + messageId: 'unsafeDateNow', + }, + { + object: 'Math', + property: 'random', + messageId: 'unsafeMathRandom', + }, + { + object: 'performance', + property: 'now', + messageId: 'unsafePerformanceNow', + }, + { + object: 'crypto', + property: 'randomUUID', + messageId: 'unsafeCryptoRandomUUID', + }, + { + object: 'crypto', + property: 'getRandomValues', + messageId: 'unsafeCryptoGetRandomValues', + }, +]; + +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Enforce wrapping random/time APIs (Date.now, Math.random, performance.now, crypto.randomUUID) with withRandomSafeContext', + category: 'Best Practices', + recommended: true, + }, + fixable: null, + schema: [], + messages: { + unsafeDateNow: + '`Date.now()` should be replaced with `safeDateNow()` from `@sentry/core` to ensure safe time value generation. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafeMathRandom: + '`Math.random()` should be replaced with `safeMathRandom()` from `@sentry/core` to ensure safe random value generation. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafePerformanceNow: + '`performance.now()` should be wrapped with `withRandomSafeContext()` to ensure safe time value generation. Use: `withRandomSafeContext(() => performance.now())`. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafeCryptoRandomUUID: + '`crypto.randomUUID()` should be wrapped with `withRandomSafeContext()` to ensure safe random value generation. Use: `withRandomSafeContext(() => crypto.randomUUID())`. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafeCryptoGetRandomValues: + '`crypto.getRandomValues()` should be wrapped with `withRandomSafeContext()` to ensure safe random value generation. Use: `withRandomSafeContext(() => crypto.getRandomValues(...))`. You can disable this rule with an eslint-disable comment if this usage is intentional.', + }, + }, + create: function (context) { + /** + * Check if a node is inside a withRandomSafeContext call + */ + function isInsidewithRandomSafeContext(node) { + let current = node.parent; + + while (current) { + // Check if we're inside a callback passed to withRandomSafeContext + if ( + current.type === 'CallExpression' && + current.callee.type === 'Identifier' && + current.callee.name === 'withRandomSafeContext' + ) { + return true; + } + + // Also check for arrow functions or regular functions passed to withRandomSafeContext + if ( + (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') && + current.parent?.type === 'CallExpression' && + current.parent.callee.type === 'Identifier' && + current.parent.callee.name === 'withRandomSafeContext' + ) { + return true; + } + + current = current.parent; + } + + return false; + } + + /** + * Check if a node is inside the safeRandomGeneratorRunner.ts file (the definition file) + */ + function isInSafeRandomGeneratorRunner(_node) { + const filename = context.getFilename(); + return filename.includes('safeRandomGeneratorRunner'); + } + + return { + CallExpression(node) { + // Skip if we're in the safeRandomGeneratorRunner.ts file itself + if (isInSafeRandomGeneratorRunner(node)) { + return; + } + + // Check for member expression calls like Date.now(), Math.random(), etc. + if (node.callee.type === 'MemberExpression') { + const callee = node.callee; + + // Get the object name (e.g., 'Date', 'Math', 'performance', 'crypto') + let objectName = null; + if (callee.object.type === 'Identifier') { + objectName = callee.object.name; + } + + // Get the property name (e.g., 'now', 'random', 'randomUUID') + let propertyName = null; + if (callee.property.type === 'Identifier') { + propertyName = callee.property.name; + } else if (callee.computed && callee.property.type === 'Literal') { + propertyName = callee.property.value; + } + + if (!objectName || !propertyName) { + return; + } + + // Check if this is one of the unsafe APIs + const unsafeApi = UNSAFE_MEMBER_CALLS.find(api => api.object === objectName && api.property === propertyName); + + if (unsafeApi && !isInsidewithRandomSafeContext(node)) { + context.report({ + node, + messageId: unsafeApi.messageId, + }); + } + } + }, + }; + }, +}; diff --git a/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts b/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts new file mode 100644 index 000000000000..e145336d6c3e --- /dev/null +++ b/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts @@ -0,0 +1,146 @@ +import { RuleTester } from 'eslint'; +import { describe, test } from 'vitest'; +// @ts-expect-error untyped module +import rule from '../../../src/rules/no-unsafe-random-apis'; + +describe('no-unsafe-random-apis', () => { + test('ruleTester', () => { + const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 2020, + }, + }); + + ruleTester.run('no-unsafe-random-apis', rule, { + valid: [ + // Wrapped with withRandomSafeContext - arrow function + { + code: 'withRandomSafeContext(() => Date.now())', + }, + { + code: 'withRandomSafeContext(() => Math.random())', + }, + { + code: 'withRandomSafeContext(() => performance.now())', + }, + { + code: 'withRandomSafeContext(() => crypto.randomUUID())', + }, + { + code: 'withRandomSafeContext(() => crypto.getRandomValues(new Uint8Array(16)))', + }, + // Wrapped with withRandomSafeContext - regular function + { + code: 'withRandomSafeContext(function() { return Date.now(); })', + }, + // Nested inside withRandomSafeContext + { + code: 'withRandomSafeContext(() => { const x = Date.now(); return x + Math.random(); })', + }, + // Expression inside withRandomSafeContext + { + code: 'withRandomSafeContext(() => Date.now() / 1000)', + }, + // Other unrelated calls should be fine + { + code: 'const x = someObject.now()', + }, + { + code: 'const x = Date.parse("2021-01-01")', + }, + { + code: 'const x = Math.floor(5.5)', + }, + { + code: 'const x = performance.mark("test")', + }, + ], + invalid: [ + // Direct Date.now() calls + { + code: 'const time = Date.now()', + errors: [ + { + messageId: 'unsafeDateNow', + }, + ], + }, + // Direct Math.random() calls + { + code: 'const random = Math.random()', + errors: [ + { + messageId: 'unsafeMathRandom', + }, + ], + }, + // Direct performance.now() calls + { + code: 'const perf = performance.now()', + errors: [ + { + messageId: 'unsafePerformanceNow', + }, + ], + }, + // Direct crypto.randomUUID() calls + { + code: 'const uuid = crypto.randomUUID()', + errors: [ + { + messageId: 'unsafeCryptoRandomUUID', + }, + ], + }, + // Direct crypto.getRandomValues() calls + { + code: 'const bytes = crypto.getRandomValues(new Uint8Array(16))', + errors: [ + { + messageId: 'unsafeCryptoGetRandomValues', + }, + ], + }, + // Inside a function but not wrapped + { + code: 'function getTime() { return Date.now(); }', + errors: [ + { + messageId: 'unsafeDateNow', + }, + ], + }, + // Inside an arrow function but not wrapped with withRandomSafeContext + { + code: 'const getTime = () => Date.now()', + errors: [ + { + messageId: 'unsafeDateNow', + }, + ], + }, + // Inside someOtherWrapper + { + code: 'someOtherWrapper(() => Date.now())', + errors: [ + { + messageId: 'unsafeDateNow', + }, + ], + }, + // Multiple violations + { + code: 'const a = Date.now(); const b = Math.random();', + errors: [ + { + messageId: 'unsafeDateNow', + }, + { + messageId: 'unsafeMathRandom', + }, + ], + }, + ], + }); + }); +}); diff --git a/packages/nextjs/.eslintrc.js b/packages/nextjs/.eslintrc.js index 1f0ae547d4e0..4a5bdd17795e 100644 --- a/packages/nextjs/.eslintrc.js +++ b/packages/nextjs/.eslintrc.js @@ -7,6 +7,9 @@ module.exports = { jsx: true, }, extends: ['../../.eslintrc.js'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', + }, overrides: [ { files: ['scripts/**/*.ts'], @@ -27,5 +30,11 @@ module.exports = { globalThis: 'readonly', }, }, + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, ], }; diff --git a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts index c85bdc4f2ad3..8cd0c016d0fb 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts @@ -1,4 +1,4 @@ -import { captureCheckIn } from '@sentry/core'; +import { _INTERNAL_safeDateNow, captureCheckIn } from '@sentry/core'; import type { NextApiRequest } from 'next'; import type { VercelCronsConfig } from '../types'; @@ -57,14 +57,14 @@ export function wrapApiHandlerWithSentryVercelCrons { captureCheckIn({ checkInId, monitorSlug, status: 'error', - duration: Date.now() / 1000 - startTime, + duration: _INTERNAL_safeDateNow() / 1000 - startTime, }); }; @@ -82,7 +82,7 @@ export function wrapApiHandlerWithSentryVercelCrons { @@ -98,7 +98,7 @@ export function wrapApiHandlerWithSentryVercelCrons(nextConfig?: C, sentryBuildOptions: SentryBu */ function generateRandomTunnelRoute(): string { // Generate a random 8-character alphanumeric string + // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis const randomString = Math.random().toString(36).substring(2, 10); return `/${randomString}`; } diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 18f3db003177..91d1dd65ca06 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -34,6 +34,7 @@ import { isBuild } from '../common/utils/isBuild'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; import { handleOnSpanStart } from './handleOnSpanStart'; +import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext'; export * from '@sentry/node'; @@ -92,6 +93,7 @@ export function showReportDialog(): void { /** Inits the Sentry NextJS SDK on node. */ export function init(options: NodeOptions): NodeClient | undefined { + prepareSafeIdGeneratorContext(); if (isBuild()) { return; } diff --git a/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts new file mode 100644 index 000000000000..bd262eb736e1 --- /dev/null +++ b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts @@ -0,0 +1,49 @@ +import { + type _INTERNAL_RandomSafeContextRunner as _INTERNAL_RandomSafeContextRunner, + debug, + GLOBAL_OBJ, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../common/debug-build'; + +// Inline AsyncLocalStorage interface from current types +// Avoids conflict with resolving it from getBuiltinModule +type OriginalAsyncLocalStorage = typeof AsyncLocalStorage; + +/** + * Prepares the global object to generate safe random IDs in cache components contexts + * See: https://github.com/getsentry/sentry-javascript/blob/ceb003c15973c2d8f437dfb7025eedffbc8bc8b0/packages/core/src/utils/propagationContext.ts#L1 + */ +export function prepareSafeIdGeneratorContext(): void { + const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); + const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: _INTERNAL_RandomSafeContextRunner } = GLOBAL_OBJ; + const als = getAsyncLocalStorage(); + if (!als || typeof als.snapshot !== 'function') { + DEBUG_BUILD && + debug.warn( + '[@sentry/nextjs] No AsyncLocalStorage found in the runtime or AsyncLocalStorage.snapshot() is not available, skipping safe random ID generator context preparation, you may see some errors with cache components.', + ); + return; + } + + globalWithSymbol[sym] = als.snapshot(); + DEBUG_BUILD && debug.log('[@sentry/nextjs] Prepared safe random ID generator context'); +} + +function getAsyncLocalStorage(): OriginalAsyncLocalStorage | undefined { + // May exist in the Next.js runtime globals + // Doesn't exist in some of our tests + if (typeof AsyncLocalStorage !== 'undefined') { + return AsyncLocalStorage; + } + + // Try to resolve it dynamically without synchronously importing the module + // This is done to avoid importing the module synchronously at the top + // which means this is safe across runtimes + if ('getBuiltinModule' in process && typeof process.getBuiltinModule === 'function') { + const { AsyncLocalStorage } = process.getBuiltinModule('async_hooks') ?? {}; + + return AsyncLocalStorage as OriginalAsyncLocalStorage; + } + + return undefined; +} diff --git a/packages/node-core/.eslintrc.js b/packages/node-core/.eslintrc.js index 6da218bd8641..073e587833b6 100644 --- a/packages/node-core/.eslintrc.js +++ b/packages/node-core/.eslintrc.js @@ -5,5 +5,14 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-class-field-initializers': 'off', + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/node-core/src/integrations/context.ts b/packages/node-core/src/integrations/context.ts index cad8a1c4a443..6584640935ee 100644 --- a/packages/node-core/src/integrations/context.ts +++ b/packages/node-core/src/integrations/context.ts @@ -204,6 +204,7 @@ function getCultureContext(): CultureContext | undefined { */ export function getAppContext(): AppContext { const app_memory = process.memoryUsage().rss; + // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis const app_start_time = new Date(Date.now() - process.uptime() * 1000).toISOString(); // https://nodejs.org/api/process.html#processavailablememory const appContext: AppContext = { app_start_time, app_memory }; @@ -236,6 +237,7 @@ export function getDeviceContext(deviceOpt: DeviceContextOptions | true): Device // Hence, we only set boot time, if we get a valid uptime value. // @see https://github.com/getsentry/sentry-javascript/issues/5856 if (typeof uptime === 'number') { + // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis device.boot_time = new Date(Date.now() - uptime * 1000).toISOString(); } diff --git a/packages/node/.eslintrc.js b/packages/node/.eslintrc.js index 6da218bd8641..073e587833b6 100644 --- a/packages/node/.eslintrc.js +++ b/packages/node/.eslintrc.js @@ -5,5 +5,14 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-class-field-initializers': 'off', + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/opentelemetry/.eslintrc.js b/packages/opentelemetry/.eslintrc.js index fdb9952bae52..4b5e6310c8ee 100644 --- a/packages/opentelemetry/.eslintrc.js +++ b/packages/opentelemetry/.eslintrc.js @@ -3,4 +3,15 @@ module.exports = { node: true, }, extends: ['../../.eslintrc.js'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', + }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index e06fe51bfd2a..7f7edd441612 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -12,6 +12,7 @@ import { } from '@opentelemetry/semantic-conventions'; import type { Client, SpanAttributes } from '@sentry/core'; import { + _INTERNAL_safeMathRandom, baggageHeaderToDynamicSamplingContext, debug, hasSpansEnabled, @@ -121,7 +122,7 @@ export class SentrySampler implements Sampler { const dscString = parentContext?.traceState ? parentContext.traceState.get(SENTRY_TRACE_STATE_DSC) : undefined; const dsc = dscString ? baggageHeaderToDynamicSamplingContext(dscString) : undefined; - const sampleRand = parseSampleRate(dsc?.sample_rand) ?? Math.random(); + const sampleRand = parseSampleRate(dsc?.sample_rand) ?? _INTERNAL_safeMathRandom(); const [sampled, sampleRate, localSampleRateWasApplied] = sampleSpan( options, diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index ea85641387a5..f02df1d9d56c 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -12,6 +12,7 @@ import type { TransactionSource, } from '@sentry/core'; import { + _INTERNAL_safeDateNow, captureEvent, convertSpanLinksForEnvelope, debounce, @@ -82,7 +83,7 @@ export class SentrySpanExporter { }) { this._finishedSpanBucketSize = options?.timeout || DEFAULT_TIMEOUT; this._finishedSpanBuckets = new Array(this._finishedSpanBucketSize).fill(undefined); - this._lastCleanupTimestampInS = Math.floor(Date.now() / 1000); + this._lastCleanupTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000); this._spansToBucketEntry = new WeakMap(); this._sentSpans = new Map(); this._debouncedFlush = debounce(this.flush.bind(this), 1, { maxWait: 100 }); @@ -93,7 +94,7 @@ export class SentrySpanExporter { * This is called by the span processor whenever a span is ended. */ public export(span: ReadableSpan): void { - const currentTimestampInS = Math.floor(Date.now() / 1000); + const currentTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000); if (this._lastCleanupTimestampInS !== currentTimestampInS) { let droppedSpanCount = 0; @@ -146,7 +147,7 @@ export class SentrySpanExporter { `SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} spans are waiting for their parent spans to finish`, ); - const expirationDate = Date.now() + DEFAULT_TIMEOUT * 1000; + const expirationDate = _INTERNAL_safeDateNow() + DEFAULT_TIMEOUT * 1000; for (const span of sentSpans) { this._sentSpans.set(span.spanContext().spanId, expirationDate); @@ -226,7 +227,7 @@ export class SentrySpanExporter { /** Remove "expired" span id entries from the _sentSpans cache. */ private _flushSentSpanCache(): void { - const currentTimestamp = Date.now(); + const currentTimestamp = _INTERNAL_safeDateNow(); // Note, it is safe to delete items from the map as we go: https://stackoverflow.com/a/35943995/90297 for (const [spanId, expirationTime] of this._sentSpans.entries()) { if (expirationTime <= currentTimestamp) { diff --git a/packages/vercel-edge/.eslintrc.js b/packages/vercel-edge/.eslintrc.js index 6da218bd8641..073e587833b6 100644 --- a/packages/vercel-edge/.eslintrc.js +++ b/packages/vercel-edge/.eslintrc.js @@ -5,5 +5,14 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-class-field-initializers': 'off', + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], };