@jgardner04/ghost-mcp-server 1.13.5 → 1.14.1
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/package.json +1 -1
- package/src/__tests__/mcp_server.test.js +83 -0
- package/src/controllers/__tests__/imageController.test.js +2 -2
- package/src/controllers/imageController.js +11 -10
- package/src/mcp_server.js +270 -84
- package/src/routes/__tests__/imageRoutes.test.js +2 -2
- package/src/schemas/__tests__/common.test.js +3 -3
- package/src/schemas/__tests__/pageSchemas.test.js +11 -2
- package/src/schemas/common.js +3 -2
- package/src/schemas/pageSchemas.js +1 -1
- package/src/schemas/postSchemas.js +1 -1
- package/src/services/__tests__/ghostService.test.js +0 -19
- package/src/services/__tests__/imageProcessingService.test.js +148 -177
- package/src/services/__tests__/images.test.js +78 -0
- package/src/services/ghostService.js +1 -19
- package/src/services/imageProcessingService.js +100 -56
- package/src/services/images.js +34 -7
- package/src/services/pageService.js +2 -2
- package/src/utils/__tests__/formatErrorResponse.test.js +158 -0
- package/src/utils/__tests__/imageInputResolver.test.js +134 -0
- package/src/utils/__tests__/sanitizeErrorPayload.test.js +130 -0
- package/src/utils/__tests__/validation.test.js +13 -7
- package/src/utils/formatErrorResponse.js +63 -0
- package/src/utils/imageInputResolver.js +127 -0
- package/src/utils/sanitizeErrorPayload.js +67 -0
- package/src/utils/validation.js +2 -4
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { sanitizeErrorPayload } from '../sanitizeErrorPayload.js';
|
|
3
|
+
|
|
4
|
+
describe('sanitizeErrorPayload', () => {
|
|
5
|
+
const originalEnv = process.env.GHOST_ADMIN_API_KEY;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
process.env.GHOST_ADMIN_API_KEY = '';
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
if (originalEnv === undefined) {
|
|
13
|
+
delete process.env.GHOST_ADMIN_API_KEY;
|
|
14
|
+
} else {
|
|
15
|
+
process.env.GHOST_ADMIN_API_KEY = originalEnv;
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('redacts GHOST_ADMIN_API_KEY when it appears verbatim in a nested string', () => {
|
|
20
|
+
process.env.GHOST_ADMIN_API_KEY = 'super-secret-env-key-value';
|
|
21
|
+
const input = {
|
|
22
|
+
error: { message: 'Leaked super-secret-env-key-value in text' },
|
|
23
|
+
ghost: { originalMessage: 'also super-secret-env-key-value here' },
|
|
24
|
+
};
|
|
25
|
+
const out = sanitizeErrorPayload(input);
|
|
26
|
+
expect(out.error.message).not.toContain('super-secret-env-key-value');
|
|
27
|
+
expect(out.error.message).toContain('[REDACTED]');
|
|
28
|
+
expect(out.ghost.originalMessage).not.toContain('super-secret-env-key-value');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('redacts Ghost-shaped admin key literal embedded in text', () => {
|
|
32
|
+
const key = `${'a'.repeat(24)}:${'b'.repeat(64)}`;
|
|
33
|
+
const out = sanitizeErrorPayload({
|
|
34
|
+
error: { message: `Bad auth with ${key} failed` },
|
|
35
|
+
});
|
|
36
|
+
expect(out.error.message).not.toContain(key);
|
|
37
|
+
expect(out.error.message).toContain('[REDACTED]');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('redacts key= token= and access_token= query params on URLs', () => {
|
|
41
|
+
const out = sanitizeErrorPayload({
|
|
42
|
+
error: { message: 'GET https://ghost.example/admin?key=abc123&other=fine' },
|
|
43
|
+
ghost: {
|
|
44
|
+
originalMessage: 'https://x/y?token=xyz and https://x/y?access_token=qqq',
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
expect(out.error.message).toContain('key=[REDACTED]');
|
|
48
|
+
expect(out.error.message).toContain('other=fine');
|
|
49
|
+
expect(out.ghost.originalMessage).toContain('token=[REDACTED]');
|
|
50
|
+
expect(out.ghost.originalMessage).toContain('access_token=[REDACTED]');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('redacts Authorization / Cookie header-style substrings', () => {
|
|
54
|
+
const out = sanitizeErrorPayload({
|
|
55
|
+
error: { message: 'Authorization: Bearer abc.def.ghi and Cookie: sess=xyz' },
|
|
56
|
+
});
|
|
57
|
+
expect(out.error.message).not.toContain('abc.def.ghi');
|
|
58
|
+
expect(out.error.message).not.toContain('sess=xyz');
|
|
59
|
+
expect(out.error.message).toContain('Authorization');
|
|
60
|
+
expect(out.error.message).toContain('[REDACTED]');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('redacts every value in a multi-value Cookie header', () => {
|
|
64
|
+
const out = sanitizeErrorPayload({
|
|
65
|
+
error: { message: 'Cookie: a=first; b=SECRET_SESSION; c=third' },
|
|
66
|
+
});
|
|
67
|
+
expect(out.error.message).not.toContain('SECRET_SESSION');
|
|
68
|
+
expect(out.error.message).not.toContain('a=first');
|
|
69
|
+
expect(out.error.message).not.toContain('c=third');
|
|
70
|
+
expect(out.error.message).toContain('Cookie: [REDACTED]');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('redacts Set-Cookie value but preserves attributes (HttpOnly, Secure, Path)', () => {
|
|
74
|
+
const out = sanitizeErrorPayload({
|
|
75
|
+
error: { message: 'Set-Cookie: sess=TOKEN_XYZ; HttpOnly; Secure; Path=/' },
|
|
76
|
+
});
|
|
77
|
+
expect(out.error.message).not.toContain('TOKEN_XYZ');
|
|
78
|
+
expect(out.error.message).toContain('Set-Cookie: [REDACTED]');
|
|
79
|
+
expect(out.error.message).toContain('HttpOnly');
|
|
80
|
+
expect(out.error.message).toContain('Secure');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('redacts secrets inside string elements of arrays (truncation flag no longer needed)', () => {
|
|
84
|
+
const out = sanitizeErrorPayload({
|
|
85
|
+
ghost: {
|
|
86
|
+
originalMessage: ['https://x/y?key=LEAKED_1', 'https://x/y?token=LEAKED_2'],
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
expect(JSON.stringify(out)).not.toContain('LEAKED_1');
|
|
90
|
+
expect(JSON.stringify(out)).not.toContain('LEAKED_2');
|
|
91
|
+
expect(out.ghost.originalMessage[0]).toContain('key=[REDACTED]');
|
|
92
|
+
expect(out.ghost.originalMessage[1]).toContain('token=[REDACTED]');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('leaves benign content untouched', () => {
|
|
96
|
+
const input = {
|
|
97
|
+
error: { message: 'Title is required', code: 'GHOST_VALIDATION_ERROR', statusCode: 400 },
|
|
98
|
+
ghost: {
|
|
99
|
+
operation: 'posts.edit',
|
|
100
|
+
statusCode: 422,
|
|
101
|
+
originalMessage: 'Post title cannot be blank',
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const out = sanitizeErrorPayload(input);
|
|
105
|
+
expect(out).toEqual(input);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('truncates long originalMessage', () => {
|
|
109
|
+
const longMsg = 'x'.repeat(5000);
|
|
110
|
+
const out = sanitizeErrorPayload({
|
|
111
|
+
ghost: { originalMessage: longMsg },
|
|
112
|
+
});
|
|
113
|
+
expect(out.ghost.originalMessage.length).toBeLessThan(longMsg.length);
|
|
114
|
+
expect(out.ghost.originalMessage).toContain('[truncated]');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('does not redact env key when env var is empty', () => {
|
|
118
|
+
process.env.GHOST_ADMIN_API_KEY = '';
|
|
119
|
+
const out = sanitizeErrorPayload({ error: { message: 'harmless text' } });
|
|
120
|
+
expect(out.error.message).toBe('harmless text');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('does not mutate the input object', () => {
|
|
124
|
+
process.env.GHOST_ADMIN_API_KEY = 'SECRET';
|
|
125
|
+
const input = { error: { message: 'contains SECRET' } };
|
|
126
|
+
const snapshot = JSON.parse(JSON.stringify(input));
|
|
127
|
+
sanitizeErrorPayload(input);
|
|
128
|
+
expect(input).toEqual(snapshot);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -54,11 +54,14 @@ describe('validateToolInput', () => {
|
|
|
54
54
|
const result = validateToolInput(testSchema, { name: '' }, 'test_tool');
|
|
55
55
|
|
|
56
56
|
expect(result.success).toBe(false);
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
expect(
|
|
60
|
-
|
|
61
|
-
expect(
|
|
57
|
+
const text = result.errorResponse.content[0].text;
|
|
58
|
+
const jsonMatch = text.match(/```json\n([\s\S]+?)\n```/);
|
|
59
|
+
expect(jsonMatch).toBeTruthy();
|
|
60
|
+
const envelope = JSON.parse(jsonMatch[1]);
|
|
61
|
+
expect(envelope.error.name).toBe('ValidationError');
|
|
62
|
+
expect(envelope.error.code).toBe('VALIDATION_ERROR');
|
|
63
|
+
expect(envelope.error.statusCode).toBe(400);
|
|
64
|
+
expect(envelope.error.message).toContain('Validation failed');
|
|
62
65
|
});
|
|
63
66
|
});
|
|
64
67
|
|
|
@@ -141,8 +144,11 @@ describe('validateToolInput', () => {
|
|
|
141
144
|
|
|
142
145
|
expect(result.success).toBe(false);
|
|
143
146
|
expect(result.errorResponse.isError).toBe(true);
|
|
144
|
-
const
|
|
145
|
-
|
|
147
|
+
const text = result.errorResponse.content[0].text;
|
|
148
|
+
const jsonMatch = text.match(/```json\n([\s\S]+?)\n```/);
|
|
149
|
+
expect(jsonMatch).toBeTruthy();
|
|
150
|
+
const envelope = JSON.parse(jsonMatch[1]);
|
|
151
|
+
expect(envelope.error.code).toBe('VALIDATION_ERROR');
|
|
146
152
|
});
|
|
147
153
|
|
|
148
154
|
it('should pass validation when refinement is satisfied', () => {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { BaseError, GhostAPIError, ValidationError } from '../errors/index.js';
|
|
2
|
+
import { sanitizeErrorPayload } from './sanitizeErrorPayload.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Builds an MCP tool error response with a consistent envelope shape.
|
|
6
|
+
* All errors produce `{ error: {...} }`; GhostAPIError additionally includes
|
|
7
|
+
* a gated `ghost` sub-object with Ghost-specific diagnostic fields. Callers
|
|
8
|
+
* may pass an optional `extra` object whose contents will be merged into the
|
|
9
|
+
* envelope under an `extra` key and sanitized alongside the rest.
|
|
10
|
+
*
|
|
11
|
+
* @param {Error} error - The caught error.
|
|
12
|
+
* @param {string} toolName - MCP tool name for the human-readable summary line.
|
|
13
|
+
* @param {object} [extra] - Optional caller-supplied context; sanitized with the envelope.
|
|
14
|
+
* @returns {{content: {type: string, text: string}[], isError: true}}
|
|
15
|
+
*/
|
|
16
|
+
export function formatErrorResponse(error, toolName, extra) {
|
|
17
|
+
// Duck-type ZodError so we don't couple this module to the zod runtime.
|
|
18
|
+
const normalized =
|
|
19
|
+
error?.name === 'ZodError' && Array.isArray(error.issues)
|
|
20
|
+
? ValidationError.fromZod(error, toolName)
|
|
21
|
+
: error;
|
|
22
|
+
|
|
23
|
+
const base =
|
|
24
|
+
normalized instanceof BaseError
|
|
25
|
+
? normalized.toJSON()
|
|
26
|
+
: {
|
|
27
|
+
name: normalized?.name || 'Error',
|
|
28
|
+
message: normalized?.message || String(normalized),
|
|
29
|
+
code: 'UNKNOWN',
|
|
30
|
+
statusCode: 500,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const envelope = { error: base };
|
|
34
|
+
|
|
35
|
+
if (normalized instanceof GhostAPIError) {
|
|
36
|
+
envelope.ghost = {
|
|
37
|
+
operation: normalized.operation,
|
|
38
|
+
statusCode: normalized.ghostStatusCode,
|
|
39
|
+
// normalized.originalError is already a string (coerced by ExternalServiceError constructor);
|
|
40
|
+
// coerce again defensively so the sanitizer always receives a string, not an Error object.
|
|
41
|
+
originalMessage:
|
|
42
|
+
typeof normalized.originalError === 'string'
|
|
43
|
+
? normalized.originalError
|
|
44
|
+
: (normalized.originalError?.message ?? String(normalized.originalError)),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (extra && typeof extra === 'object' && Object.keys(extra).length > 0) {
|
|
49
|
+
envelope.extra = extra;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const sanitized = sanitizeErrorPayload(envelope);
|
|
53
|
+
const summary = sanitized.ghost
|
|
54
|
+
? `Error in ${toolName}: ${sanitized.error.name} [${sanitized.ghost.statusCode ?? '?'} ${sanitized.error.code}] ${sanitized.ghost.operation ?? '?'}: ${sanitized.ghost.originalMessage ?? sanitized.error.message}`
|
|
55
|
+
: `Error in ${toolName}: ${sanitized.error.message}`;
|
|
56
|
+
|
|
57
|
+
const body = `${summary}\n\n\`\`\`json\n${JSON.stringify(sanitized, null, 2)}\n\`\`\``;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
content: [{ type: 'text', text: body }],
|
|
61
|
+
isError: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
|
|
6
|
+
export const MAX_BASE64_BYTES = 5 * 1024 * 1024; // 5 MB decoded — respects MCP transport limits
|
|
7
|
+
|
|
8
|
+
const EXT_BY_MIME = {
|
|
9
|
+
'image/jpeg': '.jpg',
|
|
10
|
+
'image/jpg': '.jpg',
|
|
11
|
+
'image/png': '.png',
|
|
12
|
+
'image/gif': '.gif',
|
|
13
|
+
'image/webp': '.webp',
|
|
14
|
+
'image/svg+xml': '.svg',
|
|
15
|
+
'image/vnd.microsoft.icon': '.ico',
|
|
16
|
+
'image/x-icon': '.ico',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a caller-supplied local image path against `GHOST_MCP_IMAGE_ROOT`.
|
|
21
|
+
*
|
|
22
|
+
* Local-file input is *opt-in*: without the env var set, this function
|
|
23
|
+
* refuses every path. That prevents a compromised MCP client from reading
|
|
24
|
+
* arbitrary files via the upload tool. When set, the path must:
|
|
25
|
+
* - resolve inside the root,
|
|
26
|
+
* - exist,
|
|
27
|
+
* - not be a symlink that escapes the root.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} inputPath - Absolute or relative path supplied by caller.
|
|
30
|
+
* @returns {Promise<string>} Absolute real path to the image file.
|
|
31
|
+
*/
|
|
32
|
+
export async function resolveLocalImagePath(inputPath) {
|
|
33
|
+
const root = process.env.GHOST_MCP_IMAGE_ROOT;
|
|
34
|
+
if (!root) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
'imagePath input is disabled: GHOST_MCP_IMAGE_ROOT is not set. ' +
|
|
37
|
+
'Set it to the directory from which local uploads are allowed.'
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (typeof inputPath !== 'string' || inputPath.length === 0) {
|
|
41
|
+
throw new Error('imagePath must be a non-empty string');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Canonicalize the root too — on macOS `/var` resolves to `/private/var`
|
|
45
|
+
// via realpath, which would otherwise cause false symlink-escape errors.
|
|
46
|
+
let canonicalRoot;
|
|
47
|
+
try {
|
|
48
|
+
canonicalRoot = await fs.realpath(path.resolve(root));
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error(`GHOST_MCP_IMAGE_ROOT does not exist: ${root}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const resolved = path.resolve(inputPath);
|
|
54
|
+
// First check the textual path — catches `..` traversal before any FS I/O.
|
|
55
|
+
const resolvedStart = path.resolve(root);
|
|
56
|
+
const textuallyInside =
|
|
57
|
+
resolved === resolvedStart || resolved.startsWith(resolvedStart + path.sep);
|
|
58
|
+
if (!textuallyInside) {
|
|
59
|
+
throw new Error(`imagePath is outside the allowed root (${canonicalRoot})`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let stat;
|
|
63
|
+
try {
|
|
64
|
+
stat = await fs.stat(resolved);
|
|
65
|
+
} catch {
|
|
66
|
+
throw new Error(`imagePath does not exist: ${resolved}`);
|
|
67
|
+
}
|
|
68
|
+
if (!stat.isFile()) {
|
|
69
|
+
throw new Error(`imagePath is not a regular file: ${resolved}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Resolve symlinks and re-check containment against the canonical root.
|
|
73
|
+
const realPath = await fs.realpath(resolved);
|
|
74
|
+
const realInside = realPath === canonicalRoot || realPath.startsWith(canonicalRoot + path.sep);
|
|
75
|
+
if (!realInside) {
|
|
76
|
+
throw new Error(`imagePath symlink escapes the allowed root (${canonicalRoot})`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return realPath;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Decode a base64-encoded image to a fresh temp file.
|
|
84
|
+
*
|
|
85
|
+
* Accepts either a bare base64 string or a full `data:<mime>;base64,<data>`
|
|
86
|
+
* URI. Caps the decoded payload at MAX_BASE64_BYTES to respect MCP
|
|
87
|
+
* JSON-RPC transport limits — base64 payloads are inline in the tool
|
|
88
|
+
* call, and stdio transports choke on very large frames.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} base64 - Raw base64 or data URI.
|
|
91
|
+
* @param {string} mimeType - MIME type (used to pick the temp file extension).
|
|
92
|
+
* @returns {Promise<string>} Absolute path to the decoded temp file.
|
|
93
|
+
*/
|
|
94
|
+
export async function decodeBase64ToTempFile(base64, mimeType) {
|
|
95
|
+
if (typeof base64 !== 'string' || base64.length === 0) {
|
|
96
|
+
throw new Error('imageBase64 must be a non-empty string');
|
|
97
|
+
}
|
|
98
|
+
const ext = EXT_BY_MIME[(mimeType || '').toLowerCase()];
|
|
99
|
+
if (!ext) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Unsupported mimeType: ${mimeType}. Allowed: ${Object.keys(EXT_BY_MIME).join(', ')}`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Strip optional "data:<mime>;base64," prefix.
|
|
106
|
+
const payload = base64.startsWith('data:') ? base64.split(',', 2)[1] : base64;
|
|
107
|
+
if (!payload) throw new Error('Invalid base64 payload');
|
|
108
|
+
|
|
109
|
+
// Reject obviously non-base64 input cheaply before allocating.
|
|
110
|
+
if (!/^[A-Za-z0-9+/=\s]+$/.test(payload)) {
|
|
111
|
+
throw new Error('Invalid base64 input');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const buf = Buffer.from(payload, 'base64');
|
|
115
|
+
if (buf.length === 0) {
|
|
116
|
+
throw new Error('Invalid base64 input');
|
|
117
|
+
}
|
|
118
|
+
if (buf.length > MAX_BASE64_BYTES) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`imageBase64 decoded size (${buf.length} bytes) exceeds the 5MB limit for MCP transport`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const outPath = path.join(os.tmpdir(), `mcp-b64-${crypto.randomUUID()}${ext}`);
|
|
125
|
+
await fs.writeFile(outPath, buf);
|
|
126
|
+
return outPath;
|
|
127
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sole chokepoint for sanitizing error payloads surfaced to MCP clients.
|
|
3
|
+
* Walks the envelope and replaces values that could leak credentials.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const GHOST_KEY_PATTERN = /[0-9a-f]{24}:[0-9a-f]{64}/gi;
|
|
7
|
+
const URL_SECRET_QS_PATTERN = /([?&](?:key|token|access_token)=)[^&\s"']+/gi;
|
|
8
|
+
// Authorization / Set-Cookie values stop at ';' (Set-Cookie attributes like
|
|
9
|
+
// HttpOnly/Secure are not secrets). A bare `Cookie` header can contain multiple
|
|
10
|
+
// `name=value` pairs separated by ';' — all of which may be session tokens — so
|
|
11
|
+
// it gets a separate, greedier pattern that stops only at end-of-line.
|
|
12
|
+
const AUTH_HEADER_PATTERN = /(Authorization|Set-Cookie)\s*[:=]\s*[^\r\n,;]+/gi;
|
|
13
|
+
// Negative look-behind so this pattern matches a bare `Cookie:` header but not
|
|
14
|
+
// the `Cookie` substring inside `Set-Cookie:` (handled by AUTH_HEADER_PATTERN).
|
|
15
|
+
const COOKIE_HEADER_PATTERN = /(?<!Set-)(Cookie)\s*[:=]\s*[^\r\n]+/gi;
|
|
16
|
+
const REDACTED = '[REDACTED]';
|
|
17
|
+
const ORIGINAL_MESSAGE_MAX_BYTES = 2048;
|
|
18
|
+
|
|
19
|
+
function redactString(value, envKey) {
|
|
20
|
+
if (typeof value !== 'string' || value.length === 0) return value;
|
|
21
|
+
let out = value;
|
|
22
|
+
if (envKey) {
|
|
23
|
+
out = out.replaceAll(envKey, REDACTED);
|
|
24
|
+
}
|
|
25
|
+
out = out.replace(GHOST_KEY_PATTERN, REDACTED);
|
|
26
|
+
out = out.replace(URL_SECRET_QS_PATTERN, `$1${REDACTED}`);
|
|
27
|
+
out = out.replace(AUTH_HEADER_PATTERN, `$1: ${REDACTED}`);
|
|
28
|
+
out = out.replace(COOKIE_HEADER_PATTERN, `$1: ${REDACTED}`);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function truncate(value, maxBytes) {
|
|
33
|
+
if (typeof value !== 'string') return value;
|
|
34
|
+
if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value;
|
|
35
|
+
// Slice by bytes (not chars) to honour the byte limit even for multibyte content.
|
|
36
|
+
return `${Buffer.from(value, 'utf8').subarray(0, maxBytes).toString('utf8')}…[truncated]`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function walk(node, envKey) {
|
|
40
|
+
if (node === null || node === undefined) return node;
|
|
41
|
+
if (typeof node === 'string') return redactString(node, envKey);
|
|
42
|
+
if (Array.isArray(node)) return node.map((item) => walk(item, envKey));
|
|
43
|
+
if (typeof node === 'object') {
|
|
44
|
+
const result = {};
|
|
45
|
+
for (const [k, v] of Object.entries(node)) {
|
|
46
|
+
const walked = walk(v, envKey);
|
|
47
|
+
result[k] =
|
|
48
|
+
k === 'originalMessage' && typeof walked === 'string'
|
|
49
|
+
? truncate(walked, ORIGINAL_MESSAGE_MAX_BYTES)
|
|
50
|
+
: walked;
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
return node;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Deep-walks an error envelope and redacts known secret patterns.
|
|
59
|
+
* Non-destructive: returns a new object; does not mutate input.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} envelope - Error envelope object.
|
|
62
|
+
* @returns {object} Sanitized envelope.
|
|
63
|
+
*/
|
|
64
|
+
export function sanitizeErrorPayload(envelope) {
|
|
65
|
+
const envKey = process.env.GHOST_ADMIN_API_KEY || '';
|
|
66
|
+
return walk(envelope, envKey);
|
|
67
|
+
}
|
package/src/utils/validation.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Provides explicit Zod validation to ensure input is validated at handler entry points.
|
|
4
4
|
*/
|
|
5
5
|
import { ValidationError } from '../errors/index.js';
|
|
6
|
+
import { formatErrorResponse } from './formatErrorResponse.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Validates tool input against a Zod schema and returns a structured result.
|
|
@@ -18,10 +19,7 @@ export const validateToolInput = (schema, input, toolName) => {
|
|
|
18
19
|
const error = ValidationError.fromZod(result.error, toolName);
|
|
19
20
|
return {
|
|
20
21
|
success: false,
|
|
21
|
-
errorResponse:
|
|
22
|
-
content: [{ type: 'text', text: JSON.stringify(error.toJSON(), null, 2) }],
|
|
23
|
-
isError: true,
|
|
24
|
-
},
|
|
22
|
+
errorResponse: formatErrorResponse(error, toolName),
|
|
25
23
|
};
|
|
26
24
|
}
|
|
27
25
|
return { success: true, data: result.data };
|