Skip to content

Commit c1fa824

Browse files
rohitg00cursoragent
andcommitted
fix: address CodeRabbit and Devin review findings from PR #74
- TLS: default allowSelfSigned=true to preserve self-signed cert workflow; enforce rejectUnauthorized=true when trustedCAs are supplied in client context - Signature verification: verifySecureMessage now cryptographically verifies signatures using PeerIdentity.verifyHex instead of only checking fingerprints - WebSocket: fix timeout handle leak in performClientHandshake; add error logging to empty catch blocks; add per-socket error handler; pre-compute signature once in broadcast(); add send error callbacks; reject startup if TLS configured but certInfo missing - Command parsing: replace broken regex tokenizer with shared splitCommand() utility that correctly handles --flag="value with spaces" - Rate limiter: prefer x-real-ip, fall back to rightmost x-forwarded-for entry - Save route: scope IPv6 prefix checks to actual IPv6 addresses; stop leaking internal error messages to clients - Publish: extract shared parseSkillFrontmatter(); fix getRepoInfo to use async execFileSync; fix path containment checks with separator - Remote transport: add rejectUnauthorized option; bracket IPv6 in URLs; fix host:port splitting for IPv6 with lastIndexOf - Health checks: add tls/tlsAllowSelfSigned to Host type; pass https options - Fetcher: validate owner/repo; derive indexDir from INDEX_PATH - .gitignore: add recursive **/.env pattern, remove redundant entries Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3c6fa38 commit c1fa824

File tree

15 files changed

+270
-158
lines changed

15 files changed

+270
-158
lines changed

.gitignore

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@ npm-debug.log*
2424

2525
# Environment
2626
.env
27-
.env.local
28-
.env.*.local
27+
**/.env
2928
**/.env.local
3029
**/.env.*.local
3130

packages/api/src/middleware/rate-limit.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ export function rateLimiter(maxRequests = 60, windowMs = 60_000) {
1818
}, windowMs).unref();
1919

2020
return async (c: Context, next: Next): Promise<Response | void> => {
21+
const xRealIp = c.req.header("x-real-ip");
2122
const forwarded = c.req.header("x-forwarded-for");
22-
const ip =
23-
forwarded?.split(",")[0]?.trim() ||
24-
c.req.header("x-real-ip") ||
25-
"unknown";
23+
const forwardedIp = forwarded
24+
? forwarded.split(",").at(-1)?.trim()
25+
: undefined;
26+
const ip = xRealIp || forwardedIp || "unknown";
2627
const now = Date.now();
2728

2829
let entry = windows.get(ip);

packages/api/src/routes/save.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,16 @@ function isAllowedUrl(url: string): boolean {
3434
if (bare.startsWith("10.") || bare.startsWith("192.168.")) return false;
3535
if (/^172\.(1[6-9]|2\d|3[01])\./.test(bare)) return false;
3636
if (bare.startsWith("169.254.")) return false;
37-
if (
38-
bare.startsWith("fe80:") ||
39-
bare.startsWith("fc") ||
40-
bare.startsWith("fd")
41-
)
42-
return false;
37+
if (bare.includes(":")) {
38+
if (
39+
bare.startsWith("fe80:") ||
40+
bare.startsWith("fc") ||
41+
bare.startsWith("fd")
42+
)
43+
return false;
44+
if (bare.startsWith("ff")) return false;
45+
}
4346
if (/^(22[4-9]|23\d|24\d|25[0-5])\./.test(bare)) return false;
44-
if (bare.startsWith("ff")) return false;
4547
return true;
4648
} catch {
4749
return false;
@@ -106,17 +108,17 @@ export function saveRoutes() {
106108
tags: tagger.detectTags(content),
107109
});
108110
} catch (err) {
109-
const message = err instanceof Error ? err.message : "Unknown error";
111+
console.error("Save extraction failed:", err);
110112
const isTimeout =
111113
(err instanceof DOMException && err.name === "TimeoutError") ||
112114
(err instanceof Error &&
113115
(err.name === "TimeoutError" || err.name === "AbortError"));
114116

115117
if (isTimeout) {
116-
return c.json({ error: `Fetch timeout: ${message}` }, 504);
118+
return c.json({ error: "Fetch timed out" }, 504);
117119
}
118120

119-
return c.json({ error: `Extraction failed: ${message}` }, 422);
121+
return c.json({ error: "Extraction failed" }, 422);
120122
}
121123
});
122124

packages/cli/src/commands/publish.ts

Lines changed: 65 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
readdirSync,
77
statSync,
88
} from "node:fs";
9-
import { join, basename, dirname, resolve } from "node:path";
9+
import { join, basename, dirname, resolve, sep } from "node:path";
1010
import chalk from "chalk";
1111
import { Command, Option } from "clipanion";
1212
import {
@@ -41,6 +41,59 @@ interface SkillFrontmatter {
4141
version?: string;
4242
}
4343

44+
function parseSkillFrontmatter(content: string): SkillFrontmatter {
45+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
46+
if (!match) return {};
47+
48+
const frontmatter: SkillFrontmatter = {};
49+
const lines = match[1].split(/\r?\n/);
50+
let inTagsList = false;
51+
52+
for (const line of lines) {
53+
if (inTagsList) {
54+
const tagMatch = line.match(/^\s*-\s*(.+)$/);
55+
if (tagMatch) {
56+
frontmatter.tags ??= [];
57+
frontmatter.tags.push(tagMatch[1].trim().replace(/^["']|["']$/g, ""));
58+
continue;
59+
}
60+
if (line.trim() === "") continue;
61+
inTagsList = false;
62+
}
63+
64+
const colonIdx = line.indexOf(":");
65+
if (colonIdx === -1) continue;
66+
67+
const key = line.slice(0, colonIdx).trim();
68+
const value = line.slice(colonIdx + 1).trim();
69+
70+
switch (key) {
71+
case "name":
72+
frontmatter.name = value.replace(/^["']|["']$/g, "");
73+
break;
74+
case "description":
75+
frontmatter.description = value.replace(/^["']|["']$/g, "");
76+
break;
77+
case "version":
78+
frontmatter.version = value.replace(/^["']|["']$/g, "");
79+
break;
80+
case "tags":
81+
if (value.startsWith("[")) {
82+
frontmatter.tags = value
83+
.slice(1, -1)
84+
.split(",")
85+
.map((t) => t.trim().replace(/^["']|["']$/g, ""))
86+
.filter((t) => t.length > 0);
87+
} else if (value === "") {
88+
inTagsList = true;
89+
frontmatter.tags = [];
90+
}
91+
break;
92+
}
93+
}
94+
return frontmatter;
95+
}
96+
4497
export class PublishCommand extends Command {
4598
static override paths = [["publish"]];
4699

@@ -186,7 +239,7 @@ export class PublishCommand extends Command {
186239
skill.safeName,
187240
);
188241
const resolvedDir = resolve(mintlifyDir);
189-
if (!resolvedDir.startsWith(resolvedOutput)) {
242+
if (!resolvedDir.startsWith(resolvedOutput + sep)) {
190243
console.log(
191244
chalk.red(`Skipping ${skill.safeName} (path traversal detected)`),
192245
);
@@ -255,7 +308,7 @@ export class PublishCommand extends Command {
255308
const resolvedSkillDir = resolve(skillDir);
256309
const resolvedWellKnownDir = resolve(wellKnownDir);
257310

258-
if (!resolvedSkillDir.startsWith(resolvedWellKnownDir)) {
311+
if (!resolvedSkillDir.startsWith(resolvedWellKnownDir + sep)) {
259312
console.log(
260313
chalk.yellow(` Skipping "${skill.name}" (path traversal detected)`),
261314
);
@@ -360,8 +413,7 @@ export class PublishCommand extends Command {
360413
for (const entry of entries) {
361414
const entryPath = join(skillPath, entry);
362415
if (statSync(entryPath).isFile()) {
363-
if (entry.startsWith(".") || entry === ".skillkit-metadata.json")
364-
continue;
416+
if (entry.startsWith(".")) continue;
365417
files.push(entry);
366418
}
367419
}
@@ -374,57 +426,7 @@ export class PublishCommand extends Command {
374426
}
375427

376428
private parseFrontmatter(content: string): SkillFrontmatter {
377-
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
378-
if (!match) return {};
379-
380-
const frontmatter: SkillFrontmatter = {};
381-
const lines = match[1].split(/\r?\n/);
382-
let inTagsList = false;
383-
384-
for (const line of lines) {
385-
if (inTagsList) {
386-
const tagMatch = line.match(/^\s*-\s*(.+)$/);
387-
if (tagMatch) {
388-
frontmatter.tags ??= [];
389-
frontmatter.tags.push(tagMatch[1].trim().replace(/^["']|["']$/g, ""));
390-
continue;
391-
}
392-
if (line.trim() === "") continue;
393-
inTagsList = false;
394-
}
395-
396-
const colonIdx = line.indexOf(":");
397-
if (colonIdx === -1) continue;
398-
399-
const key = line.slice(0, colonIdx).trim();
400-
const value = line.slice(colonIdx + 1).trim();
401-
402-
switch (key) {
403-
case "name":
404-
frontmatter.name = value.replace(/^["']|["']$/g, "");
405-
break;
406-
case "description":
407-
frontmatter.description = value.replace(/^["']|["']$/g, "");
408-
break;
409-
case "version":
410-
frontmatter.version = value.replace(/^["']|["']$/g, "");
411-
break;
412-
case "tags":
413-
if (value.startsWith("[")) {
414-
frontmatter.tags = value
415-
.slice(1, -1)
416-
.split(",")
417-
.map((t) => t.trim().replace(/^["']|["']$/g, ""))
418-
.filter((t) => t.length > 0);
419-
} else if (value === "") {
420-
inTagsList = true;
421-
frontmatter.tags = [];
422-
}
423-
break;
424-
}
425-
}
426-
427-
return frontmatter;
429+
return parseSkillFrontmatter(content);
428430
}
429431
}
430432

@@ -468,7 +470,7 @@ export class PublishSubmitCommand extends Command {
468470
const skillName =
469471
this.name || frontmatter.name || basename(dirname(skillMdPath));
470472

471-
const repoInfo = this.getRepoInfo(dirname(skillMdPath));
473+
const repoInfo = await this.getRepoInfo(dirname(skillMdPath));
472474
if (!repoInfo) {
473475
console.error(chalk.red("Not a git repository or no remote configured"));
474476
console.error(
@@ -567,42 +569,7 @@ export class PublishSubmitCommand extends Command {
567569
}
568570

569571
private parseFrontmatter(content: string): SkillFrontmatter {
570-
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
571-
if (!match) return {};
572-
573-
const frontmatter: SkillFrontmatter = {};
574-
const lines = match[1].split(/\r?\n/);
575-
576-
for (const line of lines) {
577-
const colonIdx = line.indexOf(":");
578-
if (colonIdx === -1) continue;
579-
580-
const key = line.slice(0, colonIdx).trim();
581-
const value = line.slice(colonIdx + 1).trim();
582-
583-
switch (key) {
584-
case "name":
585-
frontmatter.name = value.replace(/^["']|["']$/g, "");
586-
break;
587-
case "description":
588-
frontmatter.description = value.replace(/^["']|["']$/g, "");
589-
break;
590-
case "version":
591-
frontmatter.version = value.replace(/^["']|["']$/g, "");
592-
break;
593-
case "tags":
594-
if (value.startsWith("[")) {
595-
frontmatter.tags = value
596-
.slice(1, -1)
597-
.split(",")
598-
.map((t) => t.trim().replace(/^["']|["']$/g, ""))
599-
.filter((t) => t.length > 0);
600-
}
601-
break;
602-
}
603-
}
604-
605-
return frontmatter;
572+
return parseSkillFrontmatter(content);
606573
}
607574

608575
private slugify(name: string): string {
@@ -613,10 +580,12 @@ export class PublishSubmitCommand extends Command {
613580
.replace(/^-+|-+$/g, "");
614581
}
615582

616-
private getRepoInfo(dir: string): { owner: string; repo: string } | null {
583+
private async getRepoInfo(
584+
dir: string,
585+
): Promise<{ owner: string; repo: string } | null> {
617586
try {
618-
const { execSync } = require("node:child_process");
619-
const remote = execSync("git remote get-url origin", {
587+
const { execFileSync } = await import("node:child_process");
588+
const remote = execFileSync("git", ["remote", "get-url", "origin"], {
620589
cwd: dir,
621590
encoding: "utf-8",
622591
stdio: ["pipe", "pipe", "ignore"],

packages/core/src/executor/engine.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { execFileSync } from "node:child_process";
88
import { randomUUID } from "node:crypto";
9+
import { splitCommand } from "../utils/shell.js";
910
import type {
1011
ExecutableSkill,
1112
ExecutableTask,
@@ -526,13 +527,9 @@ export class SkillExecutionEngine {
526527
for (const rule of task.verify.automated) {
527528
if (rule.command) {
528529
try {
529-
const cmdParts =
530-
rule.command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
530+
const cmdParts = splitCommand(rule.command);
531531
if (cmdParts.length === 0) return false;
532-
const [cmd, ...cmdArgs] = cmdParts as [string, ...string[]];
533-
const sanitizedArgs = cmdArgs.map((a) =>
534-
a.replace(/^["']|["']$/g, ""),
535-
);
532+
const [cmd, ...sanitizedArgs] = cmdParts;
536533
const output = execFileSync(cmd, sanitizedArgs, {
537534
cwd: this.projectPath,
538535
encoding: "utf-8",

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,6 @@ export * from './agents-md/index.js';
132132

133133
// Save (Content Extraction & Skill Generation)
134134
export * from './save/index.js';
135+
136+
// Utilities
137+
export { splitCommand } from './utils/shell.js';

packages/core/src/plan/executor.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import type {
1414
PlanEvent,
1515
PlanEventListener,
1616
} from "./types.js";
17+
import { execFileSync } from "node:child_process";
18+
import { splitCommand } from "../utils/shell.js";
1719

1820
/**
1921
* Step executor function type
@@ -499,13 +501,11 @@ export const shellExecutor: StepExecutor = async (step, _task, _plan) => {
499501
}
500502

501503
try {
502-
const { execFileSync } = await import("node:child_process");
503-
const parts = step.command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
504+
const parts = splitCommand(step.command);
504505
if (parts.length === 0) {
505506
return { success: false, output: "", error: "Empty command" };
506507
}
507-
const [cmd, ...rawArgs] = parts as [string, ...string[]];
508-
const args = rawArgs.map((a) => a.replace(/^["']|["']$/g, ""));
508+
const [cmd, ...args] = parts;
509509
const output = execFileSync(cmd, args, {
510510
encoding: "utf-8",
511511
timeout: 60000,

packages/core/src/recommend/fetcher.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
writeFileSync,
77
mkdirSync,
88
} from "node:fs";
9-
import { join } from "node:path";
9+
import { join, dirname } from "node:path";
1010
import { tmpdir, homedir } from "node:os";
1111
import { randomUUID } from "node:crypto";
1212
import type { SkillIndex, SkillSummary, IndexSource } from "./types.js";
@@ -46,6 +46,13 @@ export async function fetchSkillsFromRepo(
4646
owner: string,
4747
repo: string,
4848
): Promise<{ skills: SkillSummary[]; error?: string }> {
49+
if (!/^[\w.-]+$/.test(owner) || !/^[\w.-]+$/.test(repo)) {
50+
return {
51+
skills: [],
52+
error: `Invalid owner or repo name: ${owner}/${repo}`,
53+
};
54+
}
55+
4956
const cloneUrl = `https://github.com/${owner}/${repo}.git`;
5057
const tempDir = join(tmpdir(), `skillkit-fetch-${randomUUID()}`);
5158

@@ -179,7 +186,7 @@ export async function buildSkillIndex(
179186
* Save skill index to cache
180187
*/
181188
export function saveIndex(index: SkillIndex): void {
182-
const indexDir = join(homedir(), ".skillkit");
189+
const indexDir = dirname(INDEX_PATH);
183190
if (!existsSync(indexDir)) {
184191
mkdirSync(indexDir, { recursive: true });
185192
}

0 commit comments

Comments
 (0)