@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,102 @@
|
|
|
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 { jest } from '@jest/globals';
|
|
18
|
+
import { TenantIntegrityError } from '@lowdefy/errors';
|
|
19
|
+
|
|
20
|
+
const secret = 'planted-handle-error-secret';
|
|
21
|
+
|
|
22
|
+
process.env.LOWDEFY_SECRET_API_KEY = secret;
|
|
23
|
+
|
|
24
|
+
const { default: createHandleError } = await import('./createHandleError.js');
|
|
25
|
+
|
|
26
|
+
function createAxiosError() {
|
|
27
|
+
const error = new Error(`Request failed for key ${secret}`);
|
|
28
|
+
error.name = 'AxiosError';
|
|
29
|
+
error.code = 'ERR_BAD_REQUEST';
|
|
30
|
+
error.config = {
|
|
31
|
+
auth: { username: 'svc-user', password: 'planted-config-password' },
|
|
32
|
+
params: { api_key: 'planted-config-param' },
|
|
33
|
+
headers: { Authorization: 'Bearer planted-config-header' },
|
|
34
|
+
};
|
|
35
|
+
return error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function testContext({ logger, headers } = {}) {
|
|
39
|
+
return {
|
|
40
|
+
headers,
|
|
41
|
+
logger: logger ?? { error: jest.fn() },
|
|
42
|
+
readConfigFile: jest.fn(async () => null),
|
|
43
|
+
req: { url: '/api/request/page/req', method: 'POST', hostname: 'app.example.com' },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
console.error = jest.fn();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('handleError logs the referer header with credential query parameters redacted', async () => {
|
|
52
|
+
const context = testContext({
|
|
53
|
+
headers: { referer: 'https://app.example.com/verify?token=planted-token&page=2' },
|
|
54
|
+
});
|
|
55
|
+
const handleError = createHandleError({ context });
|
|
56
|
+
|
|
57
|
+
await handleError(new Error('Failed.'));
|
|
58
|
+
|
|
59
|
+
const [fields] = context.logger.error.mock.calls[0];
|
|
60
|
+
expect(fields.headers.referer).toBe('https://app.example.com/verify?token=%5Bredacted%5D&page=2');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('handleError fallback output carries no axios config auth and no planted secret when the log call throws', async () => {
|
|
64
|
+
const logError = new Error(`Log sink rejected ${secret}`);
|
|
65
|
+
const context = testContext({
|
|
66
|
+
logger: {
|
|
67
|
+
error: jest.fn(() => {
|
|
68
|
+
throw logError;
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
const handleError = createHandleError({ context });
|
|
73
|
+
|
|
74
|
+
await handleError(createAxiosError());
|
|
75
|
+
|
|
76
|
+
expect(console.error).toHaveBeenCalledTimes(3);
|
|
77
|
+
const output = console.error.mock.calls.map(([line]) => line).join('\n');
|
|
78
|
+
expect(output).not.toContain('planted-config-password');
|
|
79
|
+
expect(output).not.toContain('planted-config-param');
|
|
80
|
+
expect(output).not.toContain('planted-config-header');
|
|
81
|
+
expect(output).not.toContain(secret);
|
|
82
|
+
const logged = JSON.parse(console.error.mock.calls[0][0]);
|
|
83
|
+
expect(logged.message).toBe('Request failed for key [REDACTED]');
|
|
84
|
+
expect(logged.code).toBe('ERR_BAD_REQUEST');
|
|
85
|
+
expect(JSON.parse(console.error.mock.calls[2][0]).message).toBe('Log sink rejected [REDACTED]');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('handleError logs a TenantIntegrityError as tenant_integrity_error with endpoint and organization', async () => {
|
|
89
|
+
const context = testContext();
|
|
90
|
+
context.endpointId = 'save-contact';
|
|
91
|
+
context.user = { id: 'u1', organization_id: 'org_a' };
|
|
92
|
+
const handleError = createHandleError({ context });
|
|
93
|
+
|
|
94
|
+
await handleError(
|
|
95
|
+
new TenantIntegrityError('bad row', { collection: 'contacts', organizationId: 'org_a' })
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const [fields] = context.logger.error.mock.calls[0];
|
|
99
|
+
expect(fields.event).toBe('tenant_integrity_error');
|
|
100
|
+
expect(fields.endpointId).toBe('save-contact');
|
|
101
|
+
expect(fields.organization_id).toBe('org_a');
|
|
102
|
+
});
|
|
@@ -18,6 +18,8 @@ import { createNodeLogger } from '@lowdefy/logger/node';
|
|
|
18
18
|
import { type } from '@lowdefy/helpers';
|
|
19
19
|
|
|
20
20
|
import appMeta from '../../build/appMeta.js';
|
|
21
|
+
import scrubSecrets from '../scrubSecrets.js';
|
|
22
|
+
import serializeErrorForLog from './serializeErrorForLog.js';
|
|
21
23
|
|
|
22
24
|
// Deploy identity on every log line — pid/hostname are stripped, so these
|
|
23
25
|
// fields are what correlates a line to an app build across replicas.
|
|
@@ -31,11 +33,19 @@ if (!type.isNone(appMeta.version)) {
|
|
|
31
33
|
if (!type.isNone(appMeta.gitSha)) {
|
|
32
34
|
base.git_sha = appMeta.gitSha;
|
|
33
35
|
}
|
|
36
|
+
// The deployment environment (LOWDEFY_ENVIRONMENT), so one log drain can hold every environment.
|
|
37
|
+
if (!type.isNone(appMeta.environment)) {
|
|
38
|
+
base.environment = appMeta.environment;
|
|
39
|
+
}
|
|
34
40
|
|
|
35
41
|
const logger = createNodeLogger({
|
|
36
42
|
name: 'lowdefy_server',
|
|
37
43
|
level: process.env.LOWDEFY_LOG_LEVEL ?? 'info',
|
|
38
44
|
base,
|
|
45
|
+
serializers: { err: serializeErrorForLog },
|
|
46
|
+
// Scrubbing the finished line rather than the logged object covers every field of every
|
|
47
|
+
// call - debug payloads and request results as well as `err` - with no walker to miss one.
|
|
48
|
+
hooks: { streamWrite: scrubSecrets },
|
|
39
49
|
});
|
|
40
50
|
|
|
41
51
|
function createLogger(metadata = {}) {
|
|
@@ -0,0 +1,117 @@
|
|
|
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 { jest } from '@jest/globals';
|
|
18
|
+
import { createNodeLogger } from '@lowdefy/logger/node';
|
|
19
|
+
import { markCredential, runInCredentialScope } from '@lowdefy/node-utils';
|
|
20
|
+
|
|
21
|
+
const secret = 'planted/secret+value=1';
|
|
22
|
+
const shortSecret = 'short77';
|
|
23
|
+
|
|
24
|
+
const lines = [];
|
|
25
|
+
const destination = {
|
|
26
|
+
write(line) {
|
|
27
|
+
lines.push(line);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// The prod logger writes to stdout; route the real logger to memory so each test reads the
|
|
32
|
+
// exact line pino would emit, after the streamWrite hook.
|
|
33
|
+
jest.unstable_mockModule('@lowdefy/logger/node', () => ({
|
|
34
|
+
createNodeLogger: (options) => createNodeLogger({ ...options, destination }),
|
|
35
|
+
}));
|
|
36
|
+
jest.unstable_mockModule('../../build/appMeta.js', () => ({ default: {} }));
|
|
37
|
+
|
|
38
|
+
process.env.LOWDEFY_SECRET_API_KEY = secret;
|
|
39
|
+
process.env.LOWDEFY_SECRET_SHORT = shortSecret;
|
|
40
|
+
process.env.LOWDEFY_LOG_LEVEL = 'debug';
|
|
41
|
+
|
|
42
|
+
const { default: createLogger } = await import('./createLogger.js');
|
|
43
|
+
|
|
44
|
+
function createAxiosError() {
|
|
45
|
+
const error = new Error('Request failed with status code 401');
|
|
46
|
+
error.name = 'AxiosError';
|
|
47
|
+
error.code = 'ERR_BAD_REQUEST';
|
|
48
|
+
error.config = {
|
|
49
|
+
auth: { username: 'svc-user', password: 'planted-config-password' },
|
|
50
|
+
params: { api_key: 'planted-config-param' },
|
|
51
|
+
headers: { Authorization: 'Bearer planted-config-header' },
|
|
52
|
+
baseURL: 'https://api.example.com',
|
|
53
|
+
};
|
|
54
|
+
error.response = { status: 401, data: { detail: 'planted-response-body' } };
|
|
55
|
+
return error;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
lines.length = 0;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('prod logger drops an axios error config auth, params and headers', () => {
|
|
63
|
+
createLogger().error({ err: createAxiosError() }, 'Request failed');
|
|
64
|
+
expect(lines).toHaveLength(1);
|
|
65
|
+
const line = lines[0];
|
|
66
|
+
expect(line).not.toContain('planted-config-password');
|
|
67
|
+
expect(line).not.toContain('planted-config-param');
|
|
68
|
+
expect(line).not.toContain('planted-config-header');
|
|
69
|
+
expect(line).not.toContain('planted-response-body');
|
|
70
|
+
const { err } = JSON.parse(line);
|
|
71
|
+
expect(err.config).toBeUndefined();
|
|
72
|
+
expect(err.code).toBe('ERR_BAD_REQUEST');
|
|
73
|
+
expect(err.statusCode).toBe(401);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('prod logger redacts a planted secret in a message raw, URL-encoded and base64', () => {
|
|
77
|
+
const basic = Buffer.from(`svc-user:${secret}`).toString('base64');
|
|
78
|
+
const error = new Error(`raw ${secret} encoded ${encodeURIComponent(secret)} basic ${basic}`);
|
|
79
|
+
createLogger().error({ err: error }, error.message);
|
|
80
|
+
expect(lines).toHaveLength(1);
|
|
81
|
+
const line = lines[0];
|
|
82
|
+
expect(line).not.toContain(secret);
|
|
83
|
+
expect(line).not.toContain(encodeURIComponent(secret));
|
|
84
|
+
expect(line).not.toContain(Buffer.from(secret).toString('base64').slice(0, 20));
|
|
85
|
+
const { msg, err } = JSON.parse(line);
|
|
86
|
+
expect(msg).toMatch(/^raw \[REDACTED\] encoded \[REDACTED\] basic \S*\[REDACTED\]/);
|
|
87
|
+
expect(err.message).toBe(msg);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('prod logger redacts a planted secret in a debug payload that is not an error', () => {
|
|
91
|
+
createLogger({ rid: 'req-1' }).debug({ response: { token: secret } }, 'Request result');
|
|
92
|
+
expect(lines).toHaveLength(1);
|
|
93
|
+
expect(lines[0]).not.toContain(secret);
|
|
94
|
+
expect(JSON.parse(lines[0]).response.token).toBe('[REDACTED]');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('prod logger leaves a secret shorter than 8 characters alone', () => {
|
|
98
|
+
createLogger().info({ detail: shortSecret }, `value ${shortSecret}`);
|
|
99
|
+
expect(lines).toHaveLength(1);
|
|
100
|
+
const { msg, detail } = JSON.parse(lines[0]);
|
|
101
|
+
expect(msg).toBe(`value ${shortSecret}`);
|
|
102
|
+
expect(detail).toBe(shortSecret);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('prod logger redacts a value the request marked as a credential', () => {
|
|
106
|
+
runInCredentialScope(() => {
|
|
107
|
+
markCredential('runtime-made-app-key-0001');
|
|
108
|
+
createLogger({ rid: 'req-1' }).debug(
|
|
109
|
+
{ event: 'debug_control_return', response: { key: 'runtime-made-app-key-0001' } },
|
|
110
|
+
'Returned'
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
createLogger({ rid: 'req-2' }).debug({ response: { key: 'runtime-made-app-key-0001' } });
|
|
114
|
+
expect(lines).toHaveLength(2);
|
|
115
|
+
expect(JSON.parse(lines[0]).response.key).toBe('[REDACTED]');
|
|
116
|
+
expect(JSON.parse(lines[1]).response.key).toBe('runtime-made-app-key-0001');
|
|
117
|
+
});
|
|
@@ -14,20 +14,28 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import redactUrlQuery from './redactUrlQuery.js';
|
|
18
|
+
|
|
17
19
|
// Emits one access-log line per request, after the response is finalized.
|
|
20
|
+
// `url` is the route path only (c.req.path). The referer keeps its query but
|
|
21
|
+
// with credential parameters redacted, because the browser sends the page's
|
|
22
|
+
// full URL there and that URL can carry a sign-in token or a signed OAuth
|
|
23
|
+
// query (see redactUrlQuery).
|
|
18
24
|
function logRequest({ context, status, durationMs }) {
|
|
19
|
-
const { headers = {}
|
|
25
|
+
const { headers = {} } = context;
|
|
26
|
+
const user = context.user ?? {};
|
|
20
27
|
context.logger.info(
|
|
21
28
|
{
|
|
22
29
|
user: {
|
|
23
30
|
id: user.id,
|
|
24
31
|
roles: user.roles,
|
|
25
|
-
sub: user.sub,
|
|
26
|
-
session_id: user.session_id, // TODO: Implement session id
|
|
27
32
|
},
|
|
28
33
|
url: context.req.url,
|
|
29
34
|
method: context.req.method,
|
|
30
35
|
hostname: context.req.hostname,
|
|
36
|
+
// The address the server resolved (see getClientAddress). The forwarding
|
|
37
|
+
// headers below are logged as the client sent them, and are not it.
|
|
38
|
+
client_address: context.clientAddress,
|
|
31
39
|
status,
|
|
32
40
|
duration_ms: durationMs,
|
|
33
41
|
headers: {
|
|
@@ -37,7 +45,7 @@ function logRequest({ context, status, durationMs }) {
|
|
|
37
45
|
'sec-ch-ua': headers['sec-ch-ua'],
|
|
38
46
|
'user-agent': headers['user-agent'],
|
|
39
47
|
host: headers.host,
|
|
40
|
-
referer: headers.referer,
|
|
48
|
+
referer: redactUrlQuery(headers.referer),
|
|
41
49
|
'x-forwarded-for': headers['x-forwarded-for'],
|
|
42
50
|
// Vercel headers
|
|
43
51
|
'x-vercel-id': headers['x-vercel-id'],
|
|
@@ -0,0 +1,37 @@
|
|
|
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 } from '@lowdefy/helpers';
|
|
18
|
+
|
|
19
|
+
// `received` is the one kept log field holding values fetched at runtime (a token
|
|
20
|
+
// from an earlier step sent as a header), which the by-value secret scrub
|
|
21
|
+
// cannot recognise, so credential-looking keys are masked by name instead.
|
|
22
|
+
const credentialKeyParts = ['authorization', 'token', 'secret', 'password', 'apikey', 'cookie'];
|
|
23
|
+
|
|
24
|
+
function isCredentialKey(key) {
|
|
25
|
+
const normalized = key.toLowerCase().replace(/[-_]/g, '');
|
|
26
|
+
return credentialKeyParts.some((part) => normalized.includes(part));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Shared references and cycles are copied as such, so the serializer walk still
|
|
30
|
+
// marks them '[Circular]'.
|
|
31
|
+
function maskCredentialKeys(value) {
|
|
32
|
+
return mapPlainValues(value, (item, key) =>
|
|
33
|
+
key !== undefined && isCredentialKey(key) ? '[REDACTED]' : item
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default maskCredentialKeys;
|
|
@@ -0,0 +1,61 @@
|
|
|
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 { lowdefyErrorNames, readErrorCodes } from '@lowdefy/errors';
|
|
18
|
+
|
|
19
|
+
import maskCredentialKeys from './maskCredentialKeys.js';
|
|
20
|
+
|
|
21
|
+
// Kept only on nodes named as a Lowdefy error class: libraries reuse some of
|
|
22
|
+
// these names for their own data - axios's `config` carries auth, params and
|
|
23
|
+
// headers - so keying on the field alone would let a library's copy through.
|
|
24
|
+
const lowdefyErrorFields = [
|
|
25
|
+
'configKey',
|
|
26
|
+
'source',
|
|
27
|
+
'config',
|
|
28
|
+
'handled',
|
|
29
|
+
'isLowdefyError',
|
|
30
|
+
'received',
|
|
31
|
+
'typeName',
|
|
32
|
+
'location',
|
|
33
|
+
'service',
|
|
34
|
+
'hint',
|
|
35
|
+
'retryAfter',
|
|
36
|
+
'methodName',
|
|
37
|
+
'metaData',
|
|
38
|
+
'blockId',
|
|
39
|
+
'pageId',
|
|
40
|
+
'isReject',
|
|
41
|
+
'collection',
|
|
42
|
+
'connectionId',
|
|
43
|
+
'organizationId',
|
|
44
|
+
'field',
|
|
45
|
+
'endpointId',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
function projectErrorForLog(err) {
|
|
49
|
+
const { code, statusCode } = readErrorCodes(err);
|
|
50
|
+
const props = { name: err.name, message: err.message, stack: err.stack, code, statusCode };
|
|
51
|
+
if (lowdefyErrorNames.has(err.name)) {
|
|
52
|
+
lowdefyErrorFields.forEach((field) => {
|
|
53
|
+
props[field] = err[field];
|
|
54
|
+
});
|
|
55
|
+
props.received = maskCredentialKeys(err.received);
|
|
56
|
+
}
|
|
57
|
+
props.cause = err.cause;
|
|
58
|
+
return props;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default projectErrorForLog;
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
// Query parameters that carry a credential rather than a filter: the
|
|
18
|
+
// magic-link and reset tokens, the OAuth authorization code and signed state,
|
|
19
|
+
// signed-URL signatures, one-time passcodes. Matched case-insensitively.
|
|
20
|
+
const secretParamNames = new Set([
|
|
21
|
+
'code',
|
|
22
|
+
'state',
|
|
23
|
+
'sig',
|
|
24
|
+
'otp',
|
|
25
|
+
'nonce',
|
|
26
|
+
'auth',
|
|
27
|
+
'apikey',
|
|
28
|
+
'api_key',
|
|
29
|
+
'api-key',
|
|
30
|
+
'session',
|
|
31
|
+
'sessionid',
|
|
32
|
+
'session_id',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
// Names that contain one of these are credentials whatever the prefix or
|
|
36
|
+
// suffix - access_token, X-Amz-Signature, client_secret, X-Amz-Credential.
|
|
37
|
+
const secretParamFragments = ['token', 'secret', 'password', 'signature', 'credential'];
|
|
38
|
+
|
|
39
|
+
function isSecretParam(name) {
|
|
40
|
+
const lower = name.toLowerCase();
|
|
41
|
+
if (secretParamNames.has(lower)) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
return secretParamFragments.some((fragment) => lower.includes(fragment));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A URL fit for an access log. The query string stays - it is often what
|
|
48
|
+
// distinguishes one page view from the next - but the value of every
|
|
49
|
+
// credential-like parameter is replaced with "[redacted]". The browser echoes
|
|
50
|
+
// the page's full URL back as the Referer of every fetch that page makes, so
|
|
51
|
+
// the magic-link verify page or the OAuth consent page would otherwise write
|
|
52
|
+
// its token or signed state into the log store on each request. The fragment
|
|
53
|
+
// is dropped: a browser never sends it in a Referer, and implicit-flow tokens
|
|
54
|
+
// travel there. Returns undefined for a missing value. Works on relative URLs
|
|
55
|
+
// too, so a malformed or path-only header is still visible.
|
|
56
|
+
function redactUrlQuery(url) {
|
|
57
|
+
if (typeof url !== 'string' || url === '') {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const withoutFragment = url.split('#')[0];
|
|
61
|
+
const queryStart = withoutFragment.indexOf('?');
|
|
62
|
+
if (queryStart === -1) {
|
|
63
|
+
return withoutFragment;
|
|
64
|
+
}
|
|
65
|
+
const base = withoutFragment.slice(0, queryStart);
|
|
66
|
+
const params = new URLSearchParams(withoutFragment.slice(queryStart + 1));
|
|
67
|
+
for (const name of params.keys()) {
|
|
68
|
+
if (isSecretParam(name)) {
|
|
69
|
+
params.set(name, '[redacted]');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return `${base}?${params.toString()}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default redactUrlQuery;
|
|
@@ -0,0 +1,73 @@
|
|
|
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 redactUrlQuery from './redactUrlQuery.js';
|
|
18
|
+
|
|
19
|
+
test('redactUrlQuery redacts the magic-link token and keeps the other parameters', () => {
|
|
20
|
+
expect(
|
|
21
|
+
redactUrlQuery(
|
|
22
|
+
'https://app.example.com/api/auth/magic-link/verify?token=abc123&callbackURL=%2Fhome'
|
|
23
|
+
)
|
|
24
|
+
).toEqual(
|
|
25
|
+
'https://app.example.com/api/auth/magic-link/verify?token=%5Bredacted%5D&callbackURL=%2Fhome'
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('redactUrlQuery redacts the signed OAuth state, sig and code', () => {
|
|
30
|
+
expect(
|
|
31
|
+
redactUrlQuery('https://app.example.com/oauth-consent?client_id=app&state=xyz&sig=abc&code=q')
|
|
32
|
+
).toEqual(
|
|
33
|
+
'https://app.example.com/oauth-consent?client_id=app&state=%5Bredacted%5D&sig=%5Bredacted%5D&code=%5Bredacted%5D'
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('redactUrlQuery matches credential names case-insensitively and by fragment', () => {
|
|
38
|
+
expect(
|
|
39
|
+
redactUrlQuery(
|
|
40
|
+
'https://bucket.s3.amazonaws.com/uploads/file.pdf?X-Amz-Credential=AKIA&X-Amz-Signature=deadbeef&X-Amz-Expires=300&access_token=t&client_secret=s'
|
|
41
|
+
)
|
|
42
|
+
).toEqual(
|
|
43
|
+
'https://bucket.s3.amazonaws.com/uploads/file.pdf?X-Amz-Credential=%5Bredacted%5D&X-Amz-Signature=%5Bredacted%5D&X-Amz-Expires=300&access_token=%5Bredacted%5D&client_secret=%5Bredacted%5D'
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('redactUrlQuery leaves an ordinary page query untouched', () => {
|
|
48
|
+
expect(redactUrlQuery('https://app.example.com/orders?status=open&page=2')).toEqual(
|
|
49
|
+
'https://app.example.com/orders?status=open&page=2'
|
|
50
|
+
);
|
|
51
|
+
expect(redactUrlQuery('https://app.example.com/orders')).toEqual(
|
|
52
|
+
'https://app.example.com/orders'
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('redactUrlQuery drops the fragment', () => {
|
|
57
|
+
expect(redactUrlQuery('https://app.example.com/callback?page=1#access_token=abc')).toEqual(
|
|
58
|
+
'https://app.example.com/callback?page=1'
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('redactUrlQuery returns undefined for a missing header', () => {
|
|
63
|
+
expect(redactUrlQuery(undefined)).toBeUndefined();
|
|
64
|
+
expect(redactUrlQuery(null)).toBeUndefined();
|
|
65
|
+
expect(redactUrlQuery('')).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('redactUrlQuery redacts a value that is not an absolute URL', () => {
|
|
69
|
+
expect(redactUrlQuery('/oauth-select-organization?state=xyz&sig=abc&org=acme')).toEqual(
|
|
70
|
+
'/oauth-select-organization?state=%5Bredacted%5D&sig=%5Bredacted%5D&org=acme'
|
|
71
|
+
);
|
|
72
|
+
expect(redactUrlQuery('not a url')).toEqual('not a url');
|
|
73
|
+
});
|
|
@@ -14,18 +14,12 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import { serializer } from '@lowdefy/helpers';
|
|
18
18
|
|
|
19
|
-
import
|
|
19
|
+
import projectErrorForLog from './projectErrorForLog.js';
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
async function getSession(c) {
|
|
24
|
-
if (authJson.configured !== true) {
|
|
25
|
-
return undefined;
|
|
26
|
-
}
|
|
27
|
-
const authUser = await getAuthUser(c);
|
|
28
|
-
return authUser?.session ?? undefined;
|
|
21
|
+
function serializeErrorForLog(error) {
|
|
22
|
+
return serializer.serialize(error, { projectError: projectErrorForLog })?.['~e'] ?? error;
|
|
29
23
|
}
|
|
30
24
|
|
|
31
|
-
export default
|
|
25
|
+
export default serializeErrorForLog;
|