diff --git a/docs/docs/features.md b/docs/docs/features.md index 34badefa..311d7561 100644 --- a/docs/docs/features.md +++ b/docs/docs/features.md @@ -96,7 +96,7 @@ TestPlanIt is a comprehensive test management platform designed to help teams pl ### LLM Integration - **Test case generation** - Generate test cases from requirements using AI -- **QuickScript AI generation** - Convert manual test cases into automation scripts with AI, optionally informed by your code repository +- **QuickScript AI generation** - Convert manual test cases into automation scripts with AI, optionally informed by your code repository (GitHub, GitLab, Bitbucket, Azure DevOps, Gitea/Forgejo/Gogs) - **Enhance Writing** - Get AI recommendations to improve writing for any rich text field - **Magic Select** - AI-assisted test case selection for quickly building test runs - **Auto Tag** - Automatically suggest and apply tags to test cases, test runs, and sessions using AI analysis diff --git a/docs/docs/user-guide/llm-quickscript.md b/docs/docs/user-guide/llm-quickscript.md index b85d62eb..e62dd33f 100644 --- a/docs/docs/user-guide/llm-quickscript.md +++ b/docs/docs/user-guide/llm-quickscript.md @@ -27,7 +27,15 @@ Connecting a code repository gives the AI access to your actual source files, en ### 1. Add a Code Repository -Navigate to **Administration > Code Repositories** and add a connection to your repository (GitHub, GitLab, Bitbucket, or Azure DevOps). You'll need to provide credentials with read access to the repository. +Navigate to **Administration > Code Repositories** and add a connection to your repository. Supported providers: + +- **GitHub** — github.com or GitHub Enterprise +- **GitLab** — gitlab.com or self-hosted +- **Bitbucket Cloud** — bitbucket.org +- **Azure DevOps** — dev.azure.com +- **Gitea / Forgejo / Gogs** — self-hosted Git servers with a compatible `/api/v1/` REST API + +You'll need to provide credentials with read access to the repository. ### 2. Configure QuickScript Settings diff --git a/testplanit/app/[locale]/admin/code-repositories/columns.tsx b/testplanit/app/[locale]/admin/code-repositories/columns.tsx index 903ae2f9..40610886 100644 --- a/testplanit/app/[locale]/admin/code-repositories/columns.tsx +++ b/testplanit/app/[locale]/admin/code-repositories/columns.tsx @@ -25,6 +25,7 @@ const providerLabel: Record = { GITLAB: "GitLab", BITBUCKET: "Bitbucket", AZURE_DEVOPS: "Azure DevOps", + GITEA: "Gitea / Forgejo / Gogs", }; interface ColumnActions { diff --git a/testplanit/components/admin/code-repositories/CodeRepositoryConfigForm.tsx b/testplanit/components/admin/code-repositories/CodeRepositoryConfigForm.tsx index 98323576..6b2799a2 100644 --- a/testplanit/components/admin/code-repositories/CodeRepositoryConfigForm.tsx +++ b/testplanit/components/admin/code-repositories/CodeRepositoryConfigForm.tsx @@ -7,6 +7,8 @@ import { } from "@/components/ui/form"; import { HelpPopover } from "@/components/ui/help-popover"; import { Input } from "@/components/ui/input"; +import { ShieldAlert } from "lucide-react"; +import { useTranslations } from "next-intl"; import { UseFormReturn } from "react-hook-form"; interface FieldConfig { @@ -16,6 +18,7 @@ interface FieldConfig { type?: string; // "password" for sensitive fields isCredential: boolean; // true = goes in credentials object; false = goes in settings helpKey?: string; + isUrl?: boolean; // true = show HTTP plaintext warning when value starts with http:// } // Field definitions per provider -- mirrors IntegrationConfigForm.tsx providerFields pattern @@ -66,6 +69,7 @@ const providerFields: Record = { placeholder: "https://gitlab.com", isCredential: false, helpKey: "codeRepository.baseUrl", + isUrl: true, }, ], BITBUCKET: [ @@ -114,6 +118,7 @@ const providerFields: Record = { placeholder: "https://dev.azure.com/myorg", isCredential: false, helpKey: "codeRepository.organizationUrl", + isUrl: true, }, { name: "project", @@ -130,6 +135,38 @@ const providerFields: Record = { helpKey: "codeRepository.azureRepoId", }, ], + GITEA: [ + { + name: "personalAccessToken", + label: "Personal Access Token", + type: "password", + isCredential: true, + placeholder: "...", + helpKey: "codeRepository.giteaToken", + }, + { + name: "baseUrl", + label: "Server URL", + placeholder: "https://gitea.example.com", + isCredential: false, + helpKey: "codeRepository.giteaBaseUrl", + isUrl: true, + }, + { + name: "owner", + label: "Owner", + placeholder: "myorg", + isCredential: false, + helpKey: "codeRepository.giteaOwner", + }, + { + name: "repo", + label: "Repository", + placeholder: "my-repo", + isCredential: false, + helpKey: "codeRepository.giteaRepo", + }, + ], }; interface CodeRepositoryConfigFormProps { @@ -142,6 +179,7 @@ export function CodeRepositoryConfigForm({ form, }: CodeRepositoryConfigFormProps) { const fields = providerFields[provider] ?? []; + const t = useTranslations("admin.codeRepositories"); return (
@@ -155,26 +193,39 @@ export function CodeRepositoryConfigForm({ key={field.name} control={form.control} name={formFieldName} - render={({ field: formField }) => ( - - - {field.label} - - - - - - - - )} + render={({ field: formField }) => { + const showHttpWarning = + field.isUrl && + typeof formField.value === "string" && + formField.value.startsWith("http://"); + + return ( + + + {field.label} + + + + + + {showHttpWarning && ( +

+ + {t("httpWarning")} +

+ )} + +
+ ); + }} /> ); })} diff --git a/testplanit/components/admin/code-repositories/CodeRepositoryModal.tsx b/testplanit/components/admin/code-repositories/CodeRepositoryModal.tsx index 0f400635..44295a9f 100644 --- a/testplanit/components/admin/code-repositories/CodeRepositoryModal.tsx +++ b/testplanit/components/admin/code-repositories/CodeRepositoryModal.tsx @@ -38,11 +38,12 @@ const PROVIDERS = [ { value: "GITLAB", label: "GitLab" }, { value: "BITBUCKET", label: "Bitbucket Cloud" }, { value: "AZURE_DEVOPS", label: "Azure DevOps" }, + { value: "GITEA", label: "Gitea / Forgejo / Gogs" }, ] as const; const formSchema = z.object({ name: z.string().min(1, "Name is required"), - provider: z.enum(["GITHUB", "GITLAB", "BITBUCKET", "AZURE_DEVOPS"]), + provider: z.enum(["GITHUB", "GITLAB", "BITBUCKET", "AZURE_DEVOPS", "GITEA"]), credentials: z.record(z.string(), z.string()).optional(), settings: z.record(z.string(), z.string()).optional(), isActive: z.boolean().optional(), diff --git a/testplanit/lib/integrations/adapters/GitRepoAdapter.test.ts b/testplanit/lib/integrations/adapters/GitRepoAdapter.test.ts index a5a157fb..d6358355 100644 --- a/testplanit/lib/integrations/adapters/GitRepoAdapter.test.ts +++ b/testplanit/lib/integrations/adapters/GitRepoAdapter.test.ts @@ -1,11 +1,38 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { AzureDevOpsRepoAdapter } from "./AzureDevOpsRepoAdapter"; import { BitbucketRepoAdapter } from "./BitbucketRepoAdapter"; +import { GiteaRepoAdapter } from "./GiteaRepoAdapter"; import { GitHubRepoAdapter } from "./GitHubRepoAdapter"; import { GitLabRepoAdapter } from "./GitLabRepoAdapter"; // Stub fetch so adapters don't error -vi.stubGlobal("fetch", vi.fn()); +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +// Mock DNS resolution to avoid real lookups in tests +vi.mock("~/utils/ssrf", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + assertSsrfSafeResolved: vi.fn().mockResolvedValue(undefined), + }; +}); + +function makeResponse( + data: any, + status = 200, + headers: Record = {} +) { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? "OK" : "Error", + headers: new Headers(headers), + json: () => Promise.resolve(data), + text: () => Promise.resolve(typeof data === "string" ? data : JSON.stringify(data)), + url: "https://api.github.com/repos/test/test", + }; +} // The factory uses require() which fails in Vitest's ESM context. // Instead of testing the factory directly, test that each adapter can be @@ -53,6 +80,19 @@ describe("GitRepoAdapter adapter instantiation", () => { expect((adapter as any).project).toBe("myproject"); }); + it("instantiates GiteaRepoAdapter with credentials and settings", () => { + const adapter = new GiteaRepoAdapter(creds, { + baseUrl: "https://gitea.example.com", + owner: "testorg", + repo: "testrepo", + }); + expect(adapter).toBeInstanceOf(GiteaRepoAdapter); + expect((adapter as any).personalAccessToken).toBe("test-token"); + expect((adapter as any).baseUrl).toBe("https://gitea.example.com"); + expect((adapter as any).owner).toBe("testorg"); + expect((adapter as any).repo).toBe("testrepo"); + }); + it("handles null settings gracefully", () => { const adapter = new GitHubRepoAdapter(creds, null); expect(adapter).toBeInstanceOf(GitHubRepoAdapter); @@ -73,6 +113,7 @@ describe("GitRepoAdapter adapter instantiation", () => { new GitLabRepoAdapter(creds, {}), new BitbucketRepoAdapter({ username: "u", appPassword: "p" }, {}), new AzureDevOpsRepoAdapter(creds, {}), + new GiteaRepoAdapter(creds, { baseUrl: "https://gitea.example.com" }), ]; for (const adapter of adapters) { @@ -83,3 +124,71 @@ describe("GitRepoAdapter adapter instantiation", () => { } }); }); + +describe("GitRepoAdapter redirect protection", () => { + let adapter: GitHubRepoAdapter; + + beforeEach(() => { + vi.clearAllMocks(); + adapter = new GitHubRepoAdapter( + { personalAccessToken: "test-token" }, + { owner: "testorg", repo: "testrepo" } + ); + (adapter as any).rateLimitDelay = 0; + (adapter as any).lastRequestTime = 0; + }); + + it("follows safe redirects with SSRF validation", async () => { + // First response: redirect + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 301, + statusText: "Moved", + headers: new Headers({ Location: "https://api.github.com/repos/testorg/testrepo-new" }), + url: "https://api.github.com/repos/testorg/testrepo", + json: () => Promise.resolve({}), + text: () => Promise.resolve(""), + }); + // Second response: actual data after redirect + mockFetch.mockResolvedValueOnce(makeResponse({ default_branch: "main" })); + + const branch = await adapter.getDefaultBranch(); + + expect(branch).toBe("main"); + expect(mockFetch).toHaveBeenCalledTimes(2); + // Second fetch should use redirect: "error" to prevent chains + expect(mockFetch.mock.calls[1][1]).toEqual( + expect.objectContaining({ redirect: "error" }) + ); + }); + + it("rejects redirect with no Location header", async () => { + // Disable retries so the redirect error propagates directly + (adapter as any).maxRetries = 0; + + mockFetch.mockResolvedValue({ + ok: false, + status: 302, + statusText: "Found", + headers: new Headers({}), + url: "https://api.github.com/repos/testorg/testrepo", + json: () => Promise.resolve({}), + text: () => Promise.resolve(""), + }); + + await expect(adapter.getDefaultBranch()).rejects.toThrow( + "Redirect (302) with no Location header" + ); + }); + + it("uses redirect: manual to prevent automatic redirect following", async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ default_branch: "main" })); + + await adapter.getDefaultBranch(); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ redirect: "manual" }) + ); + }); +}); diff --git a/testplanit/lib/integrations/adapters/GitRepoAdapter.ts b/testplanit/lib/integrations/adapters/GitRepoAdapter.ts index 63cad563..fdc7e5d2 100644 --- a/testplanit/lib/integrations/adapters/GitRepoAdapter.ts +++ b/testplanit/lib/integrations/adapters/GitRepoAdapter.ts @@ -4,7 +4,7 @@ * Does NOT extend BaseAdapter (which is for issue tracking, not git file fetching). */ -import { isSsrfSafe } from "~/utils/ssrf"; +import { assertSsrfSafeResolved, isSsrfSafe } from "~/utils/ssrf"; export interface RepoFileEntry { path: string; @@ -99,21 +99,25 @@ export abstract class GitRepoAdapter { try { const safeUrl = this.sanitizeUrl(url); + // Resolve DNS and verify the IP is not private (closes DNS rebinding) + await assertSsrfSafeResolved(safeUrl); const response = await fetch(safeUrl, { ...options, signal: controller.signal, + redirect: "manual", // prevent redirect-based SSRF bypass }); + // If the server redirects, validate the target before following + if (response.status >= 300 && response.status < 400) { + return this.followSafeRedirect(response, options, controller.signal, "json"); + } + // Track rate limit state from response headers for adaptive throttling + this.trackRateLimitHeaders(response); const remaining = response.headers.get("X-RateLimit-Remaining"); const reset = response.headers.get("X-RateLimit-Reset"); const retryAfter = response.headers.get("Retry-After"); - if (remaining !== null) this.rateLimitRemaining = parseInt(remaining); - if (reset !== null) this.rateLimitResetAt = parseInt(reset); - // Retry-After (secondary rate limits) overrides the reset time - if (retryAfter !== null) - this.rateLimitResetAt = Math.floor(Date.now() / 1000) + parseInt(retryAfter); if (!response.ok) { // Handle rate limiting: 429 is always a rate limit; 403 with @@ -183,19 +187,22 @@ export abstract class GitRepoAdapter { try { const safeUrl = this.sanitizeUrl(url); + await assertSsrfSafeResolved(safeUrl); + const response = await fetch(safeUrl, { ...options, signal: controller.signal, + redirect: "manual", }); + if (response.status >= 300 && response.status < 400) { + return this.followSafeRedirect(response, options, controller.signal, "text"); + } + // Track rate limit state from response headers + this.trackRateLimitHeaders(response); const remaining = response.headers.get("X-RateLimit-Remaining"); - const reset = response.headers.get("X-RateLimit-Reset"); const retryAfter = response.headers.get("Retry-After"); - if (remaining !== null) this.rateLimitRemaining = parseInt(remaining); - if (reset !== null) this.rateLimitResetAt = parseInt(reset); - if (retryAfter !== null) - this.rateLimitResetAt = Math.floor(Date.now() / 1000) + parseInt(retryAfter); if (!response.ok) { const isRateLimited = @@ -245,6 +252,63 @@ export abstract class GitRepoAdapter { return result; } + /** + * Follow a single redirect after validating the Location URL against SSRF rules. + * Only one hop is allowed — a second redirect throws. + */ + private async followSafeRedirect( + response: Response, + options: RequestInit, + signal: AbortSignal, + mode: "json" | "text" + ): Promise { + const location = response.headers.get("Location"); + if (!location) { + throw new Error( + `Redirect (${response.status}) with no Location header` + ); + } + + // Resolve relative redirects against the original request URL + const redirectUrl = this.sanitizeUrl( + new URL(location, response.url).href + ); + await assertSsrfSafeResolved(redirectUrl); + + const redirectResponse = await fetch(redirectUrl, { + ...options, + signal, + redirect: "error", // no further redirects + }); + + this.trackRateLimitHeaders(redirectResponse); + + if (!redirectResponse.ok) { + const errorText = await redirectResponse.text().catch(() => ""); + throw new Error( + `HTTP ${redirectResponse.status} ${redirectResponse.statusText}: ${errorText.slice(0, 200)}` + ); + } + + return mode === "json" + ? (await redirectResponse.json()) as T + : (await redirectResponse.text()) as T; + } + + /** + * Extract rate-limit headers from a response and update internal state. + */ + private trackRateLimitHeaders(response: Response): void { + const remaining = response.headers.get("X-RateLimit-Remaining"); + const reset = response.headers.get("X-RateLimit-Reset"); + const retryAfter = response.headers.get("Retry-After"); + if (remaining !== null) this.rateLimitRemaining = parseInt(remaining); + if (reset !== null) this.rateLimitResetAt = parseInt(reset); + if (retryAfter !== null) + this.rateLimitResetAt = + Math.floor(Date.now() / 1000) + parseInt(retryAfter); + } + protected async applyRateLimit(): Promise { const now = Date.now(); @@ -331,6 +395,10 @@ export function createGitRepoAdapter( const { AzureDevOpsRepoAdapter } = require("./AzureDevOpsRepoAdapter"); return new AzureDevOpsRepoAdapter(credentials, settings); } + case "GITEA": { + const { GiteaRepoAdapter } = require("./GiteaRepoAdapter"); + return new GiteaRepoAdapter(credentials, settings); + } default: throw new Error(`Unknown git provider: ${provider}`); } diff --git a/testplanit/lib/integrations/adapters/GiteaRepoAdapter.test.ts b/testplanit/lib/integrations/adapters/GiteaRepoAdapter.test.ts new file mode 100644 index 00000000..2dbd8998 --- /dev/null +++ b/testplanit/lib/integrations/adapters/GiteaRepoAdapter.test.ts @@ -0,0 +1,282 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GiteaRepoAdapter } from "./GiteaRepoAdapter"; + +// Mock fetch globally +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +// Mock DNS resolution to avoid real lookups in tests +vi.mock("~/utils/ssrf", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + assertSsrfSafeResolved: vi.fn().mockResolvedValue(undefined), + }; +}); + +function makeResponse( + data: any, + status = 200, + headers: Record = {} +) { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? "OK" : "Error", + headers: new Headers(headers), + json: () => Promise.resolve(data), + text: () => Promise.resolve(typeof data === "string" ? data : JSON.stringify(data)), + url: "https://gitea.example.com", + }; +} + +describe("GiteaRepoAdapter", () => { + let adapter: GiteaRepoAdapter; + + beforeEach(() => { + vi.clearAllMocks(); + adapter = new GiteaRepoAdapter( + { personalAccessToken: "test-token-123" }, + { baseUrl: "https://gitea.example.com", owner: "myorg", repo: "myrepo" } + ); + // Speed up tests by eliminating rate limit delays + (adapter as any).rateLimitDelay = 0; + (adapter as any).lastRequestTime = 0; + }); + + describe("constructor", () => { + it("stores credentials and settings", () => { + expect((adapter as any).personalAccessToken).toBe("test-token-123"); + expect((adapter as any).owner).toBe("myorg"); + expect((adapter as any).repo).toBe("myrepo"); + expect((adapter as any).baseUrl).toBe("https://gitea.example.com"); + }); + + it("strips trailing slash from baseUrl", () => { + const a = new GiteaRepoAdapter( + { personalAccessToken: "tok" }, + { baseUrl: "https://gitea.example.com/", owner: "o", repo: "r" } + ); + expect((a as any).baseUrl).toBe("https://gitea.example.com"); + }); + + it("throws with null settings since baseUrl is required", () => { + expect(() => new GiteaRepoAdapter( + { personalAccessToken: "tok" }, + null + )).toThrow(); + }); + }); + + describe("getDefaultBranch", () => { + it("returns the default branch from Gitea API", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse({ default_branch: "main" }) + ); + + const branch = await adapter.getDefaultBranch(); + + expect(branch).toBe("main"); + expect(mockFetch).toHaveBeenCalledWith( + "https://gitea.example.com/api/v1/repos/myorg/myrepo", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "token test-token-123", + }), + }) + ); + }); + }); + + describe("listAllFiles", () => { + it("resolves branch to tree SHA and lists files", async () => { + // First call: get branch info + mockFetch.mockResolvedValueOnce( + makeResponse({ + commit: { commit: { tree: { sha: "tree-sha-abc" } }, sha: "commit-sha" }, + }) + ); + // Second call: get tree + mockFetch.mockResolvedValueOnce( + makeResponse({ + tree: [ + { path: "src/index.ts", type: "blob", size: 100 }, + { path: "src/utils", type: "tree", size: 0 }, + { path: "src/utils/helper.ts", type: "blob", size: 50 }, + ], + truncated: false, + }) + ); + + const result = await adapter.listAllFiles("main"); + + expect(result.files).toHaveLength(2); // Only blobs + expect(result.files[0].path).toBe("src/index.ts"); + expect(result.files[0].size).toBe(100); + expect(result.files[1].path).toBe("src/utils/helper.ts"); + expect(result.truncated).toBe(false); + }); + + it("falls back to commit SHA if tree SHA not available", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse({ + commit: { sha: "fallback-sha" }, + }) + ); + mockFetch.mockResolvedValueOnce( + makeResponse({ + tree: [{ path: "README.md", type: "blob", size: 200 }], + }) + ); + + const result = await adapter.listAllFiles("main"); + + expect(result.files).toHaveLength(1); + // Verify the tree endpoint was called with the fallback SHA + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/git/trees/fallback-sha"), + expect.any(Object) + ); + }); + + it("reports truncated when Gitea returns truncated: true", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse({ + commit: { commit: { tree: { sha: "abc" } } }, + }) + ); + mockFetch.mockResolvedValueOnce( + makeResponse({ + tree: [{ path: "src/index.ts", type: "blob", size: 100 }], + truncated: true, + }) + ); + + const result = await adapter.listAllFiles("main"); + expect(result.truncated).toBe(true); + }); + + it("stops pagination when entries are fewer than page size", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse({ + commit: { commit: { tree: { sha: "abc" } } }, + }) + ); + // Single page with fewer than 100 entries + mockFetch.mockResolvedValueOnce( + makeResponse({ + tree: [ + { path: "a.ts", type: "blob", size: 10 }, + { path: "b.ts", type: "blob", size: 20 }, + ], + }) + ); + + const result = await adapter.listAllFiles("main"); + + expect(result.files).toHaveLength(2); + // Should only make 2 fetch calls (branch + 1 tree page) + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("throws when branch cannot be resolved to a tree SHA", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse({ commit: {} }) + ); + + await expect(adapter.listAllFiles("main")).rejects.toThrow( + "Could not resolve branch to a tree SHA" + ); + }); + + it("encodes owner and repo in URL", async () => { + const a = new GiteaRepoAdapter( + { personalAccessToken: "tok" }, + { baseUrl: "https://gitea.example.com", owner: "my org", repo: "my repo" } + ); + (a as any).rateLimitDelay = 0; + + mockFetch.mockResolvedValueOnce( + makeResponse({ + commit: { commit: { tree: { sha: "abc" } } }, + }) + ); + mockFetch.mockResolvedValueOnce( + makeResponse({ tree: [] }) + ); + + await a.listAllFiles("main"); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/repos/my%20org/my%20repo/branches/"), + expect.any(Object) + ); + }); + }); + + describe("getFileContent", () => { + it("fetches raw file content from Gitea API", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse("console.log('hello')") + ); + + const result = await adapter.getFileContent("src/index.ts", "main"); + expect(result).toBe("console.log('hello')"); + }); + + it("includes ref parameter in URL", async () => { + mockFetch.mockResolvedValueOnce(makeResponse("content")); + + await adapter.getFileContent("src/index.ts", "feat/branch"); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("?ref=feat%2Fbranch"), + expect.any(Object) + ); + }); + }); + + describe("testConnection", () => { + it("returns success with default branch", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse({ default_branch: "develop" }) + ); + + const result = await adapter.testConnection(); + + expect(result.success).toBe(true); + expect(result.defaultBranch).toBe("develop"); + }); + + it("returns error on failure", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse({ message: "Not Found" }, 404) + ); + + const result = await adapter.testConnection(); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe("authentication", () => { + it("sends token in Authorization header", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse({ default_branch: "main" }) + ); + + await adapter.testConnection(); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "token test-token-123", + Accept: "application/json", + }), + }) + ); + }); + }); +}); diff --git a/testplanit/lib/integrations/adapters/GiteaRepoAdapter.ts b/testplanit/lib/integrations/adapters/GiteaRepoAdapter.ts new file mode 100644 index 00000000..1cddb639 --- /dev/null +++ b/testplanit/lib/integrations/adapters/GiteaRepoAdapter.ts @@ -0,0 +1,117 @@ +/** + * Git repository adapter for Gitea, Forgejo, and Gogs. + * All three platforms expose a compatible /api/v1/ REST API. + */ +import { + GitRepoAdapter, ListFilesResult, RepoFileEntry, TestConnectionResult +} from "./GitRepoAdapter"; + +const MAX_FILES = 10000; + +export class GiteaRepoAdapter extends GitRepoAdapter { + private personalAccessToken: string; + private owner: string; + private repo: string; + private baseUrl: string; + + constructor( + credentials: Record, + settings: Record | null | undefined + ) { + super(); + this.personalAccessToken = credentials.personalAccessToken; + this.owner = settings?.owner ?? ""; + this.repo = settings?.repo ?? ""; + this.baseUrl = (settings?.baseUrl ?? "").replace(/\/$/, ""); + this.baseUrl = this.sanitizeUrl(this.baseUrl); + } + + private get authHeaders() { + return { + Authorization: `token ${this.personalAccessToken}`, + Accept: "application/json", + }; + } + + async getDefaultBranch(): Promise { + const data = await this.makeRequest( + `${this.baseUrl}/api/v1/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}`, + { headers: this.authHeaders } + ); + return data.default_branch; + } + + async listAllFiles(branch: string): Promise { + // Step 1: Resolve branch to tree SHA + const branchData = await this.makeRequest( + `${this.baseUrl}/api/v1/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}/branches/${encodeURIComponent(branch)}`, + { headers: this.authHeaders } + ); + const treeSha: string = branchData.commit?.commit?.tree?.sha + ?? branchData.commit?.id + ?? branchData.commit?.sha; + + if (!treeSha) { + throw new Error("Could not resolve branch to a tree SHA"); + } + + // Step 2: Fetch recursive tree (paginated) + const files: RepoFileEntry[] = []; + let page = 1; + let truncated = false; + + while (files.length < MAX_FILES) { + const treeData = await this.makeRequest( + `${this.baseUrl}/api/v1/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}/git/trees/${treeSha}?recursive=true&per_page=100&page=${page}`, + { headers: this.authHeaders } + ); + + if (treeData.truncated) { + truncated = true; + } + + const entries: any[] = treeData.tree ?? []; + if (entries.length === 0) break; + + const fileEntries = entries + .filter((item: any) => item.type === "blob") + .map((item: any) => ({ + path: item.path as string, + size: (item.size as number) ?? 0, + type: "file" as const, + })); + files.push(...fileEntries); + + // Gitea returns total_count when paginated; stop when we've got all pages + const totalCount = treeData.total_count; + if (totalCount !== undefined && files.length >= totalCount) break; + + // If this page returned fewer than requested, we're done + if (entries.length < 100) break; + + page++; + } + + return { files: files.slice(0, MAX_FILES), truncated }; + } + + async getFileContent(path: string, branch: string): Promise { + // Gitea raw endpoint returns file content directly as text + return this.makeTextRequest( + `${this.baseUrl}/api/v1/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}/raw/${path}?ref=${encodeURIComponent(branch)}`, + { headers: this.authHeaders } + ); + } + + async testConnection(): Promise { + try { + const data = await this.makeRequest( + `${this.baseUrl}/api/v1/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}`, + { headers: this.authHeaders } + ); + return { success: true, defaultBranch: data.default_branch }; + } catch (err: any) { + return { success: false, error: err.message }; + } + } +} diff --git a/testplanit/lib/openapi/zenstack-openapi.json b/testplanit/lib/openapi/zenstack-openapi.json index 2e9f2f57..dc9bc548 100644 --- a/testplanit/lib/openapi/zenstack-openapi.json +++ b/testplanit/lib/openapi/zenstack-openapi.json @@ -598,7 +598,8 @@ "GITHUB", "GITLAB", "BITBUCKET", - "AZURE_DEVOPS" + "AZURE_DEVOPS", + "GITEA" ] }, "LlmProvider": { diff --git a/testplanit/messages/en-US.json b/testplanit/messages/en-US.json index 5d154c0a..9c262195 100644 --- a/testplanit/messages/en-US.json +++ b/testplanit/messages/en-US.json @@ -3199,7 +3199,8 @@ "repositoryCreated": "Repository created", "saveFailed": "Failed to save repository", "namePlaceholder": "My Repository", - "selectProvider": "Select provider" + "selectProvider": "Select provider", + "httpWarning": "This URL uses HTTP. Credentials will be sent in plaintext. Use HTTPS for secure connections." }, "exportTemplates": { "title": "QuickScript Templates", @@ -4854,7 +4855,7 @@ }, "codeRepository": { "name": "## Repository Name\nA friendly display name for this repository connection.\n\n- Used to identify it in project code context settings.\n- Does not need to match the repository name on the provider.", - "provider": "## Git Provider\nThe platform hosting this repository.\n\n- **GitHub** — github.com (cloud or Enterprise)\n- **GitLab** — gitlab.com or self-hosted\n- **Bitbucket Cloud** — bitbucket.org (cloud only)\n- **Azure DevOps** — dev.azure.com", + "provider": "## Git Provider\nThe platform hosting this repository.\n\n- **GitHub** — github.com (cloud or Enterprise)\n- **GitLab** — gitlab.com or self-hosted\n- **Bitbucket Cloud** — bitbucket.org (cloud only)\n- **Azure DevOps** — dev.azure.com\n- **Gitea / Forgejo / Gogs** — self-hosted Git servers with compatible APIs", "githubToken": "## Personal Access Token\nA GitHub token with **Contents: Read** permission.\n\nGenerate one at: GitHub → Profile Settings → Developer settings → Personal access tokens → Fine-grained tokens.", "owner": "## Owner\nThe GitHub organization or user account that owns the repository.\n\n- Example: `myorg` or `myusername`\n- This is the first path segment in your repository URL on github.com", "repo": "## Repository\nThe repository name, without the owner prefix.\n\n- Example: `my-repo` (not `myorg/my-repo`)\n- This is the second path segment in your repository URL on github.com", @@ -4868,7 +4869,11 @@ "azureToken": "## Personal Access Token\nAn Azure DevOps PAT with **Code (Read)** permission.\n\nCreate one at: Azure DevOps → User settings → Personal access tokens.", "organizationUrl": "## Organization URL\nYour Azure DevOps organization URL.\n\n- Example: `https://dev.azure.com/myorg`", "project": "## Project Name\nThe name of the Azure DevOps project containing the repository.\n\n- Found as the third path segment in your Azure DevOps URL after the organization name", - "azureRepoId": "## Repository Name or ID\nThe repository name or GUID within the Azure DevOps project.\n\n- Example: `my-repo`" + "azureRepoId": "## Repository Name or ID\nThe repository name or GUID within the Azure DevOps project.\n\n- Example: `my-repo`", + "giteaToken": "## Personal Access Token\nA token with repository read permissions.\n\nGenerate one at: Site Administration → Applications, or User Settings → Applications.\n\n- Works with Gitea, Forgejo, Gogs, and any server exposing a compatible API.", + "giteaBaseUrl": "## Server URL\nThe base URL of your self-hosted Git server.\n\n- Example: `https://gitea.example.com`\n- Must include the protocol (https:// recommended)\n- Works with Gitea, Forgejo, Gogs, and other servers with a Gitea-compatible `/api/v1/` REST API", + "giteaOwner": "## Owner\nThe organization or user account that owns the repository.\n\n- Example: `myorg` or `myusername`\n- This is the first path segment in your repository URL", + "giteaRepo": "## Repository\nThe repository name, without the owner prefix.\n\n- Example: `my-repo` (not `myorg/my-repo`)" }, "promptConfig": { "name": "## Configuration Name\nA unique, descriptive name for this prompt configuration.\n\n- Used to identify the configuration when assigning it to a project (e.g. \"GPT-4o Prompts\", \"Conservative Prompts\").", diff --git a/testplanit/messages/es-ES.json b/testplanit/messages/es-ES.json index fd63ae08..7aa54991 100644 --- a/testplanit/messages/es-ES.json +++ b/testplanit/messages/es-ES.json @@ -3199,7 +3199,8 @@ "repositoryCreated": "Repositorio creado", "saveFailed": "No se pudo guardar el repositorio", "namePlaceholder": "Mi repositorio", - "selectProvider": "Seleccionar proveedor" + "selectProvider": "Seleccionar proveedor", + "httpWarning": "Esta URL utiliza HTTP. Las credenciales se enviarán en texto plano. Utilice HTTPS para conexiones seguras." }, "exportTemplates": { "title": "Plantillas de QuickScript", @@ -4854,7 +4855,7 @@ }, "codeRepository": { "name": "## Nombre del repositorio\nUn nombre para mostrar descriptivo para esta conexión de repositorio.\n\n- Se utiliza para identificarlo en la configuración del contexto del código del proyecto.\n- No necesita coincidir con el nombre del repositorio en el proveedor.", - "provider": "## Proveedor de Git\nLa plataforma que aloja este repositorio.\n\n- **GitHub** — github.com (nube o Enterprise)\n- **GitLab** — gitlab.com o autoalojado\n- **Bitbucket Cloud** — bitbucket.org (solo en la nube)\n- **Azure DevOps** — dev.azure.com", + "provider": "## Proveedor de Git\nLa plataforma que aloja este repositorio.\n\n- **GitHub** — github.com (cloud o Enterprise)\n- **GitLab** — gitlab.com o autoalojado\n- **Bitbucket Cloud** — bitbucket.org (solo en la nube)\n- **Azure DevOps** — dev.azure.com\n- **Gitea / Forgejo / Gogs** — servidores Git autoalojados con API compatibles", "githubToken": "## Token de acceso personal\nUn token de GitHub con permiso de **Contenido: Lectura**.\n\nGenere uno en: GitHub → Configuración de perfil → Configuración de desarrollador → Tokens de acceso personal → Tokens de grano fino.", "owner": "## Propietario\nLa organización de GitHub o la cuenta de usuario que posee el repositorio.\n\n- Ejemplo: `myorg` o `myusername`\n- Este es el primer segmento de ruta en la URL de su repositorio en github.com", "repo": "## Repositorio\nEl nombre del repositorio, sin el prefijo del propietario.\n\n- Ejemplo: `my-repo` (no `myorg/my-repo`)\n- Este es el segundo segmento de ruta en la URL de su repositorio en github.com", @@ -4868,7 +4869,11 @@ "azureToken": "## Token de acceso personal\nUn PAT de Azure DevOps con permiso **Código (lectura)**.\n\nCree uno en: Azure DevOps → Configuración de usuario → Tokens de acceso personal.", "organizationUrl": "## URL de la organización\nLa URL de su organización de Azure DevOps.\n\n- Ejemplo: `https://dev.azure.com/myorg`", "project": "## Nombre del proyecto\nEl nombre del proyecto de Azure DevOps que contiene el repositorio.\n\n- Se encuentra como el tercer segmento de ruta en la URL de Azure DevOps después del nombre de la organización", - "azureRepoId": "## Nombre o ID del repositorio\nEl nombre del repositorio o GUID dentro del proyecto de Azure DevOps.\n\n- Ejemplo: `my-repo`" + "azureRepoId": "## Nombre o ID del repositorio\nEl nombre del repositorio o GUID dentro del proyecto de Azure DevOps.\n\n- Ejemplo: `my-repo`", + "giteaToken": "## Token de acceso personal\nUn token con permisos de lectura de repositorio.\n\nGenere uno en: Administración del sitio → Aplicaciones, o Configuración de usuario → Aplicaciones.\n\n- Funciona con Gitea, Forgejo, Gogs y cualquier servidor que exponga una API compatible.", + "giteaBaseUrl": "## URL del servidor\nLa URL base de tu servidor Git autohospedado.\n\n- Ejemplo: `https://gitea.example.com`\n- Debe incluir el protocolo (https:// recomendado)\n- Funciona con Gitea, Forgejo, Gogs y otros servidores con una API REST `/api/v1/` compatible con Gitea", + "giteaOwner": "## Propietario\nLa organización o cuenta de usuario propietaria del repositorio.\n\n- Ejemplo: `myorg` o `myusername`\n- Este es el primer segmento de ruta en la URL de su repositorio.", + "giteaRepo": "## Repositorio\nEl nombre del repositorio, sin el prefijo del propietario.\n\n- Ejemplo: `my-repo` (no `myorg/my-repo`)" }, "promptConfig": { "name": "## Nombre de configuración\nUn nombre descriptivo único para esta configuración de solicitud.\n\n- Se utiliza para identificar la configuración al asignarla a un proyecto (por ejemplo, \"Solicitantes GPT-4o\", \"Solicitantes conservadores\").", diff --git a/testplanit/messages/fr-FR.json b/testplanit/messages/fr-FR.json index 036bc1d8..b3653544 100644 --- a/testplanit/messages/fr-FR.json +++ b/testplanit/messages/fr-FR.json @@ -3199,7 +3199,8 @@ "repositoryCreated": "Dépôt créé", "saveFailed": "Échec de l'enregistrement du dépôt", "namePlaceholder": "Mon dépôt", - "selectProvider": "Sélectionner le fournisseur" + "selectProvider": "Sélectionner le fournisseur", + "httpWarning": "Cette URL utilise le protocole HTTP. Vos identifiants seront envoyés en clair. Utilisez HTTPS pour une connexion sécurisée." }, "exportTemplates": { "title": "Modèles QuickScript", @@ -4854,7 +4855,7 @@ }, "codeRepository": { "name": "## Nom du dépôt\nNom convivial pour cette connexion au dépôt.\n\n- Utilisé pour l'identifier dans les paramètres contextuels du code du projet.\n- Ne doit pas nécessairement correspondre au nom du dépôt chez le fournisseur.", - "provider": "## Fournisseur Git\nLa plateforme hébergeant ce dépôt.\n\n- **GitHub** — github.com (cloud ou Enterprise)\n- **GitLab** — gitlab.com ou auto-hébergé\n- **Bitbucket Cloud** — bitbucket.org (cloud uniquement)\n- **Azure DevOps** — dev.azure.com", + "provider": "## Fournisseur Git\nLa plateforme hébergeant ce dépôt.\n\n- **GitHub** — github.com (cloud ou Enterprise)\n- **GitLab** — gitlab.com ou auto-hébergé\n- **Bitbucket Cloud** — bitbucket.org (cloud uniquement)\n- **Azure DevOps** — dev.azure.com\n- **Gitea / Forgejo / Gogs** — Serveurs Git auto-hébergés avec API compatibles", "githubToken": "## Jeton d'accès personnel\nUn jeton GitHub avec l'autorisation **Contenu : Lecture**.\n\nGénérez-en un ici : GitHub → Paramètres du profil → Paramètres développeur → Jetons d'accès personnels → Jetons à granularité fine.", "owner": "## Propriétaire\nL'organisation GitHub ou le compte utilisateur propriétaire du dépôt.\n\n- Exemple : `myorg` ou `myusername`\n- Il s'agit du premier segment du chemin d'accès dans l'URL de votre dépôt sur github.com", "repo": "## Dépôt\nLe nom du dépôt, sans le préfixe du propriétaire.\n\n- Exemple : `my-repo` (et non `myorg/my-repo`)\n- Il s'agit du deuxième segment du chemin dans l'URL de votre dépôt sur github.com", @@ -4868,7 +4869,11 @@ "azureToken": "## Jeton d'accès personnel\nUn jeton d'accès personnel Azure DevOps avec l'autorisation **Lecture du code**.\n\nCréez-en un à l'adresse : Azure DevOps → Paramètres utilisateur → Jetons d'accès personnels.", "organizationUrl": "## URL de l'organisation\nURL de votre organisation Azure DevOps.\n\n- Exemple : `https://dev.azure.com/myorg`", "project": "## Nom du projet\nLe nom du projet Azure DevOps contenant le dépôt.\n\n- Se trouve comme troisième segment de chemin dans votre URL Azure DevOps après le nom de l'organisation.", - "azureRepoId": "## Nom ou ID du dépôt\nLe nom ou le GUID du dépôt dans le projet Azure DevOps.\n\n- Exemple : `my-repo`" + "azureRepoId": "## Nom ou ID du dépôt\nLe nom ou le GUID du dépôt dans le projet Azure DevOps.\n\n- Exemple : `my-repo`", + "giteaToken": "## Jeton d'accès personnel\nJeton disposant des autorisations de lecture du dépôt.\n\nGénérez-en un dans : Administration du site → Applications ou Paramètres utilisateur → Applications.\n\n- Compatible avec Gitea, Forgejo, Gogs et tout serveur exposant une API compatible.", + "giteaBaseUrl": "## URL du serveur\nL'URL de base de votre serveur Git auto-hébergé.\n\n- Exemple : `https://gitea.example.com`\n- Doit inclure le protocole (https:// recommandé)\n- Fonctionne avec Gitea, Forgejo, Gogs et autres serveurs disposant d'une API REST `/api/v1/` compatible avec Gitea", + "giteaOwner": "## Propriétaire\nL'organisation ou le compte utilisateur propriétaire du dépôt.\n\n- Exemple : `myorg` ou `myusername`\n- Premier segment du chemin d'accès dans l'URL de votre dépôt", + "giteaRepo": "## Dépôt\nLe nom du dépôt, sans le préfixe du propriétaire.\n\n- Exemple : `my-repo` (et non `myorg/my-repo`)" }, "promptConfig": { "name": "## Nom de la configuration\nUn nom unique et descriptif pour cette configuration d'invite.\n\n- Utilisé pour identifier la configuration lors de son attribution à un projet (par exemple « Invites GPT-4o », « Invites conservatrices »).", diff --git a/testplanit/prisma/schema.prisma b/testplanit/prisma/schema.prisma index de428b45..0017b047 100644 --- a/testplanit/prisma/schema.prisma +++ b/testplanit/prisma/schema.prisma @@ -166,6 +166,7 @@ enum CodeRepositoryProvider { GITLAB BITBUCKET AZURE_DEVOPS + GITEA } enum ApplicationArea { diff --git a/testplanit/schema.zmodel b/testplanit/schema.zmodel index af59c9ad..f9aab89c 100644 --- a/testplanit/schema.zmodel +++ b/testplanit/schema.zmodel @@ -1244,20 +1244,20 @@ enum DuplicateScanResultStatus { } model DuplicateScanResult { - id Int @id @default(autoincrement()) - projectId Int - project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) - caseAId Int - caseA RepositoryCases @relation("DuplicateCaseA", fields: [caseAId], references: [id], onDelete: Cascade) - caseBId Int - caseB RepositoryCases @relation("DuplicateCaseB", fields: [caseBId], references: [id], onDelete: Cascade) - score Float + id Int @id @default(autoincrement()) + projectId Int + project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) + caseAId Int + caseA RepositoryCases @relation("DuplicateCaseA", fields: [caseAId], references: [id], onDelete: Cascade) + caseBId Int + caseB RepositoryCases @relation("DuplicateCaseB", fields: [caseBId], references: [id], onDelete: Cascade) + score Float matchedFields String[] detectionMethod String @default("fuzzy") status DuplicateScanResultStatus @default(PENDING) - scanJobId String? - isDeleted Boolean @default(false) - createdAt DateTime @default(now()) @db.Timestamptz(6) + scanJobId String? + isDeleted Boolean @default(false) + createdAt DateTime @default(now()) @db.Timestamptz(6) @@unique([caseAId, caseBId, scanJobId]) @@index([projectId, status, isDeleted]) @@ -1318,16 +1318,16 @@ enum StepSequenceMatchStatus { } model StepSequenceMatch { - id Int @id @default(autoincrement()) - projectId Int - project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) - fingerprint String - stepCount Int - status StepSequenceMatchStatus @default(PENDING) - scanJobId String? - isDeleted Boolean @default(false) - createdAt DateTime @default(now()) @db.Timestamptz(6) - members StepSequenceMatchCase[] + id Int @id @default(autoincrement()) + projectId Int + project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) + fingerprint String + stepCount Int + status StepSequenceMatchStatus @default(PENDING) + scanJobId String? + isDeleted Boolean @default(false) + createdAt DateTime @default(now()) @db.Timestamptz(6) + members StepSequenceMatchCase[] @@unique([projectId, fingerprint, scanJobId]) @@index([projectId, status, isDeleted]) @@ -1369,14 +1369,14 @@ model StepSequenceMatch { } model StepSequenceMatchCase { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) matchId Int - match StepSequenceMatch @relation(fields: [matchId], references: [id], onDelete: Cascade) + match StepSequenceMatch @relation(fields: [matchId], references: [id], onDelete: Cascade) caseId Int - case RepositoryCases @relation(fields: [caseId], references: [id], onDelete: Cascade) + case RepositoryCases @relation(fields: [caseId], references: [id], onDelete: Cascade) startStepId Int endStepId Int - isDeleted Boolean @default(false) + isDeleted Boolean @default(false) @@unique([matchId, caseId]) @@index([caseId]) @@ -2545,6 +2545,7 @@ enum CodeRepositoryProvider { GITLAB BITBUCKET AZURE_DEVOPS + GITEA @@deny('all', !auth()) @@allow('update', auth().access == 'ADMIN') diff --git a/testplanit/utils/ssrf.test.ts b/testplanit/utils/ssrf.test.ts index f6890541..08913d01 100644 --- a/testplanit/utils/ssrf.test.ts +++ b/testplanit/utils/ssrf.test.ts @@ -1,5 +1,13 @@ -import { describe, expect, it } from "vitest"; -import { isSsrfSafe } from "./ssrf"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockLookup = vi.hoisted(() => vi.fn()); + +vi.mock("node:dns/promises", () => ({ + default: { lookup: mockLookup }, + lookup: mockLookup, +})); + +import { assertSsrfSafeResolved, isSsrfSafe } from "./ssrf"; describe("isSsrfSafe", () => { describe("blocks localhost", () => { @@ -65,6 +73,10 @@ describe("isSsrfSafe", () => { it("blocks fd00:: (unique local)", () => { expect(isSsrfSafe("https://[fd12::1]/api")).toBe(false); }); + + it("blocks fe80:: (link-local)", () => { + expect(isSsrfSafe("https://[fe80::1]/api")).toBe(false); + }); }); describe("blocks non-HTTP protocols", () => { @@ -97,6 +109,10 @@ describe("isSsrfSafe", () => { it("allows Azure DevOps URLs", () => { expect(isSsrfSafe("https://dev.azure.com/myorg")).toBe(true); }); + + it("allows self-hosted Gitea URLs", () => { + expect(isSsrfSafe("https://gitea.mycompany.com/api/v1")).toBe(true); + }); }); describe("handles invalid input", () => { @@ -109,3 +125,77 @@ describe("isSsrfSafe", () => { }); }); }); + +describe("assertSsrfSafeResolved", () => { + beforeEach(() => { + mockLookup.mockReset(); + }); + + describe("blocks DNS rebinding attacks", () => { + it("throws when hostname resolves to loopback", async () => { + mockLookup.mockResolvedValueOnce({ address: "127.0.0.1", family: 4 } as any); + + await expect( + assertSsrfSafeResolved("https://evil.example.com/api") + ).rejects.toThrow("hostname resolves to a private or internal address"); + }); + + it("throws when hostname resolves to private 10.x.x.x", async () => { + mockLookup.mockResolvedValueOnce({ address: "10.0.0.1", family: 4 } as any); + + await expect( + assertSsrfSafeResolved("https://evil.example.com/api") + ).rejects.toThrow("hostname resolves to a private or internal address"); + }); + + it("throws when hostname resolves to 192.168.x.x", async () => { + mockLookup.mockResolvedValueOnce({ address: "192.168.1.1", family: 4 } as any); + + await expect( + assertSsrfSafeResolved("https://evil.example.com/api") + ).rejects.toThrow("hostname resolves to a private or internal address"); + }); + + it("throws when hostname resolves to AWS metadata IP", async () => { + mockLookup.mockResolvedValueOnce({ address: "169.254.169.254", family: 4 } as any); + + await expect( + assertSsrfSafeResolved("https://evil.example.com/api") + ).rejects.toThrow("hostname resolves to a private or internal address"); + }); + }); + + describe("allows safe resolved addresses", () => { + it("passes when hostname resolves to a public IP", async () => { + mockLookup.mockResolvedValueOnce({ address: "140.82.121.4", family: 4 } as any); + + await expect( + assertSsrfSafeResolved("https://github.com/api") + ).resolves.not.toThrow(); + }); + }); + + describe("skips DNS lookup for raw IPs", () => { + it("skips lookup for IPv4 addresses", async () => { + await assertSsrfSafeResolved("https://140.82.121.4/api"); + + expect(mockLookup).not.toHaveBeenCalled(); + }); + + it("skips lookup for IPv6 addresses", async () => { + await assertSsrfSafeResolved("https://[2606:4700::1]/api"); + + expect(mockLookup).not.toHaveBeenCalled(); + }); + }); + + describe("handles DNS failures", () => { + it("throws on DNS resolution failure", async () => { + mockLookup.mockRejectedValueOnce(new Error("ENOTFOUND")); + + await expect( + assertSsrfSafeResolved("https://nonexistent.example.com/api") + ).rejects.toThrow("DNS resolution failed"); + }); + }); +}); diff --git a/testplanit/utils/ssrf.ts b/testplanit/utils/ssrf.ts index d3a92a76..6eba9110 100644 --- a/testplanit/utils/ssrf.ts +++ b/testplanit/utils/ssrf.ts @@ -1,3 +1,5 @@ +import { lookup } from "node:dns/promises"; + // Private IP ranges that must be blocked to prevent SSRF attacks const PRIVATE_RANGES: RegExp[] = [ // IPv4 loopback @@ -15,8 +17,14 @@ const PRIVATE_RANGES: RegExp[] = [ // IPv6 unique local /^fc/i, /^fd/i, + // IPv6 link-local + /^fe80:/i, ]; +function isPrivateIp(ip: string): boolean { + return PRIVATE_RANGES.some((r) => r.test(ip)); +} + /** * Returns true if the URL is safe to make a server-side request to. * Blocks localhost, loopback addresses, and private IP ranges. @@ -34,7 +42,7 @@ export function isSsrfSafe(url: string): boolean { if (hostname === "localhost") return false; // Block if hostname is a private/loopback IP - if (PRIVATE_RANGES.some((r) => r.test(hostname))) return false; + if (isPrivateIp(hostname)) return false; // Only allow http/https if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { @@ -47,3 +55,33 @@ export function isSsrfSafe(url: string): boolean { return false; } } + +/** + * Resolve a URL's hostname via DNS and verify the resolved IP is not private. + * This closes the DNS rebinding gap where a public hostname resolves to a + * private/internal IP address. + * + * Call this immediately before fetch() to minimize the TOCTOU window. + * Throws if the resolved address is private or the hostname cannot be resolved. + */ +export async function assertSsrfSafeResolved(url: string): Promise { + const parsed = new URL(url); + const hostname = parsed.hostname.replace(/^\[|\]$/g, ""); + + // Skip DNS lookup for raw IP addresses — already checked by isSsrfSafe() + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.includes(":")) { + return; + } + + try { + const { address } = await lookup(hostname); + if (isPrivateIp(address)) { + throw new Error( + "Request blocked: hostname resolves to a private or internal address" + ); + } + } catch (err: any) { + if (err.message?.includes("Request blocked")) throw err; + throw new Error(`DNS resolution failed for ${hostname}: ${err.message}`); + } +}