seo-intel 1.4.8 → 1.4.9

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.9 (2026-04-10)
4
+
5
+ ### Security
6
+ - Fixed arbitrary file write via `--out` query param in dashboard terminal API — write paths now server-controlled only
7
+ - Fixed path traversal in froggo config loader — project names validated to `[a-z0-9_-]`
8
+ - Added project name validation to export and terminal API endpoints
9
+
10
+ ### URL Normalization
11
+ - Pages are now normalized before storage: fragments stripped (`/#pricing` → `/`), `index.html` collapsed
12
+ - Internal link targets also normalized for consistent orphan/link analysis
13
+ - Re-crawl to clean up existing fragment duplicates in your database
14
+
3
15
  ## 1.4.8 (2026-04-10)
4
16
 
5
17
  ### Export: own site only, zero competitor bloat
package/db/db.js CHANGED
@@ -268,7 +268,19 @@ export function upsertDomain(db, { domain, project, role }) {
268
268
  `).run(domain, project, role, now, now);
269
269
  }
270
270
 
271
+ function normalizePageUrl(rawUrl) {
272
+ try {
273
+ const u = new URL(rawUrl);
274
+ u.hash = ''; // strip fragments (#pricing, #faq, etc.)
275
+ let path = u.pathname;
276
+ path = path.replace(/\/index\.html?$/i, '/'); // /en/index.html → /en/
277
+ u.pathname = path;
278
+ return u.toString();
279
+ } catch { return rawUrl; }
280
+ }
281
+
271
282
  export function upsertPage(db, { domainId, url, statusCode, wordCount, loadMs, isIndexable, clickDepth = 0, publishedDate = null, modifiedDate = null, contentHash = null, title = null, metaDesc = null, bodyText = null }) {
283
+ url = normalizePageUrl(url);
272
284
  const now = Date.now();
273
285
  db.prepare(`
274
286
  INSERT INTO pages (domain_id, url, crawled_at, first_seen_at, status_code, word_count, load_ms, is_indexable, click_depth, published_date, modified_date, content_hash, title, meta_desc, body_text)
@@ -350,7 +362,7 @@ export function insertLinks(db, sourceId, links) {
350
362
  const stmt = db.prepare(`INSERT INTO links (source_id, target_url, anchor_text, is_internal) VALUES (?, ?, ?, ?)`);
351
363
  db.exec('BEGIN');
352
364
  try {
353
- for (const l of links) stmt.run(sourceId, l.url, l.anchor, l.isInternal ? 1 : 0);
365
+ for (const l of links) stmt.run(sourceId, normalizePageUrl(l.url), l.anchor, l.isInternal ? 1 : 0);
354
366
  db.exec('COMMIT');
355
367
  } catch (e) { db.exec('ROLLBACK'); throw e; }
356
368
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.4.8",
3
+ "version": "1.4.9",
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
@@ -596,7 +596,7 @@ async function handleRequest(req, res) {
596
596
  const format = url.searchParams.get('format') || 'json';
597
597
  const profile = url.searchParams.get('profile'); // dev | content | ai-pipeline
598
598
 
599
- if (!project) { json(res, 400, { error: 'Missing project' }); return; }
599
+ if (!project || !/^[a-z0-9_-]+$/i.test(project)) { json(res, 400, { error: 'Invalid project name' }); return; }
600
600
 
601
601
  const { getDb } = await import('./db/db.js');
602
602
  const db = getDb(join(__dirname, 'seo-intel.db'));
@@ -1164,6 +1164,10 @@ async function handleRequest(req, res) {
1164
1164
  const params = url.searchParams;
1165
1165
  const command = params.get('command');
1166
1166
  const project = params.get('project') || '';
1167
+ if (project && !/^[a-z0-9_-]+$/i.test(project)) {
1168
+ json(res, 400, { error: 'Invalid project name' });
1169
+ return;
1170
+ }
1167
1171
 
1168
1172
  // Whitelist allowed commands
1169
1173
  const ALLOWED = ['crawl', 'extract', 'analyze', 'export-actions', 'competitive-actions',
@@ -1190,7 +1194,7 @@ async function handleRequest(req, res) {
1190
1194
  if (params.get('type')) args.push('--type', params.get('type'));
1191
1195
  if (params.get('limit')) args.push('--limit', params.get('limit'));
1192
1196
  if (params.has('raw')) args.push('--raw');
1193
- if (params.get('out')) args.push('--out', params.get('out'));
1197
+ // --out is NOT passed from dashboard — write paths are server-controlled only (see auto-save below)
1194
1198
 
1195
1199
  // Auto-save exports from dashboard to reports/
1196
1200
  const EXPORT_CMDS = ['export-actions', 'suggest-usecases', 'competitive-actions'];