sbuilder-mcp 0.82.1 → 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 +7 -0
- package/CHANGELOG.vi.md +7 -0
- package/dist/transport/media.js +62 -13
- 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.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
|
+
|
|
9
16
|
## [0.82.1] - 2026-10-10
|
|
10
17
|
|
|
11
18
|
### Fixed
|
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.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
|
+
|
|
9
16
|
## [0.82.1] - 2026-10-10
|
|
10
17
|
|
|
11
18
|
### Fixed
|
package/dist/transport/media.js
CHANGED
|
@@ -2,6 +2,8 @@ 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';
|
|
5
7
|
import { ApiError } from './http.js';
|
|
6
8
|
import { siteToken } from '../tools/credentialpick.js';
|
|
7
9
|
/**
|
|
@@ -58,6 +60,7 @@ const TYPE_BY_EXT = {
|
|
|
58
60
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
59
61
|
'.csv': 'text/csv',
|
|
60
62
|
};
|
|
63
|
+
const PDF_EXT = new Set(['.pdf']);
|
|
61
64
|
/** What a local `path` may be: the media types this server names, nothing else. */
|
|
62
65
|
const MEDIA_EXT = new Set(Object.keys(TYPE_BY_EXT).filter((e) => !['.xlsx', '.xls', '.csv'].includes(e)).concat('.pdf'));
|
|
63
66
|
export function typeForName(name) {
|
|
@@ -129,6 +132,59 @@ async function fromUrl(ctx, siteId, url, source) {
|
|
|
129
132
|
}
|
|
130
133
|
return uploaded(parsed);
|
|
131
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
|
+
}
|
|
132
188
|
export async function uploadMedia(ctx, siteId, source) {
|
|
133
189
|
const doFetch = ctx.fetchImpl ?? fetch;
|
|
134
190
|
// THE SERVER'S OWN DOOR FIRST, when the source is a URL.
|
|
@@ -153,22 +209,15 @@ export async function uploadMedia(ctx, siteId, source) {
|
|
|
153
209
|
// URL. `name` still becomes the library's display name (the form field below).
|
|
154
210
|
// A PDF is where the sensitive documents live (a tax return, a passport scan),
|
|
155
211
|
// so it comes only from the working or temp directory; images from anywhere.
|
|
156
|
-
|
|
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 });
|
|
157
216
|
bytes = await readFile(real);
|
|
158
217
|
filename = basename(real);
|
|
159
218
|
}
|
|
160
219
|
else if (source.url) {
|
|
161
|
-
|
|
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);
|
|
220
|
+
const res = await fetchPublic(ctx, doFetch, source.url);
|
|
172
221
|
if (!res.ok) {
|
|
173
222
|
throw new ApiError(res.status, 'source_unreachable', `could not fetch ${source.url}`);
|
|
174
223
|
}
|
|
@@ -179,7 +228,7 @@ export async function uploadMedia(ctx, siteId, source) {
|
|
|
179
228
|
// CDN link), name it rather than uploading something called "".
|
|
180
229
|
// The type comes from the response or the URL's own name, never from `name`
|
|
181
230
|
// (still sent as the display name below).
|
|
182
|
-
filename =
|
|
231
|
+
filename = new URL(source.url).pathname.split('/').pop() || 'image';
|
|
183
232
|
}
|
|
184
233
|
else {
|
|
185
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.
|
|
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",
|