@blocofy/cli 0.17.0 → 0.17.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/README.md +12 -0
- package/bin/blocofy.mjs +3 -2
- package/lib/dev-server.mjs +61 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -132,6 +132,9 @@ and editor views for this command are retired; to share a draft page, create a p
|
|
|
132
132
|
it (v1 API `POST /themes/{id}/preview-links` or MCP `create_preview_link`; one page per link).
|
|
133
133
|
The target site is verified once at start; a long session keeps that target (restart it after
|
|
134
134
|
changing credentials, context or the project binding).
|
|
135
|
+
The dev server answers this machine only: it listens on `http://127.0.0.1:<port>`, refuses a request
|
|
136
|
+
whose `Host` (or `Origin`, when sent) is not that address or `localhost:<port>`, and answers only
|
|
137
|
+
`GET`/`HEAD` (anything else gets `405`). The reload channel is same-origin only.
|
|
135
138
|
- `--port <n>` — local port (default 3030).
|
|
136
139
|
- `--no-sync` — local preview only (skip draft sync).
|
|
137
140
|
- `--name <name>` — name the draft when it is first created (ignored if it already exists).
|
|
@@ -623,6 +626,15 @@ Each retry prints a notice on stderr.
|
|
|
623
626
|
|
|
624
627
|
## Changelog
|
|
625
628
|
|
|
629
|
+
- **0.17.1** (2026-11-01) — Security fix: `theme dev` answers this machine only.
|
|
630
|
+
- The local server listens on IPv4 loopback (`127.0.0.1`) instead of every network interface, and
|
|
631
|
+
prints that address. There is no option to open it to the network.
|
|
632
|
+
- A request whose `Host` is not `127.0.0.1:<port>` / `localhost:<port>`, or whose `Origin` is another
|
|
633
|
+
site, is refused before anything is read or rendered (DNS-rebinding and cross-site pages).
|
|
634
|
+
- Only `GET` and `HEAD` are answered; other methods get `405` with `Allow: GET, HEAD` and never reach
|
|
635
|
+
the platform. `HEAD` returns headers only and never spends a render.
|
|
636
|
+
- The reload channel no longer sends `Access-Control-Allow-Origin: *`; the local page still reloads on
|
|
637
|
+
save. Views on other origins no longer subscribe to it.
|
|
626
638
|
- **0.17.0** (2026-10-31) — `theme publish` tells you when the live site is not serving the files
|
|
627
639
|
you published.
|
|
628
640
|
- **`theme publish` reports an unrefreshed live site.** When the platform answers
|
package/bin/blocofy.mjs
CHANGED
|
@@ -2596,7 +2596,8 @@ async function themeDev(rest) {
|
|
|
2596
2596
|
}
|
|
2597
2597
|
}
|
|
2598
2598
|
|
|
2599
|
-
|
|
2599
|
+
// The URL the dev server actually binds (IPv4 loopback): `localhost` may resolve to ::1 first.
|
|
2600
|
+
const localUrl = `http://127.0.0.1:${port}`;
|
|
2600
2601
|
// A session without a URL (an older platform answer) must not become "undefined&hr=…".
|
|
2601
2602
|
const previewUrl = session?.previewUrl ? `${session.previewUrl}&hr=${port}` : null;
|
|
2602
2603
|
const editorUrl = session?.editorUrl ? `${session.editorUrl}&hr=${port}` : null;
|
|
@@ -2616,7 +2617,7 @@ async function themeDev(rest) {
|
|
|
2616
2617
|
.filter(Boolean)
|
|
2617
2618
|
.join(" ");
|
|
2618
2619
|
console.log(`\n Press: ${keys}`);
|
|
2619
|
-
console.log(` Edit a theme file and save —
|
|
2620
|
+
console.log(` Edit a theme file and save — the local view reloads automatically.\n`);
|
|
2620
2621
|
|
|
2621
2622
|
// Senkron kapsamı (#119 CLI bulgu #2): hangi dizinler taşınıyor / taşınmıyor —
|
|
2622
2623
|
// "config/pages senkronlanıyor sandım" karışıklığını açıkça önler.
|
package/lib/dev-server.mjs
CHANGED
|
@@ -11,6 +11,43 @@ import {
|
|
|
11
11
|
} from "./local-theme.mjs";
|
|
12
12
|
import { DRAFT_TARGET_AMBIGUOUS, draftSyncErrorLine, pushTheme } from "./theme-sync.mjs";
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The dev server proxies page renders with the developer's bearer, so it answers only this machine:
|
|
16
|
+
* it binds IPv4 loopback, and every request must name it (Host + optional Origin) before any branch runs.
|
|
17
|
+
* There is no LAN option on purpose — exposing it needs its own auth/threat model.
|
|
18
|
+
*/
|
|
19
|
+
const DEV_SERVER_HOST = "127.0.0.1";
|
|
20
|
+
const ALLOWED_METHODS = "GET, HEAD";
|
|
21
|
+
|
|
22
|
+
/** Why `req` is refused (status + short reason; never echoes request data or the credential), or null. */
|
|
23
|
+
function refusal(req, port) {
|
|
24
|
+
if (req.method !== "GET" && req.method !== "HEAD") return { status: 405, reason: "Method not allowed." };
|
|
25
|
+
// Origin-form only: an absolute request target (proxy style) could name another authority.
|
|
26
|
+
if (typeof req.url !== "string" || !req.url.startsWith("/")) return { status: 400, reason: "Bad request target." };
|
|
27
|
+
const ownHosts = new Set([`${DEV_SERVER_HOST}:${port}`, `localhost:${port}`]);
|
|
28
|
+
const ownOrigins = new Set([...ownHosts].map((host) => `http://${host}`));
|
|
29
|
+
// rawHeaders keeps duplicates that req.headers would silently collapse.
|
|
30
|
+
const headerValues = (name) => {
|
|
31
|
+
const values = [];
|
|
32
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
33
|
+
if (req.rawHeaders[i].toLowerCase() === name) values.push(req.rawHeaders[i + 1].trim().toLowerCase());
|
|
34
|
+
}
|
|
35
|
+
return values;
|
|
36
|
+
};
|
|
37
|
+
// Exactly one Host, and it is this server: a DNS-rebinding page arrives with its own host name.
|
|
38
|
+
// X-Forwarded-Host / Forwarded / Referer are never read.
|
|
39
|
+
const hosts = headerValues("host");
|
|
40
|
+
if (hosts.length !== 1 || !ownHosts.has(hosts[0])) {
|
|
41
|
+
return { status: 403, reason: `This dev server only answers http://${DEV_SERVER_HOST}:${port}.` };
|
|
42
|
+
}
|
|
43
|
+
// A browser that sends Origin must be on this server's own origin (cross-origin pages and `null` refused).
|
|
44
|
+
const origins = headerValues("origin");
|
|
45
|
+
if (origins.length > 1 || (origins.length === 1 && !ownOrigins.has(origins[0]))) {
|
|
46
|
+
return { status: 403, reason: `Cross-origin requests are not served; open http://${DEV_SERVER_HOST}:${port}.` };
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
14
51
|
/**
|
|
15
52
|
* Standalone dev server. For each page request it reads the local theme files and
|
|
16
53
|
* posts them to the platform's `/api/dev/render` endpoint, then injects a livereload
|
|
@@ -105,18 +142,29 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
|
|
|
105
142
|
}
|
|
106
143
|
|
|
107
144
|
const server = createServer(async (req, res) => {
|
|
145
|
+
// Before every branch: no SSE subscription, disk read or authenticated render for a refused request.
|
|
146
|
+
const refused = refusal(req, server.address()?.port);
|
|
147
|
+
if (refused) {
|
|
148
|
+
res.writeHead(refused.status, {
|
|
149
|
+
"content-type": "text/plain; charset=utf-8",
|
|
150
|
+
"cache-control": "no-store",
|
|
151
|
+
...(refused.status === 405 ? { allow: ALLOWED_METHODS } : {}),
|
|
152
|
+
});
|
|
153
|
+
res.end(`${refused.reason}\n`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const head = req.method === "HEAD";
|
|
108
157
|
try {
|
|
109
158
|
const pathname = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
|
|
110
159
|
|
|
111
|
-
// Livereload SSE channel.
|
|
160
|
+
// Livereload SSE channel — same-origin only (no CORS header): cross-origin views no longer subscribe.
|
|
112
161
|
if (pathname === LIVERELOAD_PATH) {
|
|
113
162
|
res.writeHead(200, {
|
|
114
163
|
"content-type": "text/event-stream",
|
|
115
164
|
"cache-control": "no-cache, no-transform",
|
|
116
|
-
connection: "keep-alive",
|
|
117
|
-
// Cross-origin iframe'ler (canlı-domain önizleme + editör) subscribe edebilsin.
|
|
118
|
-
"access-control-allow-origin": "*",
|
|
165
|
+
...(head ? {} : { connection: "keep-alive" }),
|
|
119
166
|
});
|
|
167
|
+
if (head) return void res.end();
|
|
120
168
|
res.write("retry: 1000\n\n");
|
|
121
169
|
clients.add(res);
|
|
122
170
|
req.on("close", () => clients.delete(res));
|
|
@@ -127,7 +175,14 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
|
|
|
127
175
|
const assetFile = safeAssetFile(dir, pathname);
|
|
128
176
|
if (assetFile) {
|
|
129
177
|
res.writeHead(200, { "content-type": contentTypeFor(assetFile), "cache-control": "no-store" });
|
|
130
|
-
res.end(readFileSync(assetFile));
|
|
178
|
+
res.end(head ? undefined : readFileSync(assetFile));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// HEAD never spends an authenticated render: headers only.
|
|
183
|
+
if (head) {
|
|
184
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
185
|
+
res.end();
|
|
131
186
|
return;
|
|
132
187
|
}
|
|
133
188
|
|
|
@@ -166,7 +221,7 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
|
|
|
166
221
|
onReload?.({ file, synced: syncDraft && !error, clients: clients.size, error });
|
|
167
222
|
});
|
|
168
223
|
|
|
169
|
-
server.listen(port);
|
|
224
|
+
server.listen(port, DEV_SERVER_HOST);
|
|
170
225
|
|
|
171
226
|
return {
|
|
172
227
|
server,
|
package/package.json
CHANGED