@lowdefy/server 6.0.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/App.jsx +41 -21
- package/client/Page.jsx +101 -24
- package/client/clearReloadedForTypes.js +29 -0
- package/client/clientBuildImports.test.mjs +60 -0
- package/client/getShownPage.js +28 -0
- package/client/getShownPage.test.mjs +52 -0
- package/client/loadAllIcons.js +45 -0
- package/client/loadPageTypes.js +44 -0
- package/client/main.jsx +22 -1
- package/client/reloadedForTypesKey.js +21 -0
- package/client/shouldReloadForTypes.js +38 -0
- package/client/shouldReloadForTypes.test.mjs +63 -0
- package/client/types.js +28 -0
- package/lib/build/security.js +26 -0
- package/lib/client/auth/Auth.jsx +11 -6
- package/lib/client/auth/AuthConfigured.jsx +224 -39
- package/lib/client/auth/AuthNotConfigured.js +44 -4
- package/lib/client/auth/createFetchCheckingBuild.js +45 -0
- package/lib/client/auth/createFetchCheckingBuild.test.mjs +100 -0
- package/lib/client/bundleBuildId.js +23 -0
- package/lib/client/createLogUsage.js +46 -25
- package/lib/client/createLogUsage.test.mjs +90 -0
- package/lib/client/getCspNonce.js +25 -0
- package/lib/server/auth/createSystemContext.js +70 -0
- package/lib/server/auth/{getAuthConfig.js → getAuth.js} +15 -7
- package/lib/server/auth/getStrategies.js +40 -0
- package/lib/server/getClientAddress.js +56 -0
- package/lib/server/getClientAddress.test.mjs +108 -0
- package/lib/server/log/createHandleError.js +16 -4
- package/lib/server/log/createHandleError.test.mjs +102 -0
- package/lib/server/log/createLogger.js +10 -0
- package/lib/server/log/createLogger.test.mjs +117 -0
- package/lib/server/log/logRequest.js +12 -4
- package/lib/server/log/maskCredentialKeys.js +37 -0
- package/lib/server/log/projectErrorForLog.js +61 -0
- package/lib/server/log/redactUrlQuery.js +75 -0
- package/lib/server/log/redactUrlQuery.test.mjs +73 -0
- package/lib/server/{auth/session.js → log/serializeErrorForLog.js} +5 -11
- package/lib/server/log/serializeErrorForLog.test.mjs +337 -0
- package/lib/server/scrubSecrets.js +21 -0
- package/lib/server/sentry/captureSentryError.js +29 -3
- package/lib/server/sentry/captureSentryError.test.mjs +128 -0
- package/lib/server/sentry/initSentry.js +46 -14
- package/lib/server/sentry/initSentry.serverless.test.mjs +165 -0
- package/lib/server/sentry/initSentry.test.mjs +365 -0
- package/lib/server/sentry/scrubEvent.js +40 -0
- package/lib/server/sentry/scrubEvent.test.mjs +88 -0
- package/lib/server/sentry/sentrySdk.js +23 -0
- package/lib/server/sentry/setSentryUser.js +3 -3
- package/lowdefy/build.mjs +1 -0
- package/lowdefy/build.test.mjs +68 -0
- package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
- package/package.json +47 -42
- package/package.original.json +47 -42
- package/src/app.js +74 -17
- package/src/app.test.mjs +118 -0
- package/src/html/collectChunkClosure.js +31 -0
- package/src/html/collectLazyImports.js +41 -0
- package/src/html/collectPageTypesAssets.js +57 -0
- package/src/html/collectPageTypesAssets.test.mjs +130 -0
- package/src/html/getAssets.js +20 -1
- package/src/html/getHomePath.js +29 -0
- package/src/html/getPageAssets.js +32 -0
- package/src/html/getPageAssets.test.mjs +47 -0
- package/src/html/renderPage.contentSecurityPolicy.test.mjs +134 -0
- package/src/html/renderPage.js +80 -19
- package/src/html/renderPage.test.mjs +189 -0
- package/src/html/template.js +50 -15
- package/src/html/template.test.mjs +126 -0
- package/src/index.js +28 -19
- package/src/initServer.js +61 -0
- package/src/initServer.test.mjs +116 -0
- package/src/lib/getRequestPath.js +29 -0
- package/src/middleware/apiContext.js +68 -13
- package/src/middleware/apiContext.test.mjs +178 -0
- package/src/middleware/createSameOriginGuard.js +36 -0
- package/src/middleware/createSameOriginGuard.test.mjs +102 -0
- package/src/middleware/errorHandler.js +56 -4
- package/src/middleware/errorHandler.test.mjs +440 -0
- package/src/middleware/noStore.js +27 -0
- package/src/middleware/refuseOtherBuild.js +49 -0
- package/src/middleware/refuseOtherBuild.test.mjs +63 -0
- package/src/middleware/requestTimeout.js +38 -0
- package/src/middleware/sentry.js +13 -5
- package/src/middleware/stampBuildId.js +30 -0
- package/src/middleware/stampBuildId.test.mjs +41 -0
- package/src/middleware/stampCaller.js +38 -0
- package/src/middleware/stampCaller.test.mjs +64 -0
- package/src/routes/agent.js +15 -3
- package/src/routes/apiPage.js +54 -9
- package/src/routes/apiPage.test.mjs +143 -0
- package/src/routes/auth.js +15 -9
- package/src/routes/auth.test.mjs +85 -0
- package/src/routes/clientError.js +11 -13
- package/src/routes/cron.js +3 -1
- package/src/routes/cronForward.js +4 -2
- package/src/routes/detached.js +22 -9
- package/src/routes/endpoints.js +27 -26
- package/src/routes/endpointsTextAnswer.test.mjs +77 -0
- package/src/routes/mcp.js +82 -5
- package/src/routes/mcp.test.mjs +215 -0
- package/src/routes/mcpMemberToken.test.mjs +125 -0
- package/src/routes/mcpProtectedResourceMetadata.js +34 -0
- package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
- package/src/routes/mountOauthDiscovery.js +44 -0
- package/src/routes/mountOauthDiscovery.test.mjs +79 -0
- package/src/routes/mountPageRoutes.js +29 -0
- package/src/routes/mountPageRoutes.test.mjs +56 -0
- package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
- package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
- package/src/routes/request.js +5 -3
- package/src/routes/requestBody.test.mjs +146 -0
- package/src/routes/usage.js +6 -2
- package/src/routes/user.js +27 -0
- package/src/routes/websocket.js +26 -3
- package/src/routes/websocket.test.mjs +106 -0
- package/src/routes/wellKnownFallback.js +30 -0
- package/vite.config.js +55 -2
- package/lib/server/apiWrapper.js +0 -87
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import http from 'node:http';
|
|
18
|
+
|
|
19
|
+
import { jest } from '@jest/globals';
|
|
20
|
+
import * as RealLight from '@sentry/node-core/light';
|
|
21
|
+
|
|
22
|
+
const envelopes = [];
|
|
23
|
+
function createStubTransport() {
|
|
24
|
+
return {
|
|
25
|
+
send: async (envelope) => {
|
|
26
|
+
envelopes.push(envelope);
|
|
27
|
+
return {};
|
|
28
|
+
},
|
|
29
|
+
flush: async () => true,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const imported = { node: false, light: false };
|
|
34
|
+
const nodeInit = jest.fn();
|
|
35
|
+
const nodeHttpIntegration = jest.fn(() => ({ name: 'Http' }));
|
|
36
|
+
|
|
37
|
+
// @sentry/node is a stub that records its import, so a test can tell which SDK started. The light
|
|
38
|
+
// SDK is real, with its network transport swapped for memory.
|
|
39
|
+
jest.unstable_mockModule('@sentry/node', () => {
|
|
40
|
+
imported.node = true;
|
|
41
|
+
return { init: nodeInit, httpIntegration: nodeHttpIntegration };
|
|
42
|
+
});
|
|
43
|
+
jest.unstable_mockModule('@sentry/node-core/light', () => {
|
|
44
|
+
imported.light = true;
|
|
45
|
+
return {
|
|
46
|
+
...RealLight,
|
|
47
|
+
init: (options) => RealLight.init({ ...options, transport: createStubTransport }),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
const loggerConfig = { sentry: {} };
|
|
51
|
+
jest.unstable_mockModule('../../build/logger.js', () => ({ default: loggerConfig }));
|
|
52
|
+
|
|
53
|
+
const { default: initSentryServer } = await import('./initSentry.js');
|
|
54
|
+
const { default: sentrySdk } = await import('./sentrySdk.js');
|
|
55
|
+
const { default: captureSentryError } = await import('./captureSentryError.js');
|
|
56
|
+
const { default: setSentryUser } = await import('./setSentryUser.js');
|
|
57
|
+
|
|
58
|
+
function sentEvents() {
|
|
59
|
+
return envelopes.flatMap(([, items]) =>
|
|
60
|
+
items.filter(([header]) => header.type === 'event').map(([, payload]) => payload)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
envelopes.length = 0;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterAll(async () => {
|
|
69
|
+
await RealLight.close();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('initSentry imports no Sentry SDK and returns null when SENTRY_DSN is not set', async () => {
|
|
73
|
+
delete process.env.SENTRY_DSN;
|
|
74
|
+
expect(await initSentryServer({ serverless: true })).toBe(null);
|
|
75
|
+
expect(imported).toEqual({ node: false, light: false });
|
|
76
|
+
expect(sentrySdk.current).toBe(null);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('initSentry on a serverless function reports errors with the light SDK, without tracing or client reports', async () => {
|
|
80
|
+
process.env.SENTRY_DSN = 'https://public@o0.ingest.sentry.io/1';
|
|
81
|
+
const sentry = await initSentryServer({ serverless: true });
|
|
82
|
+
expect(imported).toEqual({ node: false, light: true });
|
|
83
|
+
expect(sentrySdk.current).toBe(sentry);
|
|
84
|
+
const client = RealLight.getClient();
|
|
85
|
+
expect(client).toBeInstanceOf(RealLight.LightNodeClient);
|
|
86
|
+
const options = client.getOptions();
|
|
87
|
+
expect(options.tracesSampleRate).toBeUndefined();
|
|
88
|
+
expect(options.sendClientReports).toBe(false);
|
|
89
|
+
expect(options.integrations.filter(({ name }) => name === 'Http')).toHaveLength(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// A request handler that, like the Vercel function entry, reads the body with for await, sets the
|
|
93
|
+
// caller from a header and, when asked, waits and then captures an error, so a second request
|
|
94
|
+
// sets its own user while the first is still running.
|
|
95
|
+
async function handleRequest(req, res) {
|
|
96
|
+
const chunks = [];
|
|
97
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
98
|
+
setSentryUser({ user: { id: req.headers['x-user'] } });
|
|
99
|
+
if (req.headers['x-fail'] === 'true') {
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
101
|
+
captureSentryError({ error: new Error('Endpoint failed.'), context: { rid: 'req-1' } });
|
|
102
|
+
}
|
|
103
|
+
res.end('{}');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function startServer() {
|
|
107
|
+
const server = http.createServer((req, res) => {
|
|
108
|
+
handleRequest(req, res);
|
|
109
|
+
});
|
|
110
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
111
|
+
return server;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function post({ port, user, fail }) {
|
|
115
|
+
return fetch(`http://127.0.0.1:${port}/api/endpoints/sign-in`, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'content-type': 'application/json', 'x-user': user, 'x-fail': String(fail) },
|
|
118
|
+
body: JSON.stringify({ email: 'user@example.com', password: 'planted-user-password' }),
|
|
119
|
+
}).then((response) => response.text());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
test('a serverless request that captures nothing leaves nothing for the flush to send', async () => {
|
|
123
|
+
const server = await startServer();
|
|
124
|
+
try {
|
|
125
|
+
await post({ port: server.address().port, user: 'user-a', fail: false });
|
|
126
|
+
} finally {
|
|
127
|
+
await new Promise((resolve) => server.close(resolve));
|
|
128
|
+
}
|
|
129
|
+
expect(await sentrySdk.current.flush(2000)).toBe(true);
|
|
130
|
+
expect(envelopes).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a serverless request that captures an error sends it on flush, with its own user and no request body', async () => {
|
|
134
|
+
const server = await startServer();
|
|
135
|
+
const { port } = server.address();
|
|
136
|
+
try {
|
|
137
|
+
const failing = post({ port, user: 'user-a', fail: true });
|
|
138
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
139
|
+
await post({ port, user: 'user-b', fail: false });
|
|
140
|
+
await failing;
|
|
141
|
+
} finally {
|
|
142
|
+
await new Promise((resolve) => server.close(resolve));
|
|
143
|
+
}
|
|
144
|
+
await sentrySdk.current.flush(2000);
|
|
145
|
+
const events = sentEvents();
|
|
146
|
+
expect(events).toHaveLength(1);
|
|
147
|
+
expect(events[0].exception.values[0].value).toEqual('Endpoint failed.');
|
|
148
|
+
expect(events[0].user).toEqual({ id: 'user-a' });
|
|
149
|
+
expect(events[0].request.url).toMatch(/\/api\/endpoints\/sign-in$/);
|
|
150
|
+
expect(events[0].request.data).toBeUndefined();
|
|
151
|
+
expect(JSON.stringify(events[0])).not.toContain('planted-user-password');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('initSentry on a serverless function with serverTracing runs @sentry/node with tracing and no client reports', async () => {
|
|
155
|
+
loggerConfig.sentry = { serverTracing: true, tracesSampleRate: 0.2 };
|
|
156
|
+
const sentry = await initSentryServer({ serverless: true });
|
|
157
|
+
expect(imported.node).toBe(true);
|
|
158
|
+
expect(sentrySdk.current).toBe(sentry);
|
|
159
|
+
expect(nodeHttpIntegration.mock.calls).toEqual([[{ maxIncomingRequestBodySize: 'none' }]]);
|
|
160
|
+
expect(nodeInit).toHaveBeenCalledTimes(1);
|
|
161
|
+
const [options] = nodeInit.mock.calls[0];
|
|
162
|
+
expect(options.tracesSampleRate).toBe(0.2);
|
|
163
|
+
expect(options.sendClientReports).toBe(false);
|
|
164
|
+
expect(options.integrations).toEqual([{ name: 'Http' }]);
|
|
165
|
+
});
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import http from 'node:http';
|
|
18
|
+
|
|
19
|
+
import { jest } from '@jest/globals';
|
|
20
|
+
import { Hono } from 'hono';
|
|
21
|
+
import { serve } from '@hono/node-server';
|
|
22
|
+
import * as RealSentry from '@sentry/node';
|
|
23
|
+
import { markCredential, runInCredentialScope } from '@lowdefy/node-utils';
|
|
24
|
+
|
|
25
|
+
const secret = 'planted/secret+value=1';
|
|
26
|
+
|
|
27
|
+
const envelopes = [];
|
|
28
|
+
function createStubTransport() {
|
|
29
|
+
return {
|
|
30
|
+
send: async (envelope) => {
|
|
31
|
+
envelopes.push(envelope);
|
|
32
|
+
return {};
|
|
33
|
+
},
|
|
34
|
+
flush: async () => true,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const httpIntegrations = [];
|
|
39
|
+
|
|
40
|
+
// The real SDK with its network transport swapped for memory, so each test reads the exact
|
|
41
|
+
// event Sentry would send, after the hooks initSentry installs.
|
|
42
|
+
jest.unstable_mockModule('@sentry/node', () => ({
|
|
43
|
+
...RealSentry,
|
|
44
|
+
init: (options) => RealSentry.init({ ...options, transport: createStubTransport }),
|
|
45
|
+
httpIntegration: (options) => {
|
|
46
|
+
const integration = RealSentry.httpIntegration(options);
|
|
47
|
+
httpIntegrations.push(integration);
|
|
48
|
+
return integration;
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
jest.unstable_mockModule('../../build/logger.js', () => ({
|
|
52
|
+
default: { sentry: { tracesSampleRate: 1 } },
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
process.env.SENTRY_DSN = 'https://public@o0.ingest.sentry.io/1';
|
|
56
|
+
process.env.LOWDEFY_SECRET_API_KEY = secret;
|
|
57
|
+
|
|
58
|
+
const { default: initSentryServer } = await import('./initSentry.js');
|
|
59
|
+
const { default: sentrySdk } = await import('./sentrySdk.js');
|
|
60
|
+
const { default: sentryMiddleware } = await import('../../../src/middleware/sentry.js');
|
|
61
|
+
|
|
62
|
+
const sentry = await initSentryServer();
|
|
63
|
+
const client = RealSentry.getClient();
|
|
64
|
+
const options = client.getOptions();
|
|
65
|
+
|
|
66
|
+
function sentEvents() {
|
|
67
|
+
return envelopes.flatMap(([, items]) =>
|
|
68
|
+
items.filter(([header]) => header.type === 'event').map(([, payload]) => payload)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sentTransactions() {
|
|
73
|
+
return envelopes.flatMap(([, items]) =>
|
|
74
|
+
items.filter(([header]) => header.type === 'transaction').map(([, payload]) => payload)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function listen(server) {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
if (server.listening) {
|
|
81
|
+
resolve(server.address().port);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
server.once('listening', () => resolve(server.address().port));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
envelopes.length = 0;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterAll(async () => {
|
|
93
|
+
await RealSentry.close();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('initSentry initializes Sentry when SENTRY_DSN is set and makes its SDK the server one', () => {
|
|
97
|
+
expect(client).toBeDefined();
|
|
98
|
+
expect(sentry.captureException).toBe(RealSentry.captureException);
|
|
99
|
+
expect(sentrySdk.current).toBe(sentry);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('initSentry traces requests and keeps client reports on a long-running server', () => {
|
|
103
|
+
expect(options.tracesSampleRate).toBe(1);
|
|
104
|
+
expect(options.sendClientReports).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('initSentry installs exactly one Http integration, the one that attaches no request bodies', () => {
|
|
108
|
+
expect(httpIntegrations).toHaveLength(1);
|
|
109
|
+
const installedHttp = options.integrations.filter(({ name }) => name === 'Http');
|
|
110
|
+
expect(installedHttp).toHaveLength(1);
|
|
111
|
+
expect(installedHttp[0]).toBe(httpIntegrations[0]);
|
|
112
|
+
expect(client.getIntegrationByName('Http')).toBe(httpIntegrations[0]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('beforeSend replaces a planted secret in an exception value, a cause-chain exception value and extra', () => {
|
|
116
|
+
const event = {
|
|
117
|
+
event_id: 'a'.repeat(32),
|
|
118
|
+
level: 'error',
|
|
119
|
+
exception: {
|
|
120
|
+
values: [
|
|
121
|
+
{
|
|
122
|
+
type: 'AxiosError',
|
|
123
|
+
value: `connect failed for key ${secret}`,
|
|
124
|
+
mechanism: { type: 'chained', source: 'cause', exception_id: 1, parent_id: 0 },
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
type: 'RequestError',
|
|
128
|
+
value: `Request failed: ${encodeURIComponent(secret)}`,
|
|
129
|
+
stacktrace: { frames: [{ filename: 'app.js', function: 'handler', lineno: 12 }] },
|
|
130
|
+
mechanism: { type: 'generic', handled: true, exception_id: 0 },
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
extra: {
|
|
135
|
+
configKey: 'key-1',
|
|
136
|
+
error: { name: 'RequestError', message: `failed with ${secret}`, received: [secret] },
|
|
137
|
+
},
|
|
138
|
+
tags: { requestId: 'req-1' },
|
|
139
|
+
};
|
|
140
|
+
const scrubbed = options.beforeSend(event, {});
|
|
141
|
+
expect(JSON.stringify(scrubbed)).not.toContain(secret);
|
|
142
|
+
expect(JSON.stringify(scrubbed)).not.toContain(encodeURIComponent(secret));
|
|
143
|
+
expect(scrubbed.exception.values[0].value).toEqual('connect failed for key [REDACTED]');
|
|
144
|
+
expect(scrubbed.exception.values[1].value).toEqual('Request failed: [REDACTED]');
|
|
145
|
+
expect(scrubbed.exception.values[1].stacktrace).toEqual(event.exception.values[1].stacktrace);
|
|
146
|
+
expect(scrubbed.extra).toEqual({
|
|
147
|
+
configKey: 'key-1',
|
|
148
|
+
error: { name: 'RequestError', message: 'failed with [REDACTED]', received: ['[REDACTED]'] },
|
|
149
|
+
});
|
|
150
|
+
expect(scrubbed.tags).toEqual({ requestId: 'req-1' });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('beforeSend scrubs an exception captured through the SDK, cause chain included', async () => {
|
|
154
|
+
const cause = new Error(`upstream rejected ${secret}`);
|
|
155
|
+
RealSentry.captureException(new Error('Request failed.', { cause }), {
|
|
156
|
+
extra: { detail: `sent ${secret}` },
|
|
157
|
+
});
|
|
158
|
+
await RealSentry.flush();
|
|
159
|
+
const events = sentEvents();
|
|
160
|
+
expect(events).toHaveLength(1);
|
|
161
|
+
expect(JSON.stringify(events[0])).not.toContain(secret);
|
|
162
|
+
const values = events[0].exception.values.map(({ value }) => value);
|
|
163
|
+
expect(values).toEqual(['upstream rejected [REDACTED]', 'Request failed.']);
|
|
164
|
+
expect(events[0].extra.detail).toEqual('sent [REDACTED]');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('beforeBreadcrumb replaces a planted secret in a breadcrumb URL', () => {
|
|
168
|
+
const breadcrumb = {
|
|
169
|
+
type: 'http',
|
|
170
|
+
category: 'http',
|
|
171
|
+
level: 'info',
|
|
172
|
+
data: {
|
|
173
|
+
url: `https://api.example.com/v1/items?api_key=${secret}`,
|
|
174
|
+
'http.method': 'GET',
|
|
175
|
+
status_code: 200,
|
|
176
|
+
},
|
|
177
|
+
timestamp: 1700000000,
|
|
178
|
+
};
|
|
179
|
+
expect(options.beforeBreadcrumb(breadcrumb)).toEqual({
|
|
180
|
+
...breadcrumb,
|
|
181
|
+
data: {
|
|
182
|
+
url: 'https://api.example.com/v1/items?api_key=[REDACTED]',
|
|
183
|
+
'http.method': 'GET',
|
|
184
|
+
status_code: 200,
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
function createFetchSpan() {
|
|
190
|
+
return {
|
|
191
|
+
span_id: 'b'.repeat(16),
|
|
192
|
+
trace_id: 'c'.repeat(32),
|
|
193
|
+
parent_span_id: 'd'.repeat(16),
|
|
194
|
+
op: 'http.client',
|
|
195
|
+
description: `GET https://api.example.com/v1/items?api_key=${secret}`,
|
|
196
|
+
origin: 'auto.http.otel.node_fetch',
|
|
197
|
+
start_timestamp: 1700000000,
|
|
198
|
+
timestamp: 1700000001,
|
|
199
|
+
status: 'ok',
|
|
200
|
+
data: {
|
|
201
|
+
'sentry.op': 'http.client',
|
|
202
|
+
'http.method': 'GET',
|
|
203
|
+
url: `https://api.example.com/v1/items?api_key=${secret}`,
|
|
204
|
+
'http.url': `https://api.example.com/v1/items?api_key=${secret}`,
|
|
205
|
+
'http.query': `?api_key=${secret}`,
|
|
206
|
+
'http.response.status_code': 200,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
test('beforeSendSpan replaces a planted secret in a fetch span http.url and http.query', () => {
|
|
212
|
+
const scrubbed = options.beforeSendSpan(createFetchSpan());
|
|
213
|
+
expect(JSON.stringify(scrubbed)).not.toContain(secret);
|
|
214
|
+
expect(scrubbed.data['http.url']).toEqual('https://api.example.com/v1/items?api_key=[REDACTED]');
|
|
215
|
+
expect(scrubbed.data['http.query']).toEqual('?api_key=[REDACTED]');
|
|
216
|
+
expect(scrubbed.data['http.response.status_code']).toEqual(200);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('beforeSendTransaction replaces a planted secret in a fetch span http.url and http.query', () => {
|
|
220
|
+
const transaction = {
|
|
221
|
+
type: 'transaction',
|
|
222
|
+
transaction: 'POST /api/endpoints/fetch-items',
|
|
223
|
+
start_timestamp: 1700000000,
|
|
224
|
+
timestamp: 1700000002,
|
|
225
|
+
contexts: {
|
|
226
|
+
trace: { span_id: 'd'.repeat(16), trace_id: 'c'.repeat(32), op: 'http.server' },
|
|
227
|
+
},
|
|
228
|
+
spans: [createFetchSpan()],
|
|
229
|
+
};
|
|
230
|
+
const scrubbed = options.beforeSendTransaction(transaction, {});
|
|
231
|
+
expect(JSON.stringify(scrubbed)).not.toContain(secret);
|
|
232
|
+
expect(scrubbed.spans[0].data['http.url']).toEqual(
|
|
233
|
+
'https://api.example.com/v1/items?api_key=[REDACTED]'
|
|
234
|
+
);
|
|
235
|
+
expect(scrubbed.spans[0].data['http.query']).toEqual('?api_key=[REDACTED]');
|
|
236
|
+
expect(scrubbed.contexts).toEqual(transaction.contexts);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('an error event captured during a request with a JSON body carries no request data', async () => {
|
|
240
|
+
const server = http.createServer((req, res) => {
|
|
241
|
+
const chunks = [];
|
|
242
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
243
|
+
req.on('end', () => {
|
|
244
|
+
RealSentry.captureException(new Error('Endpoint failed.'));
|
|
245
|
+
res.end('{}');
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
249
|
+
const { port } = server.address();
|
|
250
|
+
try {
|
|
251
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/endpoints/sign-in`, {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
headers: { 'content-type': 'application/json' },
|
|
254
|
+
body: JSON.stringify({ email: 'user@example.com', password: 'planted-user-password' }),
|
|
255
|
+
});
|
|
256
|
+
await response.text();
|
|
257
|
+
} finally {
|
|
258
|
+
await new Promise((resolve) => server.close(resolve));
|
|
259
|
+
}
|
|
260
|
+
await RealSentry.flush();
|
|
261
|
+
const events = sentEvents();
|
|
262
|
+
expect(events).toHaveLength(1);
|
|
263
|
+
expect(events[0].request.url).toMatch(/\/api\/endpoints\/sign-in$/);
|
|
264
|
+
expect(events[0].request.data).toBeUndefined();
|
|
265
|
+
expect(JSON.stringify(events[0])).not.toContain('planted-user-password');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('a transaction sent after the response is scrubbed of the credentials its request marked', async () => {
|
|
269
|
+
const markedKey = 'runtime-made-app-key-0001';
|
|
270
|
+
const upstream = http.createServer((req, res) => res.end('{}'));
|
|
271
|
+
upstream.listen(0, '127.0.0.1');
|
|
272
|
+
const upstreamPort = await listen(upstream);
|
|
273
|
+
|
|
274
|
+
// The two middlewares the production app runs first, in its order.
|
|
275
|
+
const app = new Hono();
|
|
276
|
+
app.use('*', (c, next) => runInCredentialScope(next));
|
|
277
|
+
app.use('*', sentryMiddleware());
|
|
278
|
+
app.post('/api/endpoints/create-app', async (c) => {
|
|
279
|
+
markCredential(markedKey);
|
|
280
|
+
const upstreamResponse = await fetch(
|
|
281
|
+
`http://127.0.0.1:${upstreamPort}/v1/apps?key=${markedKey}`
|
|
282
|
+
);
|
|
283
|
+
await upstreamResponse.text();
|
|
284
|
+
return c.json({ ok: true });
|
|
285
|
+
});
|
|
286
|
+
// @hono/node-server writes the response after the middleware chain has returned, so the
|
|
287
|
+
// request's root span ends, and its transaction is sent, outside the credential scope.
|
|
288
|
+
const server = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' });
|
|
289
|
+
const port = await listen(server);
|
|
290
|
+
try {
|
|
291
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/endpoints/create-app`, {
|
|
292
|
+
method: 'POST',
|
|
293
|
+
});
|
|
294
|
+
await response.text();
|
|
295
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
296
|
+
} finally {
|
|
297
|
+
await new Promise((resolve) => server.close(resolve));
|
|
298
|
+
await new Promise((resolve) => upstream.close(resolve));
|
|
299
|
+
}
|
|
300
|
+
await RealSentry.flush();
|
|
301
|
+
const transaction = sentTransactions().find(
|
|
302
|
+
({ transaction: name }) => name === 'POST /api/endpoints/create-app'
|
|
303
|
+
);
|
|
304
|
+
expect(transaction).toBeDefined();
|
|
305
|
+
const clientSpan = transaction.spans.find(({ op }) => op === 'http.client');
|
|
306
|
+
expect(clientSpan.data['url.full']).toEqual(
|
|
307
|
+
`http://127.0.0.1:${upstreamPort}/v1/apps?key=[REDACTED]`
|
|
308
|
+
);
|
|
309
|
+
expect(clientSpan.data['http.query']).toEqual('key=[REDACTED]');
|
|
310
|
+
expect(JSON.stringify(transaction)).not.toContain(markedKey);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('concurrent requests each scrub their transaction with their own marked credentials', async () => {
|
|
314
|
+
const keyA = 'runtime-made-app-key-aaaa';
|
|
315
|
+
const keyB = 'runtime-made-app-key-bbbb';
|
|
316
|
+
const upstream = http.createServer((req, res) => res.end('{}'));
|
|
317
|
+
upstream.listen(0, '127.0.0.1');
|
|
318
|
+
const upstreamPort = await listen(upstream);
|
|
319
|
+
|
|
320
|
+
let markedB;
|
|
321
|
+
const bMarked = new Promise((resolve) => {
|
|
322
|
+
markedB = resolve;
|
|
323
|
+
});
|
|
324
|
+
const app = new Hono();
|
|
325
|
+
app.use('*', (c, next) => runInCredentialScope(next));
|
|
326
|
+
app.use('*', sentryMiddleware());
|
|
327
|
+
// The first request's middleware runs before the second's, and its fetch after the second
|
|
328
|
+
// has marked its own key, so a scrub shared between them would miss the first key.
|
|
329
|
+
app.post('/api/endpoints/first', async (c) => {
|
|
330
|
+
markCredential(keyA);
|
|
331
|
+
await bMarked;
|
|
332
|
+
await (await fetch(`http://127.0.0.1:${upstreamPort}/v1/first?key=${keyA}`)).text();
|
|
333
|
+
return c.json({ ok: true });
|
|
334
|
+
});
|
|
335
|
+
app.post('/api/endpoints/second', async (c) => {
|
|
336
|
+
markCredential(keyB);
|
|
337
|
+
markedB();
|
|
338
|
+
await (await fetch(`http://127.0.0.1:${upstreamPort}/v1/second?key=${keyB}`)).text();
|
|
339
|
+
return c.json({ ok: true });
|
|
340
|
+
});
|
|
341
|
+
const server = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' });
|
|
342
|
+
const port = await listen(server);
|
|
343
|
+
try {
|
|
344
|
+
const first = fetch(`http://127.0.0.1:${port}/api/endpoints/first`, { method: 'POST' });
|
|
345
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
346
|
+
const second = fetch(`http://127.0.0.1:${port}/api/endpoints/second`, { method: 'POST' });
|
|
347
|
+
await Promise.all([(await first).text(), (await second).text()]);
|
|
348
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
349
|
+
} finally {
|
|
350
|
+
await new Promise((resolve) => server.close(resolve));
|
|
351
|
+
await new Promise((resolve) => upstream.close(resolve));
|
|
352
|
+
}
|
|
353
|
+
await RealSentry.flush();
|
|
354
|
+
const transactions = sentTransactions();
|
|
355
|
+
const firstTransaction = transactions.find(
|
|
356
|
+
({ transaction: name }) => name === 'POST /api/endpoints/first'
|
|
357
|
+
);
|
|
358
|
+
const secondTransaction = transactions.find(
|
|
359
|
+
({ transaction: name }) => name === 'POST /api/endpoints/second'
|
|
360
|
+
);
|
|
361
|
+
expect(JSON.stringify(firstTransaction)).toContain('/v1/first?key=[REDACTED]');
|
|
362
|
+
expect(JSON.stringify(secondTransaction)).toContain('/v1/second?key=[REDACTED]');
|
|
363
|
+
expect(JSON.stringify([firstTransaction, secondTransaction])).not.toContain(keyA);
|
|
364
|
+
expect(JSON.stringify([firstTransaction, secondTransaction])).not.toContain(keyB);
|
|
365
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mapPlainValues, type } from '@lowdefy/helpers';
|
|
18
|
+
|
|
19
|
+
import scrubSecrets from '../scrubSecrets.js';
|
|
20
|
+
|
|
21
|
+
function returnUnchanged(value) {
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Class instances are left as they are: Sentry normalizes those before beforeSend
|
|
26
|
+
// runs, so the event hook still reaches their strings. Breadcrumbs reach
|
|
27
|
+
// beforeBreadcrumb un-normalized - a console breadcrumb holds the logged arguments
|
|
28
|
+
// as they are - so the walk must be cycle-safe, which mapPlainValues is.
|
|
29
|
+
// An event also carries its request's credential scrub (the Sentry middleware puts it on the
|
|
30
|
+
// request's isolation scope), which reaches the credentials that request marked when the event
|
|
31
|
+
// is sent after the request's scope has ended, as a transaction is.
|
|
32
|
+
function scrubEvent(value) {
|
|
33
|
+
const scrubRequestCredentials =
|
|
34
|
+
value?.sdkProcessingMetadata?.scrubRequestCredentials ?? returnUnchanged;
|
|
35
|
+
return mapPlainValues(value, (item) =>
|
|
36
|
+
type.isString(item) ? scrubRequestCredentials(scrubSecrets(item)) : item
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default scrubEvent;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const secret = 'planted/secret+value=1';
|
|
18
|
+
const shortSecret = 'short77';
|
|
19
|
+
|
|
20
|
+
process.env.LOWDEFY_SECRET_API_KEY = secret;
|
|
21
|
+
process.env.LOWDEFY_SECRET_SHORT = shortSecret;
|
|
22
|
+
|
|
23
|
+
const { default: scrubEvent } = await import('./scrubEvent.js');
|
|
24
|
+
|
|
25
|
+
test('scrubEvent replaces a planted secret in strings at every depth of objects and arrays', () => {
|
|
26
|
+
expect(
|
|
27
|
+
scrubEvent({
|
|
28
|
+
message: `failed ${secret}`,
|
|
29
|
+
nested: { list: [`a ${secret}`, { deeper: [[secret]] }] },
|
|
30
|
+
})
|
|
31
|
+
).toEqual({
|
|
32
|
+
message: 'failed [REDACTED]',
|
|
33
|
+
nested: { list: ['a [REDACTED]', { deeper: [['[REDACTED]']] }] },
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('scrubEvent leaves keys as they are, even a key holding a secret', () => {
|
|
38
|
+
expect(scrubEvent({ [secret]: 'value' })).toEqual({ [secret]: 'value' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('scrubEvent returns a copy and does not mutate the value it scrubs', () => {
|
|
42
|
+
const event = { extra: { detail: secret }, values: [secret] };
|
|
43
|
+
const scrubbed = scrubEvent(event);
|
|
44
|
+
expect(scrubbed).not.toBe(event);
|
|
45
|
+
expect(scrubbed.extra).not.toBe(event.extra);
|
|
46
|
+
expect(event).toEqual({ extra: { detail: secret }, values: [secret] });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('scrubEvent returns numbers, booleans, null and undefined unchanged', () => {
|
|
50
|
+
expect(scrubEvent({ a: 1, b: true, c: null, d: undefined })).toEqual({
|
|
51
|
+
a: 1,
|
|
52
|
+
b: true,
|
|
53
|
+
c: null,
|
|
54
|
+
d: undefined,
|
|
55
|
+
});
|
|
56
|
+
expect(scrubEvent(null)).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('scrubEvent scrubs a top-level string', () => {
|
|
60
|
+
expect(scrubEvent(`value ${secret}`)).toEqual('value [REDACTED]');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('scrubEvent leaves a secret shorter than 8 characters alone', () => {
|
|
64
|
+
expect(scrubEvent({ detail: `value ${shortSecret}` })).toEqual({
|
|
65
|
+
detail: `value ${shortSecret}`,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('scrubEvent returns class instances unchanged', () => {
|
|
70
|
+
class Client {
|
|
71
|
+
constructor() {
|
|
72
|
+
this.detail = secret;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const client = new Client();
|
|
76
|
+
const date = new Date(0);
|
|
77
|
+
const scrubbed = scrubEvent({ client, date });
|
|
78
|
+
expect(scrubbed.client).toBe(client);
|
|
79
|
+
expect(scrubbed.date).toBe(date);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('scrubEvent copies a cycle as a cycle', () => {
|
|
83
|
+
const data = { message: secret };
|
|
84
|
+
data.self = data;
|
|
85
|
+
const scrubbed = scrubEvent({ data });
|
|
86
|
+
expect(scrubbed.data.message).toEqual('[REDACTED]');
|
|
87
|
+
expect(scrubbed.data.self).toBe(scrubbed.data);
|
|
88
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// The Sentry SDK the server started, or null while Sentry is off. initSentryServer sets it once,
|
|
18
|
+
// before the app loads. The SDK is imported only when Sentry is on, and which SDK depends on
|
|
19
|
+
// whether the server traces requests, so nothing else imports a Sentry package: every server
|
|
20
|
+
// module that reports to Sentry reads it from here.
|
|
21
|
+
const sentrySdk = { current: null };
|
|
22
|
+
|
|
23
|
+
export default sentrySdk;
|