diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c5ce1964..b144e5a0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,6 +168,8 @@ jobs: STRIPE_SECRET_KEY: "" STRIPE_WEBHOOK_SECRET: "" run: npm --workspace=apps/web test -- --reporter=default --reporter=json --outputFile.json="$RUNNER_TEMP/web-vitest-report.json" + - name: Root MCP dependency security and type regressions + run: npm run test:mcp-security - name: Retain machine-readable test evidence if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/apps/web/package.json b/apps/web/package.json index 67b5cd592..986919f4d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,7 +27,6 @@ "@opentelemetry/exporter-trace-otlp-http": "^0.300.0", "@opentelemetry/instrumentation": "0.300.0", "@opentelemetry/resources": "3.0.0", - "@opentelemetry/sdk-trace-base": "2.12.0", "@opentelemetry/semantic-conventions": "1.44.0", "@radix-ui/react-label": "^2.1.17", "@radix-ui/react-separator": "^1.2.0", @@ -66,7 +65,8 @@ "workflow": "^4.8.9", "yaml": "^2.9.1", "zod": "^4.6.7", - "zustand": "^5.0.16" + "zustand": "^5.0.16", + "@opentelemetry/sdk-trace": "3.0.0" }, "devDependencies": { "@playwright/test": "^1.64.1", diff --git a/apps/web/src/lib/__tests__/opentelemetry-compatibility.test.ts b/apps/web/src/lib/__tests__/opentelemetry-compatibility.test.ts new file mode 100644 index 000000000..819088c28 --- /dev/null +++ b/apps/web/src/lib/__tests__/opentelemetry-compatibility.test.ts @@ -0,0 +1,91 @@ +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// Use Node's installed-package resolution, including transitive dependencies. +// Mocking these imports would hide the hoisted API mismatch this guards against. +const fromWeb = createRequire(join(process.cwd(), 'package.json')); + +describe('installed OpenTelemetry compatibility', () => { + it('preserves StackContextManager scope and restores context after errors', () => { + const { StackContextManager } = fromWeb('@opentelemetry/sdk-trace') as + typeof import('@opentelemetry/sdk-trace'); + const { createContextKey, ROOT_CONTEXT } = fromWeb('@opentelemetry/api') as + typeof import('@opentelemetry/api'); + const manager = new StackContextManager().enable(); + const key = createContextKey('eventrelay-test-request'); + const scoped = ROOT_CONTEXT.setValue(key, 'synthetic-request'); + + try { + expect(manager.active().getValue(key)).toBeUndefined(); + expect(manager.with(scoped, () => manager.active().getValue(key))).toBe('synthetic-request'); + expect(manager.active().getValue(key)).toBeUndefined(); + expect(() => manager.with(scoped, () => { + expect(manager.active().getValue(key)).toBe('synthetic-request'); + throw new Error('synthetic callback failure'); + })).toThrow('synthetic callback failure'); + expect(manager.active().getValue(key)).toBeUndefined(); + } finally { + manager.disable(); + } + }); + + it('initializes instrumentation with the logging API it actually resolves', () => { + const { InstrumentationBase } = fromWeb('@opentelemetry/instrumentation') as + typeof import('@opentelemetry/instrumentation'); + class ProbeInstrumentation extends InstrumentationBase { + protected init() { return []; } + } + + const probe = new ProbeInstrumentation('eventrelay-compatibility-test', '1.0.0', { + enabled: false, + }); + expect(probe.isEnabled()).toBe(false); + probe.disable(); + }); + + it('records and exports a span through the maintained tracing SDK', async () => { + const { TracerProvider, SimpleSpanProcessor, InMemorySpanExporter } = + fromWeb('@opentelemetry/sdk-trace') as typeof import('@opentelemetry/sdk-trace'); + const { resourceFromAttributes } = fromWeb('@opentelemetry/resources') as + typeof import('@opentelemetry/resources'); + const exporter = new InMemorySpanExporter(); + const provider = new TracerProvider({ + resource: resourceFromAttributes({ 'service.name': 'eventrelay-test' }), + spanProcessors: [new SimpleSpanProcessor({ exporter })], + }); + + try { + const span = provider.getTracer('compatibility-test').startSpan('video-processing'); + span.setAttribute('video.id', 'synthetic-video'); + span.end(); + await provider.forceFlush(); + + const spans = exporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + expect(spans[0].name).toBe('video-processing'); + expect(spans[0].attributes['video.id']).toBe('synthetic-video'); + expect(spans[0].resource.attributes['service.name']).toBe('eventrelay-test'); + } finally { + await provider.shutdown(); + } + }); + + it('shares the logging-capable API with the exporter and its SDK dependencies', () => { + const api = fromWeb('@opentelemetry/api') as typeof import('@opentelemetry/api'); + expect(typeof api.logs.getLogger).toBe('function'); + const fromExporter = createRequire(fromWeb.resolve('@opentelemetry/exporter-trace-otlp-http')); + const fromTransformer = createRequire(fromExporter.resolve('@opentelemetry/otlp-transformer')); + + for (const packageName of [ + '@opentelemetry/core', + '@opentelemetry/resources', + '@opentelemetry/sdk-trace', + '@opentelemetry/sdk-logs', + '@opentelemetry/sdk-metrics', + ]) { + const fromPackage = createRequire(fromTransformer.resolve(packageName)); + expect(fromPackage('@opentelemetry/api'), packageName).toBe(api); + } + }); +}); diff --git a/apps/web/vercel.json b/apps/web/vercel.json new file mode 100644 index 000000000..b92f533a1 --- /dev/null +++ b/apps/web/vercel.json @@ -0,0 +1,7 @@ +{ + "git": { + "deploymentEnabled": { + "dependabot/npm_and_yarn/apps/web/opentelemetry/sdk-trace-base-2.12.0": false + } + } +} diff --git a/package-lock.json b/package-lock.json index 70997339c..ea61eac43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,6 @@ "@opentelemetry/exporter-trace-otlp-http": "^0.300.0", "@opentelemetry/instrumentation": "0.300.0", "@opentelemetry/resources": "3.0.0", - "@opentelemetry/sdk-trace-base": "2.12.0", "@opentelemetry/semantic-conventions": "1.44.0", "@radix-ui/react-label": "^2.1.17", "@radix-ui/react-separator": "^1.2.0", @@ -93,7 +92,8 @@ "workflow": "^4.8.9", "yaml": "^2.9.1", "zod": "^4.6.7", - "zustand": "^5.0.16" + "zustand": "^5.0.16", + "@opentelemetry/sdk-trace": "3.0.0" }, "devDependencies": { "@modelcontextprotocol/sdk": "^1.32.0", @@ -374,91 +374,6 @@ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, - "apps/web/node_modules/@opentelemetry/api": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.10.0.tgz", - "integrity": "sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "apps/web/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.12.0.tgz", - "integrity": "sha512-JOERgZCxRM06FEJ5kEWxlSRChah6MmXDx3KwCruqEv/iEyLkTokCSemf2y1w8oIHtfrr8EVTpzebq/31vn2Cpg==", - "deprecated": "Use @opentelemetry/sdk-trace package", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.12.0", - "@opentelemetry/resources": "2.12.0", - "@opentelemetry/sdk-trace": "2.12.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "apps/web/node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.12.0.tgz", - "integrity": "sha512-1HsSAuvT4/my0QrXWsyXFdWjKaPeCHsismKZgEEIb8NK13LP6DrMRlsUbvD/0F9mDWK3xDom9AMv3IscxgOnOQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "apps/web/node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.12.0.tgz", - "integrity": "sha512-+rSRoOdln6NhXTVcBdHSlZxJDNzwxadeweK2ltZO3tVtfp6aklABc06xclDk6ovRR+EiGgIcSbh/6NgjFhnb3A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.12.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "apps/web/node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/sdk-trace": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.12.0.tgz", - "integrity": "sha512-ImVuRQ6faOsTi1Wna88s2avgrrf/iDgXPigIeynkUhjstMuoQUDOJXgkez2j1mlU4btjXcXBGa+q7qKFYR3GsA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.12.0", - "@opentelemetry/resources": "2.12.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "apps/web/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.44.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.44.0.tgz", - "integrity": "sha512-E3JkOlMBi3N8OGGkbl7tFyqk2MAS6/lySRZBK3Mu7AzjF1fT8oIChH9ChE48SUWzDn9Kdwta+G/p5LJ4xW4CLQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "apps/web/node_modules/@oxc-parser/binding-android-arm-eabi": { "version": "0.152.0", "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.152.0.tgz", @@ -4456,7 +4371,7 @@ "version": "1.32.2", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.2.tgz", "integrity": "sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", @@ -4817,9 +4732,9 @@ } }, "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.10.0.tgz", + "integrity": "sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==", "license": "Apache-2.0", "engines": { "node": ">=8.0.0" @@ -4975,9 +4890,9 @@ } }, "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", - "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.44.0.tgz", + "integrity": "sha512-E3JkOlMBi3N8OGGkbl7tFyqk2MAS6/lySRZBK3Mu7AzjF1fT8oIChH9ChE48SUWzDn9Kdwta+G/p5LJ4xW4CLQ==", "license": "Apache-2.0", "engines": { "node": ">=14" @@ -5399,7 +5314,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@rollup/plugin-commonjs": { @@ -6498,7 +6413,7 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -6508,7 +6423,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -8241,7 +8156,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -8258,7 +8173,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -9428,7 +9343,7 @@ "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "object-assign": "^4", @@ -9474,7 +9389,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -10659,7 +10574,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" @@ -10778,7 +10693,7 @@ "version": "8.5.2", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ip-address": "^10.2.0" @@ -10809,7 +10724,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -12523,7 +12438,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -12613,15 +12528,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "dev": true, - "license": "BSD-2-Clause" + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-9.0.0.tgz", + "integrity": "sha512-t+dkn3pLaz7ku3ELaBKBSQNjd0Sir5zR7Y4CpAR5a+UEH60NefBEAhAgJuWqDH5o4HDfH+EQjmXeQMLc+Xnbdg==", + "license": "BSD-3-Clause", + "devOptional": true }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -13575,7 +13490,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14595,7 +14510,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -15330,15 +15245,6 @@ "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", "license": "MIT" }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -15473,6 +15379,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -16076,7 +15991,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -17033,7 +16947,7 @@ "version": "3.25.2", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "dev": true, + "devOptional": true, "license": "ISC", "peerDependencies": { "zod": "^3.25.28 || ^4" diff --git a/package.json b/package.json index c32252aa3..e61eaae8d 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "test": "turbo run test", "build:web": "cd apps/web && npm run build", "dev:web": "cd apps/web && npm run dev", - "install:all": "npm install" + "install:all": "npm install", + "test:mcp-security": "tsc --project tests/security/tsconfig.mcp.json && vitest run --config tests/security/vitest.mcp.config.mts" }, "workspaces": [ "apps/*" @@ -79,7 +80,10 @@ "ip-address@^10.0.0": "^10.7.4", "qs@^6.0.0": "^6.16.1", "vitest@^4.0.0": "^4.1.12", - "vitest@^5.0.0": "^5.0.3" + "vitest@^5.0.0": "^5.0.3", + "@modelcontextprotocol/sdk": { + "json-schema-typed": "9.0.0" + } }, "dependencies": { "@ai-sdk/gateway": "^4.0.135", diff --git a/tests/security/mcp-transitive-security.test.ts b/tests/security/mcp-transitive-security.test.ts new file mode 100644 index 000000000..28c1b03c8 --- /dev/null +++ b/tests/security/mcp-transitive-security.test.ts @@ -0,0 +1,194 @@ +import { randomUUID } from 'node:crypto'; +import { createServer } from 'node:http'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import type { JsonSchemaType } from '@modelcontextprotocol/sdk/validation/types.js'; + +// Follow the actual optional-runtime GenAI -> MCP dependency chain. Importing +// a different workspace copy would conceal a vulnerable hoisted consumer. +const fromRoot = createRequire(fileURLToPath(new URL('../../package.json', import.meta.url))); +const fromGenai = createRequire(fromRoot.resolve('@google/genai')); +const fromSdk = createRequire(fromGenai.resolve('@modelcontextprotocol/sdk/client/index.js')); +const { Hono } = fromSdk('hono') as typeof import('hono'); +const fromAjv = createRequire(fromSdk.resolve('ajv')); +const uri = fromAjv('fast-uri') as typeof import('fast-uri'); + +describe('installed MCP transitive security', () => { + it.each(['node-server', 'hono'] as const)( + '%s static middleware preserves public files without bypassing protected routes', + async (adapter) => { + const root = await mkdtemp(join(tmpdir(), 'eventrelay-static-security-')); + try { + await mkdir(join(root, 'static/admin'), { recursive: true }); + await writeFile(join(root, 'static/public.txt'), 'public fixture'); + await writeFile(join(root, 'static/admin/secret.txt'), 'private fixture'); + const app = new Hono(); + app.use('/static/admin/*', async (c) => c.text('Unauthorized', 401)); + if (adapter === 'node-server') { + const { serveStatic } = fromSdk('@hono/node-server/serve-static') as + typeof import('@hono/node-server/serve-static'); + app.use('/static/*', serveStatic({ root })); + } else { + const { serveStatic } = fromSdk('hono/serve-static') as + typeof import('hono/serve-static'); + app.use('/static/*', serveStatic({ root, getContent: async (path) => { + try { return await readFile(path); } catch { return null; } + } })); + } + + const publicFile = await app.request('http://localhost/static/public.txt'); + expect(publicFile.status).toBe(200); + expect(await publicFile.text()).toBe('public fixture'); + for (const path of ['/static/admin/secret.txt', '/static/%61dmin/secret.txt']) { + expect((await app.request(`http://localhost${path}`)).status).toBe(401); + } + // Upstream advisory regression: routing sees a different path from a + // second decode. Neither static implementation may disclose the file. + const bypass = await app.request('http://localhost/static/%%36%31dmin/secret.txt'); + expect(bypass.status).toBe(404); + expect(await bypass.text()).not.toContain('private fixture'); + expect((await app.request('http://localhost/static/public.txt', { method: 'POST' })).status).toBe(404); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, + ); + + it('normalizes encoded host letters consistently', () => { + expect(uri.parse('//%41.com').host).toBe('a.com'); + expect(uri.equal('//%41.com', '//a.com')).toBe(true); + }); + + it('rejects an authority that a second URL parser would silently rewrite', () => { + expect(uri.parse('http://a@b@evil.example/').error).toBeTruthy(); + }); + + it('does not turn an encoded scheme into an executable URI', () => { + expect(uri.parse('%6Aavascript:alert(1)').scheme).not.toBe('javascript'); + expect(uri.normalize('%6Aavascript:alert(1)')).not.toMatch(/^javascript:/i); + }); + + it('escapes the colon when serializing a suffix reference', () => { + for (const scheme of ['javascript', 'data', 'vbscript']) { + // Suffix output omits the input scheme, so the first emitted segment + // must remain a relative path even when the component had a scheme. + const suffix = uri.serialize({ scheme: 'https', path: `${scheme}:payload` }, { reference: 'suffix' }); + expect(suffix).toBe(`${scheme}%3Apayload`); + expect(uri.parse(suffix).scheme).toBeUndefined(); + } + // Suffix parsing treats a string as an authority. Invalid authority input + // is rejected; normalize is not an HTML/URL sanitizer for invalid input. + expect(uri.parse('javascript:alert(1)', { reference: 'suffix' }).error).toBeTruthy(); + }); + + it('preserves the resource reached by normalized relative references', () => { + for (const reference of ['../x', 'a/../b']) { + const base = 'https://example.invalid/dir/page'; + expect(uri.resolve(base, uri.normalize(reference))).toBe(uri.resolve(base, reference)); + } + }); + + it('accepts nested SDK object schemas with strict keyword types', () => { + expectTypeOf().not.toMatchTypeOf(); + expectTypeOf<{ type: number }>().not.toMatchTypeOf(); + const schema: JsonSchemaType = { + type: 'object', + properties: { + payload: { anyOf: [{ type: 'null' }, { type: 'array', items: { type: 'integer' } }] }, + mode: { enum: ['read', 'write'] }, + }, + required: ['payload', 'mode'], + additionalProperties: false, + }; + const { AjvJsonSchemaValidator } = fromSdk('@modelcontextprotocol/sdk/validation/ajv') as + typeof import('@modelcontextprotocol/sdk/validation/ajv'); + const validate = new AjvJsonSchemaValidator().getValidator(schema); + for (const payload of [null, [], [1, 2]]) { + expect(validate({ payload, mode: 'read' }).valid).toBe(true); + } + for (const invalid of [ + { payload: ['1'], mode: 'read' }, + { payload: [1], mode: 'unknown' }, + { mode: 'read' }, + { payload: null, mode: 'read', unexpected: true }, + ]) { + expect(validate(invalid).valid).toBe(false); + } + }); + + it('validates MCP tool schemas and local references using the installed AJV consumer', () => { + const { AjvJsonSchemaValidator } = fromSdk('@modelcontextprotocol/sdk/validation/ajv') as + typeof import('@modelcontextprotocol/sdk/validation/ajv'); + const validate = new AjvJsonSchemaValidator().getValidator({ + $id: 'https://example.invalid/schemas/tool.json', + $defs: { name: { type: 'string', minLength: 1 } }, + type: 'object', properties: { name: { $ref: '#/$defs/name' } }, + required: ['name'], additionalProperties: false, + }); + expect(validate({ name: 'example' }).valid).toBe(true); + expect(validate({ name: 7 }).valid).toBe(false); + expect(validate({ name: '' }).valid).toBe(false); + }); + + it('bounds malformed IPv6 rejection while retaining the rate-limit consumer behavior', () => { + const fromLimiter = createRequire(fromSdk.resolve('express-rate-limit')); + const result = spawnSync(process.execPath, ['-e', ` + const { Address6 } = require(${JSON.stringify(fromLimiter.resolve('ip-address'))}); + const { ipKeyGenerator } = require(${JSON.stringify(fromSdk.resolve('express-rate-limit'))}); + console.log(JSON.stringify({ + valid: Address6.isValid('2001:db8::1'), + key: ipKeyGenerator('2001:db8::1'), + malformed: Address6.isValid('%'.repeat(256 * 1024) + '\\n'), + })); + `], { encoding: 'utf8', timeout: 2000 }); + // Isolate the advisory's long input in a bounded process: the vulnerable + // synchronous regexp takes tens of seconds and must not stall the suite. + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ valid: true, key: '2001:db8::/56', malformed: false }); + }); + + it('completes a real MCP initialize and tool-call roundtrip through the Node adapter', async () => { + const { McpServer } = fromSdk('@modelcontextprotocol/sdk/server/mcp.js') as + typeof import('@modelcontextprotocol/sdk/server/mcp.js'); + const { StreamableHTTPServerTransport } = fromSdk('@modelcontextprotocol/sdk/server/streamableHttp.js') as + typeof import('@modelcontextprotocol/sdk/server/streamableHttp.js'); + const { Client } = fromSdk('@modelcontextprotocol/sdk/client/index.js') as + typeof import('@modelcontextprotocol/sdk/client/index.js'); + const { StreamableHTTPClientTransport } = fromSdk('@modelcontextprotocol/sdk/client/streamableHttp.js') as + typeof import('@modelcontextprotocol/sdk/client/streamableHttp.js'); + const mcp = new McpServer({ name: 'eventrelay-local-security-test', version: '1.0.0' }); + mcp.registerTool('ping', { description: 'Local consumer compatibility probe' }, async () => ({ + content: [{ type: 'text', text: 'pong' }], + })); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: randomUUID, enableJsonResponse: true }); + const client = new Client({ name: 'local-probe', version: '1.0.0' }); + const server = createServer((request, response) => { + void transport.handleRequest(request, response).catch(() => { + response.writeHead(500).end(); + }); + }); + try { + await mcp.connect(transport); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing local test port'); + await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${address.port}/mcp`))); + expect((await client.listTools()).tools.map((tool) => tool.name)).toEqual(['ping']); + expect((await client.callTool({ name: 'ping', arguments: {} })).content).toEqual([{ type: 'text', text: 'pong' }]); + } finally { + await client.close(); + await mcp.close(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + }, 10000); +}); diff --git a/tests/security/tsconfig.mcp.json b/tests/security/tsconfig.mcp.json new file mode 100644 index 000000000..2ade8799a --- /dev/null +++ b/tests/security/tsconfig.mcp.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2017", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "types": [ + "node" + ] + }, + "include": [ + "mcp-transitive-security.test.ts" + ] +} diff --git a/tests/security/vitest.mcp.config.mts b/tests/security/vitest.mcp.config.mts new file mode 100644 index 000000000..2a7bd6e6d --- /dev/null +++ b/tests/security/vitest.mcp.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +// These regressions exercise the root GenAI -> MCP dependency graph. The web +// image intentionally installs only apps/web, so this suite has its own CI step. +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/security/mcp-transitive-security.test.ts'], + }, +}); diff --git a/vercel.json b/vercel.json index d77369556..2433f637a 100644 --- a/vercel.json +++ b/vercel.json @@ -7,7 +7,10 @@ { "source": "/(.*)", "has": [ - { "type": "host", "value": "event-relay-web.vercel.app" } + { + "type": "host", + "value": "event-relay-web.vercel.app" + } ], "destination": "https://uvai.io/$1", "permanent": true @@ -15,10 +18,18 @@ { "source": "/(.*)", "has": [ - { "type": "host", "value": "v0-uvai.vercel.app" } + { + "type": "host", + "value": "v0-uvai.vercel.app" + } ], "destination": "https://uvai.io/$1", "permanent": true } - ] + ], + "git": { + "deploymentEnabled": { + "dependabot/npm_and_yarn/apps/web/opentelemetry/sdk-trace-base-2.12.0": false + } + } }