seo-intel 1.5.45 → 1.5.46
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 +11 -0
- package/package.json +1 -1
- package/server.js +47 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.46 (2026-05-29)
|
|
4
|
+
|
|
5
|
+
### Security — the local dashboard now accepts requests from localhost only
|
|
6
|
+
Hardened `seo-intel serve` against a class of browser-based attack that affects local web servers in general (cross-site request forgery and DNS rebinding). While the dashboard was running, a web page open in the same browser could send requests to `localhost` — and the command-stream endpoint additionally sent a wildcard `Access-Control-Allow-Origin`, which would have let such a page read its output.
|
|
7
|
+
|
|
8
|
+
- **Loopback-only gate:** every request is now checked at the door — the `Host` must be a loopback name (defeats DNS rebinding) and any `Origin` must be loopback too (blocks cross-origin / CSRF). Non-local requests get `403`.
|
|
9
|
+
- **Removed the wildcard `Access-Control-Allow-Origin: *`** from the terminal SSE stream — the dashboard is same-origin and never needed CORS.
|
|
10
|
+
- **Standard headers added:** `X-Frame-Options: DENY`, `Content-Security-Policy: frame-ancestors 'none'` (anti-clickjacking), `X-Content-Type-Options: nosniff`.
|
|
11
|
+
|
|
12
|
+
The server already bound `127.0.0.1` only; this adds the missing in-app checks. Same-origin dashboard use is unchanged. **Recommended update for anyone who runs `seo-intel serve`.**
|
|
13
|
+
|
|
3
14
|
## 1.5.45 (2026-05-29)
|
|
4
15
|
|
|
5
16
|
### The content loop in one command — `seo-intel loop` + `run_content_loop` (MCP)
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -140,11 +140,55 @@ function getProjects() {
|
|
|
140
140
|
.filter(Boolean);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
// ── Security: loopback-only gate (anti DNS-rebinding + cross-origin/CSRF) ──
|
|
144
|
+
//
|
|
145
|
+
// This server binds 127.0.0.1, but any web page you visit can still fire
|
|
146
|
+
// requests at localhost. Two checks close that whole class:
|
|
147
|
+
// • Host — a DNS-rebinding request arrives carrying the ATTACKER's domain as
|
|
148
|
+
// Host (not localhost), so requiring a loopback Host defeats it.
|
|
149
|
+
// • Origin — a cross-origin page sends its own Origin; requiring a loopback
|
|
150
|
+
// Origin (when present) blocks cross-origin reads and CSRF.
|
|
151
|
+
// Same-origin dashboard use is unaffected: same-origin GET/SSE either sends no
|
|
152
|
+
// Origin or sends our own loopback Origin.
|
|
153
|
+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
154
|
+
|
|
155
|
+
function normHost(h) {
|
|
156
|
+
if (!h) return '';
|
|
157
|
+
h = String(h).trim().toLowerCase();
|
|
158
|
+
if (h.startsWith('[')) { const i = h.indexOf(']'); return i > 0 ? h.slice(1, i) : h.slice(1); } // [::1]:port → ::1
|
|
159
|
+
return h.split(':')[0]; // host:port → host
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isLocalRequest(req) {
|
|
163
|
+
const host = normHost(req.headers.host);
|
|
164
|
+
if (!host || !LOCAL_HOSTS.has(host)) return false; // defeats DNS rebinding
|
|
165
|
+
const origin = req.headers.origin;
|
|
166
|
+
if (origin && origin !== 'null') { // defeats cross-origin / CSRF
|
|
167
|
+
try { if (!LOCAL_HOSTS.has(normHost(new URL(origin).host))) return false; }
|
|
168
|
+
catch { return false; }
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
143
173
|
// ── Request handler ──
|
|
144
174
|
async function handleRequest(req, res) {
|
|
145
175
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
146
176
|
const path = url.pathname;
|
|
147
177
|
|
|
178
|
+
// Security headers on every response (clickjacking + MIME-sniffing). The
|
|
179
|
+
// frame-ancestors directive only governs who may iframe us — it does NOT
|
|
180
|
+
// restrict the dashboard's own CDN resources, so it is safe to set globally.
|
|
181
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
182
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
183
|
+
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
|
|
184
|
+
|
|
185
|
+
// Loopback-only gate — reject anything not local before any routing happens.
|
|
186
|
+
if (!isLocalRequest(req)) {
|
|
187
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
188
|
+
res.end('Forbidden: SEO Intel only accepts requests from localhost.');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
148
192
|
// ─── Setup wizard routes ───
|
|
149
193
|
if (path.startsWith('/setup') || path.startsWith('/api/setup/')) {
|
|
150
194
|
try {
|
|
@@ -1162,12 +1206,13 @@ ${md}`;
|
|
|
1162
1206
|
args.push('--save');
|
|
1163
1207
|
}
|
|
1164
1208
|
|
|
1165
|
-
// SSE headers
|
|
1209
|
+
// SSE headers — no CORS: the dashboard is same-origin, and the loopback
|
|
1210
|
+
// gate already blocks cross-origin callers. (Removed Access-Control-Allow-Origin:*
|
|
1211
|
+
// which previously let any website read this command-execution stream.)
|
|
1166
1212
|
res.writeHead(200, {
|
|
1167
1213
|
'Content-Type': 'text/event-stream',
|
|
1168
1214
|
'Cache-Control': 'no-cache',
|
|
1169
1215
|
'Connection': 'keep-alive',
|
|
1170
|
-
'Access-Control-Allow-Origin': '*',
|
|
1171
1216
|
});
|
|
1172
1217
|
|
|
1173
1218
|
const send = (type, data) => {
|