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.
Files changed (3) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/package.json +1 -1
  3. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.5.45",
3
+ "version": "1.5.46",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
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) => {