forked from farhoodlabs/paperclip
fork: address PR #19 review findings for Gitea skill support
- Fix GitHub Enterprise regression: dispatcher now probes for Gitea only on non-github.com hosts and falls back to the GitHub path for unknown hosts, preserving GHE support that the earlier strict github.com match broke. - Refactor readUrlSkillImports into a flat dispatcher with a sibling readGitHubUrlSkillImports helper, mirroring readGiteaUrlSkillImports. - Add SSRF guard (isPrivateOrLoopbackHost + assertPublicHost) in gitea-fetch; short-circuit probeGiteaHost and reject parseGiteaSourceUrl for loopback / RFC1918 / link-local literal IPs. - Throw on fetchGiteaTreeBlobPaths cap-hit instead of silently returning a partial blob listing (would hide SKILL.md files). - Validate non-empty repo in parseGiteaSourceUrl after .git strip. - Remove dead resolveGiteaCommitSha + GiteaCommitResponse (unused since the branches-endpoint follow-up). - Tests updated and extended. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,9 @@ import {
|
||||
fetchGiteaTreeBlobPaths,
|
||||
giteaApiBase,
|
||||
giteaHostProbeCache,
|
||||
isPrivateOrLoopbackHost,
|
||||
parseGiteaSourceUrl,
|
||||
probeGiteaHost,
|
||||
resolveGiteaCommitSha,
|
||||
resolveGiteaPinnedRef,
|
||||
resolveRawGiteaUrl,
|
||||
resolveRawGiteaUrlLegacy,
|
||||
@@ -109,6 +109,52 @@ describe("parseGiteaSourceUrl", () => {
|
||||
it("rejects URLs with fewer than 2 path segments", () => {
|
||||
expect(() => parseGiteaSourceUrl("https://git.example.com/acme")).toThrow(/Invalid Gitea URL/);
|
||||
});
|
||||
|
||||
it("rejects URLs with empty repo after .git strip", () => {
|
||||
expect(() => parseGiteaSourceUrl("https://git.example.com/acme/.git")).toThrow(
|
||||
/owner and repo are required/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects URLs pointing at private/loopback hosts", () => {
|
||||
expect(() => parseGiteaSourceUrl("https://192.168.1.10/acme/skills")).toThrow(
|
||||
/private, loopback/,
|
||||
);
|
||||
expect(() => parseGiteaSourceUrl("https://localhost/acme/skills")).toThrow(
|
||||
/private, loopback/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPrivateOrLoopbackHost", () => {
|
||||
it("flags loopback and localhost variants", () => {
|
||||
expect(isPrivateOrLoopbackHost("localhost")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("127.0.0.1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("127.99.99.99")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("foo.localhost")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags RFC1918 ranges", () => {
|
||||
expect(isPrivateOrLoopbackHost("10.0.0.1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("172.16.0.1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("172.31.255.254")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("192.168.1.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags link-local and 0.0.0.0", () => {
|
||||
expect(isPrivateOrLoopbackHost("169.254.169.254")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("0.0.0.0")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("fe80::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("fd00::1")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows public hosts", () => {
|
||||
expect(isPrivateOrLoopbackHost("git.example.com")).toBe(false);
|
||||
expect(isPrivateOrLoopbackHost("gitea.com")).toBe(false);
|
||||
expect(isPrivateOrLoopbackHost("172.32.0.1")).toBe(false);
|
||||
expect(isPrivateOrLoopbackHost("11.0.0.1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeGiteaHost", () => {
|
||||
@@ -170,6 +216,15 @@ describe("probeGiteaHost", () => {
|
||||
expect(result).toBe(true);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits to false for private/loopback hosts without making a request", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
expect(await probeGiteaHost("127.0.0.1")).toBe(false);
|
||||
expect(await probeGiteaHost("192.168.1.1")).toBe(false);
|
||||
expect(await probeGiteaHost("localhost")).toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGiteaPinnedRef", () => {
|
||||
@@ -201,27 +256,6 @@ describe("resolveGiteaPinnedRef", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGiteaCommitSha", () => {
|
||||
it("returns the sha from a commit response when given a 40-hex ref", async () => {
|
||||
const sha = "abc123abc123abc123abc123abc123abc123abcd";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(jsonResponse({ sha })),
|
||||
);
|
||||
const result = await resolveGiteaCommitSha("acme", "skills", sha, giteaApiBase("git.example.com"));
|
||||
expect(result).toBe(sha);
|
||||
});
|
||||
|
||||
it("refuses to call the API for a non-SHA ref", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(
|
||||
resolveGiteaCommitSha("acme", "skills", "main", giteaApiBase("git.example.com")),
|
||||
).rejects.toMatchObject({ status: 422 });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGiteaTreeBlobPaths", () => {
|
||||
it("returns blob paths from a single-page tree", async () => {
|
||||
vi.stubGlobal(
|
||||
@@ -263,6 +297,22 @@ describe("fetchGiteaTreeBlobPaths", () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(String(fetchMock.mock.calls[1]?.[0])).toContain("page=2");
|
||||
});
|
||||
|
||||
it("throws when the page cap is hit while the tree is still truncated", async () => {
|
||||
// Return truncated=true on every page so the loop hits MAX_PAGES (50).
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
tree: [{ path: "page.md", type: "blob" }],
|
||||
truncated: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
fetchGiteaTreeBlobPaths(giteaApiBase("git.example.com"), "acme", "skills", "main"),
|
||||
).rejects.toThrow(/exceeds .* entries/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGiteaText", () => {
|
||||
|
||||
Reference in New Issue
Block a user