sbuilder-mcp 0.82.1 → 0.82.3

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/CHANGELOG.md CHANGED
@@ -6,6 +6,19 @@ All notable changes to this project are documented in this file.
6
6
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [0.82.3] - 2026-10-10
10
+
11
+ ### Fixed
12
+ - `sb_media_upload` with `url` pins the address check to the connection itself, so a DNS answer that changes between the check and the connect (DNS rebinding) can no longer reach a private or loopback host.
13
+ - `sb_media_upload` with `url` sends a user-agent header when it fetches a file, so CDNs that answer 403 without one now serve the download.
14
+
15
+ ## [0.82.2] - 2026-10-10
16
+
17
+ ### Fixed
18
+ - `sb_media_upload` with `url` resolves the host's DNS and refuses the fetch if any address is private, loopback, link-local, cloud-metadata or otherwise special (IPv4 and IPv6, including `::ffff:127.0.0.1`, 6to4 and Teredo forms).
19
+ - `sb_media_upload` with `url` follows redirects one step at a time, re-checking every hop against the same address rules and stopping after four.
20
+ - `sb_media_upload` with `path` restricts `.pdf` files to the allowed directories by the real file name rather than the name given.
21
+
9
22
  ## [0.82.1] - 2026-10-10
10
23
 
11
24
  ### Fixed
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,19 @@ Mọi thay đổi đáng chú ý của dự án được ghi lại trong file n
6
6
  Định dạng dựa trên [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
  và dự án tuân theo [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [0.82.3] - 2026-10-10
10
+
11
+ ### Fixed
12
+ - `sb_media_upload` với `url` ghim bước kiểm tra địa chỉ vào chính kết nối, nên câu trả lời DNS thay đổi giữa lúc kiểm tra và lúc kết nối (DNS rebinding) không còn dẫn tới host riêng hoặc loopback.
13
+ - `sb_media_upload` với `url` gửi header user-agent khi tải file, nên các CDN trả 403 khi thiếu header này giờ cho tải bình thường.
14
+
15
+ ## [0.82.2] - 2026-10-10
16
+
17
+ ### Fixed
18
+ - `sb_media_upload` với `url` phân giải DNS của host và từ chối tải nếu bất kỳ địa chỉ nào thuộc mạng riêng, loopback, link-local, metadata cloud hoặc dải đặc biệt khác (IPv4 và IPv6, gồm `::ffff:127.0.0.1`, 6to4 và Teredo).
19
+ - `sb_media_upload` với `url` đi theo redirect từng bước, kiểm tra lại mỗi bước theo cùng quy tắc địa chỉ và dừng sau bốn lần.
20
+ - `sb_media_upload` với `path` giới hạn file `.pdf` trong các thư mục được phép theo tên file thật chứ không theo tên được truyền vào.
21
+
9
22
  ## [0.82.1] - 2026-10-10
10
23
 
11
24
  ### Fixed
@@ -2,6 +2,10 @@ import { identityHeaders } from './identity.js';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { guardLocal } from './localfile.js';
4
4
  import { basename, extname } from 'node:path';
5
+ import { lookup } from 'node:dns/promises';
6
+ import { BlockList, isIP } from 'node:net';
7
+ import { request as httpRequest } from 'node:http';
8
+ import { request as httpsRequest } from 'node:https';
5
9
  import { ApiError } from './http.js';
6
10
  import { siteToken } from '../tools/credentialpick.js';
7
11
  /**
@@ -58,6 +62,7 @@ const TYPE_BY_EXT = {
58
62
  '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
59
63
  '.csv': 'text/csv',
60
64
  };
65
+ const PDF_EXT = new Set(['.pdf']);
61
66
  /** What a local `path` may be: the media types this server names, nothing else. */
62
67
  const MEDIA_EXT = new Set(Object.keys(TYPE_BY_EXT).filter((e) => !['.xlsx', '.xls', '.csv'].includes(e)).concat('.pdf'));
63
68
  export function typeForName(name) {
@@ -129,6 +134,101 @@ async function fromUrl(ctx, siteId, url, source) {
129
134
  }
130
135
  return uploaded(parsed);
131
136
  }
137
+ /**
138
+ * WHEN THE PLATFORM CANNOT FETCH A URL ITSELF, THIS MACHINE DOES — and an agent can
139
+ * be talked into naming any URL. A private address here reaches the user's own
140
+ * network (cloud metadata at 169.254.169.254, a dev server on localhost) and the
141
+ * bytes are re-uploaded to a public CDN. So every address the host resolves to is
142
+ * checked — a hostname regex is beaten by `[::ffff:127.0.0.1]` or a DNS name that
143
+ * points inward — and a redirect is followed by hand, each hop checked again.
144
+ *
145
+ * The check is PINNED to the connection: the real fetch goes through node:http(s)
146
+ * with a `lookup` that checks the very address it hands to the socket, so a DNS
147
+ * answer that changes between "check" and "connect" (rebinding) still meets the
148
+ * check. Only an injected `ctx.fetchImpl` (tests) skips that path.
149
+ */
150
+ // No ::ffff:0:0/96 rule: Node's BlockList already applies the IPv4 rules to an
151
+ // IPv4-mapped address, and that rule would match EVERY IPv4 address (measured).
152
+ const PRIVATE = new BlockList();
153
+ for (const [net, bits] of [
154
+ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16],
155
+ ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['224.0.0.0', 3],
156
+ ])
157
+ PRIVATE.addSubnet(net, bits, 'ipv4');
158
+ for (const [net, bits] of [
159
+ // ::/96 (IPv4-compatible), 6to4, Teredo and old site-local carry or reach an IPv4 inside.
160
+ ['::', 96], ['2002::', 16], ['2001::', 32], ['fec0::', 10], ['64:ff9b::', 96], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8],
161
+ ])
162
+ PRIVATE.addSubnet(net, bits, 'ipv6');
163
+ async function assertPublic(ctx, raw) {
164
+ const u = new URL(raw);
165
+ const host = u.hostname.replace(/^\[|\]$/g, '');
166
+ const refuse = () => new Error('sbuilder: a media url must be a public http(s) address.');
167
+ if (!/^https?:$/.test(u.protocol))
168
+ throw refuse();
169
+ const addrs = isIP(host)
170
+ ? [host]
171
+ : await (ctx.lookupHost ?? (async (h) => (await lookup(h, { all: true })).map((a) => a.address)))(host).catch(() => {
172
+ throw new Error(`sbuilder: could not resolve ${host}.`);
173
+ });
174
+ for (const a of addrs) {
175
+ if (isPrivate(a))
176
+ throw refuse();
177
+ }
178
+ }
179
+ const isPrivate = (a) => PRIVATE.check(a, isIP(a) === 6 ? 'ipv6' : 'ipv4');
180
+ /** A `lookup` for node:net that refuses to hand a private address to the socket. */
181
+ export function pinnedLookup(resolve = (h) => lookup(h, { all: true })) {
182
+ return (host, opts, cb) => {
183
+ resolve(host).then((addrs) => {
184
+ if (!addrs.length || addrs.some((a) => isPrivate(a.address))) {
185
+ cb(new Error('sbuilder: a media url must be a public http(s) address.'));
186
+ }
187
+ else if (opts.all)
188
+ cb(null, addrs);
189
+ else
190
+ cb(null, addrs[0].address, addrs[0].family);
191
+ }, (e) => cb(e));
192
+ };
193
+ }
194
+ const NULL_BODY = new Set([101, 204, 205, 304]);
195
+ function pinnedFetch(url) {
196
+ const u = new URL(url);
197
+ const send = u.protocol === 'https:' ? httpsRequest : httpRequest;
198
+ return new Promise((resolve, reject) => {
199
+ // The headers global fetch sends: many CDNs answer 403 to a request with no agent.
200
+ const headers = { 'user-agent': 'sbuilder-mcp', accept: '*/*' };
201
+ const req = send(u, { method: 'GET', headers, lookup: pinnedLookup() }, (res) => {
202
+ const chunks = [];
203
+ res.on('data', (c) => chunks.push(c));
204
+ res.on('error', reject);
205
+ res.on('end', () => {
206
+ const headers = new Headers();
207
+ for (const [k, v] of Object.entries(res.headers)) {
208
+ if (v !== undefined)
209
+ headers.set(k, Array.isArray(v) ? v.join(', ') : v);
210
+ }
211
+ const status = res.statusCode ?? 502;
212
+ resolve(new Response(NULL_BODY.has(status) ? null : Buffer.concat(chunks), { status, headers }));
213
+ });
214
+ });
215
+ req.setTimeout(30_000, () => req.destroy(new Error('sbuilder: the media url timed out.')));
216
+ req.on('error', reject);
217
+ req.end();
218
+ });
219
+ }
220
+ export async function fetchPublic(ctx, doFetch, url) {
221
+ let at = url;
222
+ for (let hop = 0; hop < 4; hop++) {
223
+ await assertPublic(ctx, at);
224
+ const res = ctx.fetchImpl ? await doFetch(at, { redirect: 'manual' }) : await pinnedFetch(at);
225
+ const next = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;
226
+ if (!next)
227
+ return res;
228
+ at = new URL(next, at).toString();
229
+ }
230
+ throw new Error('sbuilder: too many redirects fetching the media url.');
231
+ }
132
232
  export async function uploadMedia(ctx, siteId, source) {
133
233
  const doFetch = ctx.fetchImpl ?? fetch;
134
234
  // THE SERVER'S OWN DOOR FIRST, when the source is a URL.
@@ -153,22 +253,15 @@ export async function uploadMedia(ctx, siteId, source) {
153
253
  // URL. `name` still becomes the library's display name (the form field below).
154
254
  // A PDF is where the sensitive documents live (a tax return, a passport scan),
155
255
  // so it comes only from the working or temp directory; images from anywhere.
156
- const real = await guardLocal(source.path, MEDIA_EXT, { confine: extname(source.path).toLowerCase() === '.pdf' });
256
+ let real = await guardLocal(source.path, MEDIA_EXT);
257
+ // Decided on the REAL name: a `pic.png` symlink to a PDF is a PDF.
258
+ if (extname(real).toLowerCase() === '.pdf')
259
+ real = await guardLocal(real, PDF_EXT, { confine: true });
157
260
  bytes = await readFile(real);
158
261
  filename = basename(real);
159
262
  }
160
263
  else if (source.url) {
161
- // THIS fetch runs on the USER's machine (the server's own door was missing), so a
162
- // private address here reaches their network — cloud metadata, a dev server —
163
- // and re-uploads it to a public URL. Public http(s) only.
164
- // ponytail: hostname check, not resolved-IP; DNS rebinding needs a resolve-and-check.
165
- const u = new URL(source.url);
166
- if (!/^https?:$/.test(u.protocol) ||
167
- /^(localhost|127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|0\.0\.0\.0|\[?::1\]?$|\[?f[cd][0-9a-f]{2}:|\[?fe80:)/i.test(u.hostname) ||
168
- u.hostname.endsWith('.local') || u.hostname.endsWith('.internal')) {
169
- throw new Error('sbuilder: a media url must be a public http(s) address.');
170
- }
171
- const res = await doFetch(source.url);
264
+ const res = await fetchPublic(ctx, doFetch, source.url);
172
265
  if (!res.ok) {
173
266
  throw new ApiError(res.status, 'source_unreachable', `could not fetch ${source.url}`);
174
267
  }
@@ -179,7 +272,7 @@ export async function uploadMedia(ctx, siteId, source) {
179
272
  // CDN link), name it rather than uploading something called "".
180
273
  // The type comes from the response or the URL's own name, never from `name`
181
274
  // (still sent as the display name below).
182
- filename = u.pathname.split('/').pop() || 'image';
275
+ filename = new URL(source.url).pathname.split('/').pop() || 'image';
183
276
  }
184
277
  else {
185
278
  throw new Error('sbuilder: give sb_media_upload either a local path or a url');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.82.1",
3
+ "version": "0.82.3",
4
4
  "description": "MCP server that designs and operates a Store Builder site — pages, data, theme and publish — through the platform's own API and live-edit protocol.",
5
5
  "mcpName": "io.github.vuluu2k/sbuilder-mcp",
6
6
  "type": "module",