sbuilder-mcp 0.82.0 → 0.82.2

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,20 @@ 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.2] - 2026-10-10
10
+
11
+ ### Fixed
12
+ - `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).
13
+ - `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.
14
+ - `sb_media_upload` with `path` restricts `.pdf` files to the allowed directories by the real file name rather than the name given.
15
+
16
+ ## [0.82.1] - 2026-10-10
17
+
18
+ ### Fixed
19
+ - `sb_api_call` with `file` now refuses to read or write anything but catalogue spreadsheets (`.xlsx`, `.xls`, `.csv` for upload; `.xlsx`, `.csv`, `.pdf` for download), checked on the real file after symlinks resolve and only inside the working directory or a temp directory, and never a dotfile or dot-directory.
20
+ - `sb_media_upload` with `path` accepts only media files judged on the real file rather than the `name` argument, with `.pdf` allowed only from the same two directories, so a secret file renamed to `x.png` no longer reaches the public CDN.
21
+ - `sb_media_upload` with `url` fetches only public http(s) addresses when the platform cannot download them itself, refusing loopback, private-network, link-local and cloud-metadata hosts, and no longer takes the file type from `name`.
22
+
9
23
  ## [0.82.0] - 2026-10-10
10
24
 
11
25
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,20 @@ 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.2] - 2026-10-10
10
+
11
+ ### Fixed
12
+ - `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).
13
+ - `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.
14
+ - `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.
15
+
16
+ ## [0.82.1] - 2026-10-10
17
+
18
+ ### Fixed
19
+ - `sb_api_call` với `file` từ chối đọc hoặc ghi mọi thứ ngoài bảng tính catalogue (`.xlsx`, `.xls`, `.csv` khi upload; `.xlsx`, `.csv`, `.pdf` khi tải về), kiểm tra trên file thật sau khi giải symlink, chỉ trong thư mục làm việc hoặc thư mục tạm, và không bao giờ nhận file hay thư mục bắt đầu bằng dấu chấm.
20
+ - `sb_media_upload` với `path` chỉ nhận file media, xét trên file thật chứ không theo tham số `name`, và chỉ cho `.pdf` từ hai thư mục trên, nên file bí mật đổi tên thành `x.png` không còn lên được CDN công khai.
21
+ - `sb_media_upload` với `url` chỉ tải các địa chỉ http(s) công khai khi platform không tự tải được, từ chối loopback, mạng riêng, link-local và metadata cloud, và không còn lấy kiểu file từ `name`.
22
+
9
23
  ## [0.82.0] - 2026-10-10
10
24
 
11
25
  ### Added
package/dist/tools/api.js CHANGED
@@ -4,6 +4,7 @@ import { REQUEST_SHAPES } from '../catalog/shapes.generated.js';
4
4
  import { searchOperations, describeOperation, summarizeOperation, findOperation, } from '../catalog/search.js';
5
5
  import { readFile, stat, writeFile } from 'node:fs/promises';
6
6
  import { basename } from 'node:path';
7
+ import { guardLocal } from '../transport/localfile.js';
7
8
  import { request, redact, ApiError } from '../transport/http.js';
8
9
  import { typeForName } from '../transport/media.js';
9
10
  import { restoreBodyFrom } from './undo.js';
@@ -36,6 +37,13 @@ const quoteVersion = (v) => {
36
37
  const SHIPPING_MOVED_FIX = ' — this site\'s shipping lives in the rules engine now: edit it with PATCH ' +
37
38
  '/api/sites/{siteId}/shipping-config (sb_api_call reads If-Match for you), then verify with ' +
38
39
  'POST /api/sites/{siteId}/shipping-config/preview.';
40
+ /**
41
+ * `file` exists for one job — a catalogue spreadsheet in, a template out — and is
42
+ * no wider than that: see `guardLocal` for why a path argument is hostile input.
43
+ */
44
+ const UPLOAD_EXT = new Set(['.xlsx', '.xls', '.csv']);
45
+ // No .json (config files that run code) and no .xls (macros) on the way OUT.
46
+ const DOWNLOAD_EXT = new Set(['.xlsx', '.csv', '.pdf']);
39
47
  const isPlain = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
40
48
  /** `over` wins; objects merge key by key; arrays and scalars replace. */
41
49
  export function deepMerge(base, over) {
@@ -617,6 +625,10 @@ export async function callOperation(ctx, args) {
617
625
  // with `file` saves the answer's bytes (an xlsx template) rather than
618
626
  // pasting binary into the result.
619
627
  const download = !!args.file && (op.method === 'GET' || op.method === 'HEAD');
628
+ if (args.file) {
629
+ const path = await guardLocal(args.file.path, download ? DOWNLOAD_EXT : UPLOAD_EXT, { write: download, confine: true });
630
+ args.file = { ...args.file, path };
631
+ }
620
632
  if (download && !dryRun && (await stat(args.file.path).then(() => true, () => false))) {
621
633
  throw new Error(`sbuilder: ${args.file.path} already exists — nothing was overwritten; name a new path.`);
622
634
  }
@@ -0,0 +1,51 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ /**
5
+ * EVERY DOOR FROM A TOOL ARGUMENT TO THE LOCAL DISK GOES THROUGH HERE.
6
+ *
7
+ * The agent driving these tools can be prompt-injected by content it reads — a
8
+ * page, a product, an order note — so a `path` argument is attacker-chosen. Read,
9
+ * it exfiltrates (`~/.ssh/id_rsa` uploaded under `name:"x.png"` gets a public CDN
10
+ * URL); written, it plants code (`.claude/settings.local.json`, `.mcp.json`,
11
+ * `.vscode/tasks.json` all run something, and `wx` stops only an overwrite).
12
+ *
13
+ * So: the extension is checked on the REAL file (symlinks resolved, never the
14
+ * caller's `name`), and the returned path is the resolved one, so what was
15
+ * checked is what is opened. `confine` additionally keeps the file under the
16
+ * working directory or the OS temp directory — not the working directory when it
17
+ * IS the home directory — with no dot-file or dot-folder below that root.
18
+ *
19
+ * ponytail: a local attacker racing a symlink swap between this check and the
20
+ * open is outside the threat model; O_NOFOLLOW on the open is the upgrade.
21
+ */
22
+ export async function guardLocal(path, exts, opts = {}) {
23
+ const abs = resolve(path);
24
+ const shown = [...exts].join(', ');
25
+ let real;
26
+ try {
27
+ // A write names a file that does not exist yet: resolve its folder instead.
28
+ real = opts.write ? join(await realpath(dirname(abs)), basename(abs)) : await realpath(abs);
29
+ }
30
+ catch {
31
+ throw new Error(`sbuilder: ${opts.write ? `the folder for ${abs}` : abs} does not exist.`);
32
+ }
33
+ if (!exts.has(extname(real).toLowerCase())) {
34
+ throw new Error(`sbuilder: the file must be one of ${shown} — ${basename(real)} is not.`);
35
+ }
36
+ if (opts.confine) {
37
+ const home = await realpath(homedir()).catch(() => homedir());
38
+ const cwd = await realpath(process.cwd()).catch(() => process.cwd());
39
+ // Not cwd when it IS home, or holds it (`/Users`): that root would reach all of home.
40
+ const holdsHome = cwd === home || home.startsWith(cwd.endsWith(sep) ? cwd : cwd + sep);
41
+ const roots = [...(holdsHome ? [] : [cwd]), await realpath(tmpdir()).catch(() => tmpdir())];
42
+ const root = roots.find((r) => real.startsWith(r + sep));
43
+ if (!root) {
44
+ throw new Error(`sbuilder: this file must be under the working directory or ${tmpdir()} — not ${real}.`);
45
+ }
46
+ if (relative(root, real).split(sep).some((s) => s.startsWith('.'))) {
47
+ throw new Error('sbuilder: never a dot-file or a file in a dot-folder.');
48
+ }
49
+ }
50
+ return real;
51
+ }
@@ -1,6 +1,9 @@
1
1
  import { identityHeaders } from './identity.js';
2
2
  import { readFile } from 'node:fs/promises';
3
- import { basename } from 'node:path';
3
+ import { guardLocal } from './localfile.js';
4
+ import { basename, extname } from 'node:path';
5
+ import { lookup } from 'node:dns/promises';
6
+ import { BlockList, isIP } from 'node:net';
4
7
  import { ApiError } from './http.js';
5
8
  import { siteToken } from '../tools/credentialpick.js';
6
9
  /**
@@ -57,6 +60,9 @@ const TYPE_BY_EXT = {
57
60
  '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
58
61
  '.csv': 'text/csv',
59
62
  };
63
+ const PDF_EXT = new Set(['.pdf']);
64
+ /** What a local `path` may be: the media types this server names, nothing else. */
65
+ const MEDIA_EXT = new Set(Object.keys(TYPE_BY_EXT).filter((e) => !['.xlsx', '.xls', '.csv'].includes(e)).concat('.pdf'));
60
66
  export function typeForName(name) {
61
67
  const dot = name.lastIndexOf('.');
62
68
  return dot < 0 ? '' : (TYPE_BY_EXT[name.slice(dot).toLowerCase()] ?? '');
@@ -126,6 +132,59 @@ async function fromUrl(ctx, siteId, url, source) {
126
132
  }
127
133
  return uploaded(parsed);
128
134
  }
135
+ /**
136
+ * WHEN THE PLATFORM CANNOT FETCH A URL ITSELF, THIS MACHINE DOES — and an agent can
137
+ * be talked into naming any URL. A private address here reaches the user's own
138
+ * network (cloud metadata at 169.254.169.254, a dev server on localhost) and the
139
+ * bytes are re-uploaded to a public CDN. So every address the host resolves to is
140
+ * checked — a hostname regex is beaten by `[::ffff:127.0.0.1]` or a DNS name that
141
+ * points inward — and a redirect is followed by hand, each hop checked again.
142
+ *
143
+ * ponytail: resolve-then-fetch leaves a DNS-rebinding window between the two
144
+ * lookups; pinning the checked IP into the connection (an undici dispatcher) is the
145
+ * upgrade.
146
+ */
147
+ // No ::ffff:0:0/96 rule: Node's BlockList already applies the IPv4 rules to an
148
+ // IPv4-mapped address, and that rule would match EVERY IPv4 address (measured).
149
+ const PRIVATE = new BlockList();
150
+ for (const [net, bits] of [
151
+ ['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],
152
+ ['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],
153
+ ])
154
+ PRIVATE.addSubnet(net, bits, 'ipv4');
155
+ for (const [net, bits] of [
156
+ // ::/96 (IPv4-compatible), 6to4, Teredo and old site-local carry or reach an IPv4 inside.
157
+ ['::', 96], ['2002::', 16], ['2001::', 32], ['fec0::', 10], ['64:ff9b::', 96], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8],
158
+ ])
159
+ PRIVATE.addSubnet(net, bits, 'ipv6');
160
+ async function assertPublic(ctx, raw) {
161
+ const u = new URL(raw);
162
+ const host = u.hostname.replace(/^\[|\]$/g, '');
163
+ const refuse = () => new Error('sbuilder: a media url must be a public http(s) address.');
164
+ if (!/^https?:$/.test(u.protocol))
165
+ throw refuse();
166
+ const addrs = isIP(host)
167
+ ? [host]
168
+ : await (ctx.lookupHost ?? (async (h) => (await lookup(h, { all: true })).map((a) => a.address)))(host).catch(() => {
169
+ throw new Error(`sbuilder: could not resolve ${host}.`);
170
+ });
171
+ for (const a of addrs) {
172
+ if (PRIVATE.check(a, isIP(a) === 6 ? 'ipv6' : 'ipv4'))
173
+ throw refuse();
174
+ }
175
+ }
176
+ async function fetchPublic(ctx, doFetch, url) {
177
+ let at = url;
178
+ for (let hop = 0; hop < 4; hop++) {
179
+ await assertPublic(ctx, at);
180
+ const res = await doFetch(at, { redirect: 'manual' });
181
+ const next = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;
182
+ if (!next)
183
+ return res;
184
+ at = new URL(next, at).toString();
185
+ }
186
+ throw new Error('sbuilder: too many redirects fetching the media url.');
187
+ }
129
188
  export async function uploadMedia(ctx, siteId, source) {
130
189
  const doFetch = ctx.fetchImpl ?? fetch;
131
190
  // THE SERVER'S OWN DOOR FIRST, when the source is a URL.
@@ -145,11 +204,20 @@ export async function uploadMedia(ctx, siteId, source) {
145
204
  let filename;
146
205
  let declared = '';
147
206
  if (source.path) {
148
- bytes = await readFile(source.path);
149
- filename = source.name ?? basename(source.path);
207
+ // The type comes from the REAL file's name, never from `name` — otherwise
208
+ // {path:"~/.ssh/id_rsa", name:"x.png"} uploads a key as an image to a public
209
+ // URL. `name` still becomes the library's display name (the form field below).
210
+ // A PDF is where the sensitive documents live (a tax return, a passport scan),
211
+ // so it comes only from the working or temp directory; images from anywhere.
212
+ let real = await guardLocal(source.path, MEDIA_EXT);
213
+ // Decided on the REAL name: a `pic.png` symlink to a PDF is a PDF.
214
+ if (extname(real).toLowerCase() === '.pdf')
215
+ real = await guardLocal(real, PDF_EXT, { confine: true });
216
+ bytes = await readFile(real);
217
+ filename = basename(real);
150
218
  }
151
219
  else if (source.url) {
152
- const res = await doFetch(source.url);
220
+ const res = await fetchPublic(ctx, doFetch, source.url);
153
221
  if (!res.ok) {
154
222
  throw new ApiError(res.status, 'source_unreachable', `could not fetch ${source.url}`);
155
223
  }
@@ -158,7 +226,9 @@ export async function uploadMedia(ctx, siteId, source) {
158
226
  declared = (res.headers.get('content-type') ?? '').split(';')[0].trim();
159
227
  // A URL's last segment is usually the filename; when it is not (a query-only
160
228
  // CDN link), name it rather than uploading something called "".
161
- filename = source.name ?? (new URL(source.url).pathname.split('/').pop() || 'image');
229
+ // The type comes from the response or the URL's own name, never from `name`
230
+ // (still sent as the display name below).
231
+ filename = new URL(source.url).pathname.split('/').pop() || 'image';
162
232
  }
163
233
  else {
164
234
  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.0",
3
+ "version": "0.82.2",
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",