@jgardner04/ghost-mcp-server 1.14.3 → 1.14.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgardner04/ghost-mcp-server",
3
- "version": "1.14.3",
3
+ "version": "1.14.4",
4
4
  "description": "A Model Context Protocol (MCP) server for interacting with Ghost CMS via the Admin API",
5
5
  "author": "Jonathan Gardner",
6
6
  "type": "module",
@@ -377,6 +377,40 @@ describe('urlValidator', () => {
377
377
  expect(result.isValid).toBe(true);
378
378
  });
379
379
  });
380
+
381
+ // Regression tests for JON-151: the previous two-parser sequence
382
+ // (Joi.uri() + new URL()) could disagree on these edge cases, opening a
383
+ // path-around-isSafeHost SSRF window. The single-parser pipeline must
384
+ // either reject these or canonicalize them consistently with new URL().
385
+ describe('parse-disagreement regressions (JON-151)', () => {
386
+ it('should reject credentials-prefixed URL whose true host is private (127.0.0.1)', () => {
387
+ // WHATWG: hostname is 127.0.0.1. A naive parser that split on '@' the
388
+ // wrong way could see "imgur.com" and let this through. isSafeHost
389
+ // must see 127.0.0.1 and reject.
390
+ const result = validateImageUrl('https://imgur.com@127.0.0.1/image.jpg');
391
+ expect(result.isValid).toBe(false);
392
+ });
393
+
394
+ it('should reject Unicode hostname not on the allowlist', () => {
395
+ // new URL('https://☃.com/') canonicalizes the host to xn--n3h.com
396
+ // (Punycode). Either form must be rejected — neither is allowlisted.
397
+ const result = validateImageUrl('https://☃.com/image.jpg');
398
+ expect(result.isValid).toBe(false);
399
+ });
400
+
401
+ it('should reject Punycode hostname not on the allowlist', () => {
402
+ const result = validateImageUrl('https://xn--n3h.com/image.jpg');
403
+ expect(result.isValid).toBe(false);
404
+ });
405
+
406
+ it('should reject IPv6 loopback with zone ID', () => {
407
+ // [::1%25eth0] is the percent-encoded form of [::1%eth0]. The host is
408
+ // still ::1 — the SSRF guard must catch it regardless of whether the
409
+ // parser accepts the zone-ID syntax or rejects the URL outright.
410
+ const result = validateImageUrl('http://[::1%25eth0]/image.jpg');
411
+ expect(result.isValid).toBe(false);
412
+ });
413
+ });
380
414
  });
381
415
 
382
416
  describe('createSecureAxiosConfig', () => {
@@ -1,4 +1,4 @@
1
- import Joi from 'joi';
1
+ import { z } from 'zod';
2
2
  import { URL } from 'url';
3
3
 
4
4
  // Configure allowed domains for image downloads
@@ -69,6 +69,25 @@ const isSafeHost = (hostname) => {
69
69
  );
70
70
  };
71
71
 
72
+ // Single-parser URL schema. z.string().url() uses the WHATWG URL parser
73
+ // (same engine as new URL() below), so the schema check and the subsequent
74
+ // hostname extraction cannot disagree on edge cases like embedded
75
+ // credentials, IDN/Unicode, or IPv6 zone IDs. See JON-151.
76
+ const urlSchema = z
77
+ .string()
78
+ .url()
79
+ .refine(
80
+ (s) => {
81
+ try {
82
+ const proto = new URL(s).protocol;
83
+ return proto === 'http:' || proto === 'https:';
84
+ } catch {
85
+ return false;
86
+ }
87
+ },
88
+ { message: 'must use http or https scheme' }
89
+ );
90
+
72
91
  /**
73
92
  * Validates and sanitizes a URL for safe external requests
74
93
  * @param {string} url - The URL to validate
@@ -76,19 +95,11 @@ const isSafeHost = (hostname) => {
76
95
  */
77
96
  const validateImageUrl = (url) => {
78
97
  try {
79
- // Basic URL validation with Joi
80
- const urlSchema = Joi.string()
81
- .uri({
82
- scheme: ['http', 'https'],
83
- allowRelative: false,
84
- })
85
- .required();
86
-
87
- const validation = urlSchema.validate(url);
88
- if (validation.error) {
98
+ const result = urlSchema.safeParse(url);
99
+ if (!result.success) {
89
100
  return {
90
101
  isValid: false,
91
- error: `Invalid URL format: ${validation.error.details[0].message}`,
102
+ error: `Invalid URL format: ${result.error.issues[0].message}`,
92
103
  };
93
104
  }
94
105