sbuilder-mcp 0.82.0 → 0.82.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/CHANGELOG.md +7 -0
- package/CHANGELOG.vi.md +7 -0
- package/dist/tools/api.js +12 -0
- package/dist/transport/localfile.js +51 -0
- package/dist/transport/media.js +25 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,13 @@ 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.1] - 2026-10-10
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- `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.
|
|
13
|
+
- `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.
|
|
14
|
+
- `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`.
|
|
15
|
+
|
|
9
16
|
## [0.82.0] - 2026-10-10
|
|
10
17
|
|
|
11
18
|
### Added
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,13 @@ 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.1] - 2026-10-10
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- `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.
|
|
13
|
+
- `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.
|
|
14
|
+
- `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`.
|
|
15
|
+
|
|
9
16
|
## [0.82.0] - 2026-10-10
|
|
10
17
|
|
|
11
18
|
### 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
|
+
}
|
package/dist/transport/media.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { identityHeaders } from './identity.js';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
3
|
+
import { guardLocal } from './localfile.js';
|
|
4
|
+
import { basename, extname } from 'node:path';
|
|
4
5
|
import { ApiError } from './http.js';
|
|
5
6
|
import { siteToken } from '../tools/credentialpick.js';
|
|
6
7
|
/**
|
|
@@ -57,6 +58,8 @@ const TYPE_BY_EXT = {
|
|
|
57
58
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
58
59
|
'.csv': 'text/csv',
|
|
59
60
|
};
|
|
61
|
+
/** What a local `path` may be: the media types this server names, nothing else. */
|
|
62
|
+
const MEDIA_EXT = new Set(Object.keys(TYPE_BY_EXT).filter((e) => !['.xlsx', '.xls', '.csv'].includes(e)).concat('.pdf'));
|
|
60
63
|
export function typeForName(name) {
|
|
61
64
|
const dot = name.lastIndexOf('.');
|
|
62
65
|
return dot < 0 ? '' : (TYPE_BY_EXT[name.slice(dot).toLowerCase()] ?? '');
|
|
@@ -145,10 +148,26 @@ export async function uploadMedia(ctx, siteId, source) {
|
|
|
145
148
|
let filename;
|
|
146
149
|
let declared = '';
|
|
147
150
|
if (source.path) {
|
|
148
|
-
|
|
149
|
-
|
|
151
|
+
// The type comes from the REAL file's name, never from `name` — otherwise
|
|
152
|
+
// {path:"~/.ssh/id_rsa", name:"x.png"} uploads a key as an image to a public
|
|
153
|
+
// URL. `name` still becomes the library's display name (the form field below).
|
|
154
|
+
// A PDF is where the sensitive documents live (a tax return, a passport scan),
|
|
155
|
+
// 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' });
|
|
157
|
+
bytes = await readFile(real);
|
|
158
|
+
filename = basename(real);
|
|
150
159
|
}
|
|
151
160
|
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
|
+
}
|
|
152
171
|
const res = await doFetch(source.url);
|
|
153
172
|
if (!res.ok) {
|
|
154
173
|
throw new ApiError(res.status, 'source_unreachable', `could not fetch ${source.url}`);
|
|
@@ -158,7 +177,9 @@ export async function uploadMedia(ctx, siteId, source) {
|
|
|
158
177
|
declared = (res.headers.get('content-type') ?? '').split(';')[0].trim();
|
|
159
178
|
// A URL's last segment is usually the filename; when it is not (a query-only
|
|
160
179
|
// CDN link), name it rather than uploading something called "".
|
|
161
|
-
|
|
180
|
+
// The type comes from the response or the URL's own name, never from `name`
|
|
181
|
+
// (still sent as the display name below).
|
|
182
|
+
filename = u.pathname.split('/').pop() || 'image';
|
|
162
183
|
}
|
|
163
184
|
else {
|
|
164
185
|
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.
|
|
3
|
+
"version": "0.82.1",
|
|
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",
|