diff --git a/.env.example b/.env.example index fcbb1d8..9fe6d36 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ # Copy this file to .env and set a strong random token before running podman/docker compose. # Generate one with: openssl rand -hex 32 RHESS_ADMIN_TOKEN= + +# Public origin for /.well-known artifact URLs and installCommand (no trailing slash). +# Required behind TLS-terminating proxies so clients get https:// URLs. +# PUBLIC_BASE_URL=https://rhess.example.com diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml index 9635a03..da91778 100644 --- a/deploy/deployment.yaml +++ b/deploy/deployment.yaml @@ -36,6 +36,10 @@ spec: value: /data/rhess.db - name: PORT value: "3000" + # Public origin used in /.well-known artifact URLs and installCommand. + # Must be the external https:// route URL when TLS terminates at the ingress. + # - name: PUBLIC_BASE_URL + # value: https://rhess.example.com volumeMounts: - name: data mountPath: /data diff --git a/docs/api.md b/docs/api.md index c61374a..3442924 100644 --- a/docs/api.md +++ b/docs/api.md @@ -57,7 +57,7 @@ List all indexed skills (paginated). "allowedTools": [], "skillPath": "skills/agent-ready/SKILL.md", "frontmatter": {}, - "installCommand": "npx skills add http://localhost:3000/api/v1/skills/rhdh-skills/agent-ready/artifact", + "installCommand": "npx skills add http://localhost:3000 --skill agent-ready", "lastModified": "2026-06-19T14:32:00Z" } ], @@ -133,7 +133,7 @@ Full skill detail including all files. "allowedTools": [], "skillPath": "skills/agent-ready/SKILL.md", "frontmatter": { "version": "1.0" }, - "installCommand": "npx skills add http://localhost:3000/api/v1/skills/rhdh-skills/agent-ready/artifact", + "installCommand": "npx skills add http://localhost:3000 --skill agent-ready", "lastModified": "2026-06-19T14:32:00Z", "content": "---\nname: agent-ready\n…", "files": [ @@ -155,7 +155,7 @@ Download the raw skill artifact. - Returns `text/markdown` for `skill-md` type. - Returns `application/gzip` (tar.gz) for `archive` type. -This is the URL referenced in the Agent Skills discovery index and used in `installCommand`. +This is the URL referenced in the Agent Skills discovery index (`url` field). Install via the host + `--skill` form in `installCommand`, not this artifact URL directly. **Response `404`** — skill not found. @@ -381,7 +381,7 @@ Agent Skills CLI discovery manifest ([spec](https://agentskills.io/spec/)). List ```json { - "$schema": "https://agentskills.io/schema/v0.2.0/index.json", + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", "skills": [ { "name": "agent-ready", diff --git a/src/server/ingestion/ingest.ts b/src/server/ingestion/ingest.ts index 45d0b1f..26ec25c 100644 --- a/src/server/ingestion/ingest.ts +++ b/src/server/ingestion/ingest.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { clone, discoverSkills, parseFrontmatter, bundleSkill } from "./index.js"; import type { Repositories } from "../db/init.js"; import type { UpsertSkillInput } from "../db/types.js"; +import { isValidSkillInstallId } from "../utils/skillInstallId.js"; export interface SkillIndexEntry { slug: string; @@ -48,6 +49,16 @@ export async function ingestFromClonedPath( for (const candidate of candidates) { const relativePath = path.relative(repoPath, candidate.skillMdPath); try { + if (!isValidSkillInstallId(candidate.slug)) { + failures.push({ + path: relativePath, + reason: + `Invalid skill install id '${candidate.slug}': must be 1–64 lowercase ` + + "kebab-case characters ([a-z0-9]+(?:-[a-z0-9]+)*) for npx skills CLI compatibility", + }); + continue; + } + const bundleResult = await bundleSkill(candidate); // For skill-md, the artifact IS the raw SKILL.md content — reuse it to diff --git a/src/server/routes/skills.ts b/src/server/routes/skills.ts index e8d1a98..72bf520 100644 --- a/src/server/routes/skills.ts +++ b/src/server/routes/skills.ts @@ -8,6 +8,7 @@ import type { SearchProvider } from "../search/types.js"; import type { Repositories } from "../db/init.js"; import { createAdminAuthHook } from "../plugins/adminAuth.js"; import { ingestSource } from "../ingestion/ingest.js"; +import { resolveBaseUrl } from "../utils/resolveBaseUrl.js"; /** * Decode a base64 tar.gz archive and return all contained files as @@ -48,20 +49,12 @@ async function expandArchiveToFiles( } } -/** - * Derive the server's public base URL for constructing install commands. - * Mirrors the logic in wellKnown.ts. - */ -function resolveBaseUrl(req: FastifyRequest): string { - const configured = process.env["PUBLIC_BASE_URL"]; - if (configured) return configured.replace(/\/+$/, ""); - const raw = req.headers.host; - const host = (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? req.hostname; - return `${req.protocol}://${host}`; -} - function skillToResponse(skill: Skill, source: Source | undefined, baseUrl: string) { - const installCommand = `npx skills add ${baseUrl}/api/v1/skills/${encodeURIComponent(skill.sourceSlug)}/${encodeURIComponent(skill.slug)}/artifact`; + // Point at the host so the CLI discovers via /.well-known/agent-skills/index.json, + // then select this skill. Artifact URLs are not a valid skills-add source by themselves + // and would make the CLI fetch every skill in the index. + // Use --skill= (single argv token) so odd slug characters cannot split the command. + const installCommand = `npx skills add ${baseUrl} --skill=${skill.slug}`; return { id: skill.id, source: skill.sourceSlug, @@ -122,7 +115,10 @@ const skillSchema = { allowedTools: { type: "array", items: { type: "string" }, description: "Tools the skill is allowed to use" }, skillPath: { type: "string", description: "Relative path to SKILL.md within the source repository" }, frontmatter: { type: "object", additionalProperties: true, description: "Full parsed frontmatter map (excluding name/description)" }, - installCommand: { type: "string", description: "npx skills add … command to install this skill" }, + installCommand: { + type: "string", + description: "npx skills add --skill command to install this skill via well-known discovery", + }, lastModified: { type: "string", description: "ISO 8601 timestamp of last update" }, }, required: ["id", "source", "slug", "name", "description", "artifactType", "digest", "allowedTools", "skillPath", "installCommand", "lastModified"], diff --git a/src/server/routes/wellKnown.ts b/src/server/routes/wellKnown.ts index 3bf9c66..f023430 100644 --- a/src/server/routes/wellKnown.ts +++ b/src/server/routes/wellKnown.ts @@ -1,34 +1,12 @@ import type { FastifyPluginAsync, FastifyRequest } from "fastify"; import type { SkillRepository } from "../db/types.js"; +import { resolveBaseUrl } from "../utils/resolveBaseUrl.js"; +import { isValidSkillInstallId } from "../utils/skillInstallId.js"; interface WellKnownOptions { skills: SkillRepository; } -/** - * Derive the server's public base URL. - * - * Priority: - * 1. PUBLIC_BASE_URL env var — the only trusted source for proxy deployments. - * Must be set when RHESS sits behind a reverse proxy. - * 2. req.protocol + Host header — safe for direct (non-proxied) access. - * req.hostname strips the port, so we use req.headers.host (e.g. - * "localhost:3000") to preserve it. Do NOT read X-Forwarded-* headers - * directly; they are client-controlled without trustProxy configuration. - */ -function resolveBaseUrl(req: FastifyRequest): string { - const configured = process.env["PUBLIC_BASE_URL"]; - if (configured) return configured.replace(/\/+$/, ""); - // Host header is an HTTP/1.1 request requirement and comes from the actual - // TCP connection in direct deployments — more trustworthy than X-Forwarded-* - // but must be normalized: take first value if somehow multi-valued, strip - // any surrounding whitespace, and fall back to req.hostname (port-less) only - // as a last resort. - const raw = req.headers.host; - const host = (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? req.hostname; - return `${req.protocol}://${host}`; -} - const wellKnownPlugin: FastifyPluginAsync = async (fastify, opts) => { fastify.get("/agent-skills/index.json", { schema: { @@ -66,14 +44,23 @@ const wellKnownPlugin: FastifyPluginAsync = async (fastify, op const baseUrl = resolveBaseUrl(req); const entries = opts.skills.findAllDiscoveryEntries(); return reply.send({ - $schema: "https://agentskills.io/schema/v0.2.0/index.json", - skills: entries.map((s) => ({ - name: s.name, - type: s.artifactType, - description: s.description, - url: `${baseUrl}/api/v1/skills/${encodeURIComponent(s.sourceSlug)}/${encodeURIComponent(s.slug)}/artifact`, - digest: `sha256:${s.digest}`, - })), + // Opaque schema URI required by the Agent Skills discovery RFC / npx skills CLI. + // Clients match this exactly; a different v0.2.0-looking URI is rejected. + $schema: "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + skills: entries + .filter((s) => isValidSkillInstallId(s.slug)) + .map((s) => ({ + // Discovery `name` must be a kebab-case install id ([a-z0-9-]+). Prefer slug + // over frontmatter display names that may contain spaces or mixed case. + name: s.slug, + type: s.artifactType, + // CLI rejects descriptions longer than 1024 characters. + description: s.description.length > 1024 + ? `${s.description.slice(0, 1023)}…` + : s.description, + url: `${baseUrl}/api/v1/skills/${encodeURIComponent(s.sourceSlug)}/${encodeURIComponent(s.slug)}/artifact`, + digest: `sha256:${s.digest}`, + })), }); }); }; diff --git a/src/server/utils/resolveBaseUrl.ts b/src/server/utils/resolveBaseUrl.ts new file mode 100644 index 0000000..bef2e2c --- /dev/null +++ b/src/server/utils/resolveBaseUrl.ts @@ -0,0 +1,68 @@ +import type { FastifyRequest } from "fastify"; + +/** + * Host header values safe to embed in public URLs / install commands. + * Parses the value as an HTTP authority (via WHATWG URL) so hostname/IPv4/ + * bracketed IPv6 (+ optional port) are accepted, while credentials, paths, + * queries, fragments, and other spoof markers are rejected. + */ +export function isSafeHostHeader(host: string | undefined): boolean { + if (!host) return false; + if (host.length > 253) return false; + // Reject obvious spoof / multi-value markers before URL parsing. + if (/[\s@\\/,]/.test(host)) return false; + + try { + const parsed = new URL(`http://${host}`); + // Authority-only: no userinfo, path (beyond "/"), query, or fragment. + if (parsed.username || parsed.password) return false; + if (parsed.pathname !== "/") return false; + if (parsed.search || parsed.hash) return false; + if (!parsed.hostname) return false; + + // Port must be empty or an integer in 1–65535 (URL parser already validates syntax). + if (parsed.port) { + const port = Number(parsed.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) return false; + } + + // URL parsing lowercases DNS/IPv6; compare case-insensitively so valid Host + // values are not rejected solely due to canonicalization. + return parsed.host.toLowerCase() === host.toLowerCase(); + } catch { + return false; + } +} + +/** + * Return a canonical host authority for embedding in public URLs, or null if unsafe. + * Uses the URL parser's normalized form (lowercase DNS/IPv6, bracketed IPv6). + */ +export function canonicalizeHostHeader(host: string | undefined): string | null { + if (!isSafeHostHeader(host)) return null; + try { + return new URL(`http://${host}`).host; + } catch { + return null; + } +} + +/** + * Derive the server's public base URL. + * + * Priority: + * 1. PUBLIC_BASE_URL env var — the only trusted source for proxy deployments. + * 2. req.protocol + validated Host header — for direct (non-proxied) access. + * Invalid/spoofed Host values fall back to `localhost` (no port) rather than + * reflecting attacker-controlled origins into install commands / discovery URLs. + */ +export function resolveBaseUrl(req: FastifyRequest): string { + const configured = process.env["PUBLIC_BASE_URL"]; + if (configured) return configured.replace(/\/+$/, ""); + + const raw = req.headers.host; + const candidate = (Array.isArray(raw) ? raw[0] : raw)?.trim(); + const host = canonicalizeHostHeader(candidate) ?? "localhost"; + const protocol = req.protocol === "https" ? "https" : "http"; + return `${protocol}://${host}`; +} diff --git a/src/server/utils/skillInstallId.ts b/src/server/utils/skillInstallId.ts new file mode 100644 index 0000000..85f1584 --- /dev/null +++ b/src/server/utils/skillInstallId.ts @@ -0,0 +1,9 @@ +/** + * Agent Skills / npx skills CLI install-id rules (discovery `name` / `--skill`). + * Must be 1–64 chars, lowercase kebab-case, no leading/trailing hyphen, no `--`. + */ +const SKILL_INSTALL_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export function isValidSkillInstallId(value: string): boolean { + return value.length >= 1 && value.length <= 64 && SKILL_INSTALL_ID_RE.test(value); +} diff --git a/test/server/ingestion/ingest.test.ts b/test/server/ingestion/ingest.test.ts index 359736f..f557f7e 100644 --- a/test/server/ingestion/ingest.test.ts +++ b/test/server/ingestion/ingest.test.ts @@ -120,6 +120,29 @@ describe("ingestFromClonedPath", () => { expect(slugs).toContain("beta-skill"); }); + it("rejects directory names that are not CLI-compatible install ids", async () => { + tmpDir = makeTmpDir(); + await initRepo(tmpDir); + + writeSkill(tmpDir, "skills", "alpha-skill", VALID_SKILL_A); + writeSkill(tmpDir, "skills", "Bad Skill Name", VALID_SKILL_B); + writeSkill(tmpDir, "skills", "has_underscore", VALID_SKILL_C); + await commitAll(tmpDir); + + const repos = makeRepos(); + const source = repos.sources.create({ slug: "test-source", label: "test-source", url: "file:///test" }); + const report = await ingestFromClonedPath(source.id, source.slug, tmpDir, repos); + + expect(report.discovered).toBe(3); + expect(report.indexed).toBe(1); + expect(report.failed).toBe(2); + expect(report.failures.every((f) => /install id/i.test(f.reason))).toBe(true); + + const allSkills = repos.skills.findAll({ perPage: 100 }); + expect(allSkills).toHaveLength(1); + expect(allSkills[0]!.slug).toBe("alpha-skill"); + }); + it("scenario 2: malformed frontmatter → skipped + reported", async () => { tmpDir = makeTmpDir(); await initRepo(tmpDir); diff --git a/test/server/routes/skills.test.ts b/test/server/routes/skills.test.ts index 4cc0d77..667cde4 100644 --- a/test/server/routes/skills.test.ts +++ b/test/server/routes/skills.test.ts @@ -94,6 +94,32 @@ describe("GET /api/v1/skills", () => { expect(body.data[0]).toHaveProperty("allowedTools"); }); + it("installCommand targets the well-known host with --skill filter", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/v1/skills", + headers: { host: "localhost:3000" }, + }); + expect(res.statusCode).toBe(200); + const skill = res.json().data.find((s: { slug: string }) => s.slug === "react-patterns"); + expect(skill.installCommand).toBe( + "npx skills add http://localhost:3000 --skill=react-patterns", + ); + }); + + it("installCommand does not reflect spoofed Host headers", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/v1/skills", + headers: { host: "evil.example.com@attacker.test" }, + }); + expect(res.statusCode).toBe(200); + for (const skill of res.json().data) { + expect(skill.installCommand).not.toContain("attacker.test"); + expect(skill.installCommand).toMatch(/^npx skills add http:\/\/localhost --skill=/); + } + }); + it("paginates correctly", async () => { const res = await app.inject({ method: "GET", url: "/api/v1/skills?page=1&per_page=2" }); expect(res.statusCode).toBe(200); diff --git a/test/server/routes/wellKnown.test.ts b/test/server/routes/wellKnown.test.ts index 1e82ac1..017682b 100644 --- a/test/server/routes/wellKnown.test.ts +++ b/test/server/routes/wellKnown.test.ts @@ -76,7 +76,8 @@ describe("GET /.well-known/agent-skills/index.json", () => { expect(res.statusCode).toBe(200); const body = res.json(); expect(body).toHaveProperty("$schema"); - expect(body.$schema).toContain("v0.2.0"); + // Exact URI required by npx skills CLI (opaque match against known schemas) + expect(body.$schema).toBe("https://schemas.agentskills.io/discovery/0.2.0/schema.json"); expect(Array.isArray(body.skills)).toBe(true); }); @@ -104,6 +105,113 @@ describe("GET /.well-known/agent-skills/index.json", () => { } }); + it("uses kebab-case slug as discovery name for CLI compatibility", async () => { + const res = await app.inject({ + method: "GET", + url: "/.well-known/agent-skills/index.json", + }); + const { skills } = res.json(); + const react = skills.find((s: { url: string }) => s.url.includes("react-patterns")); + expect(react.name).toBe("react-patterns"); + expect(react.name).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); + }); + + it("omits skills whose slug is not a valid CLI install id", async () => { + const db = new BetterSqlite3(":memory:"); + db.pragma("foreign_keys = ON"); + runMigrations(db); + const sources = new SqliteSourceRepository(db); + const skills = new SqliteSkillRepository(db); + const src = sources.create({ slug: "team-a", label: "team-a", url: "https://example.com/repo" }); + skills.upsertMany([ + { + sourceId: src.id, + sourceSlug: "team-a", + slug: "valid-skill", + name: "Valid Skill", + description: "ok", + artifactType: "skill-md", + digest: "abc123", + content: "# Valid\n", + supportingFiles: [], + allowedTools: [], + skillPath: "skills/valid-skill/SKILL.md", + category: null, + frontmatter: {}, + }, + { + sourceId: src.id, + sourceSlug: "team-a", + slug: "Bad Name", + name: "Bad Name", + description: "invalid", + artifactType: "skill-md", + digest: "def456", + content: "# Bad\n", + supportingFiles: [], + allowedTools: [], + skillPath: "skills/Bad Name/SKILL.md", + category: null, + frontmatter: {}, + }, + ]); + const localApp = Fastify({ logger: false }); + await localApp.register(wellKnownPlugin, { prefix: "/.well-known", skills }); + await localApp.ready(); + try { + const res = await localApp.inject({ + method: "GET", + url: "/.well-known/agent-skills/index.json", + }); + const { skills: entries } = res.json(); + expect(entries).toHaveLength(1); + expect(entries[0].name).toBe("valid-skill"); + } finally { + await localApp.close(); + } + }); + + it("truncates discovery descriptions longer than 1024 characters", async () => { + const db = new BetterSqlite3(":memory:"); + db.pragma("foreign_keys = ON"); + runMigrations(db); + const sources = new SqliteSourceRepository(db); + const skills = new SqliteSkillRepository(db); + const src = sources.create({ slug: "team-a", label: "team-a", url: "https://example.com/repo" }); + const longDescription = "x".repeat(1100); + skills.upsertMany([ + { + sourceId: src.id, + sourceSlug: "team-a", + slug: "long-desc", + name: "Long Desc", + description: longDescription, + artifactType: "skill-md", + digest: "abc123", + content: "# Long\n", + supportingFiles: [], + allowedTools: [], + skillPath: "skills/long-desc/SKILL.md", + category: null, + frontmatter: {}, + }, + ]); + const localApp = Fastify({ logger: false }); + await localApp.register(wellKnownPlugin, { prefix: "/.well-known", skills }); + await localApp.ready(); + try { + const res = await localApp.inject({ + method: "GET", + url: "/.well-known/agent-skills/index.json", + }); + const entry = res.json().skills[0]; + expect(entry.description.length).toBe(1024); + expect(entry.description.endsWith("…")).toBe(true); + } finally { + await localApp.close(); + } + }); + it("digest is sha256-prefixed", async () => { const res = await app.inject({ method: "GET", @@ -240,4 +348,18 @@ describe("GET /.well-known/agent-skills/index.json — PUBLIC_BASE_URL", () => { expect(s.url).toContain("localhost:4000"); } }); + + it("does not reflect spoofed Host headers into artifact URLs", async () => { + delete process.env["PUBLIC_BASE_URL"]; + const res = await app.inject({ + method: "GET", + url: "/.well-known/agent-skills/index.json", + headers: { host: "evil.example.com@attacker.test" }, + }); + const { skills } = res.json(); + for (const s of skills) { + expect(s.url).not.toContain("attacker.test"); + expect(s.url).toMatch(/^https?:\/\/localhost\//); + } + }); }); diff --git a/test/server/utils/resolveBaseUrl.test.ts b/test/server/utils/resolveBaseUrl.test.ts new file mode 100644 index 0000000..6600187 --- /dev/null +++ b/test/server/utils/resolveBaseUrl.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, afterEach } from "vitest"; +import type { FastifyRequest } from "fastify"; +import { isSafeHostHeader, resolveBaseUrl } from "../../../src/server/utils/resolveBaseUrl.js"; + +describe("isSafeHostHeader", () => { + it("accepts DNS hostnames with optional port", () => { + expect(isSafeHostHeader("localhost")).toBe(true); + expect(isSafeHostHeader("localhost:3000")).toBe(true); + expect(isSafeHostHeader("rhess.example.com")).toBe(true); + expect(isSafeHostHeader("rhess.example.com:8443")).toBe(true); + }); + + it("accepts mixed-case DNS hosts that URL parsing lowercases", () => { + expect(isSafeHostHeader("EXAMPLE.COM")).toBe(true); + expect(isSafeHostHeader("Example.COM:3000")).toBe(true); + expect(isSafeHostHeader("[2001:DB8::1]:8080")).toBe(true); + }); + + it("accepts IPv4 with optional port", () => { + expect(isSafeHostHeader("127.0.0.1")).toBe(true); + expect(isSafeHostHeader("127.0.0.1:3000")).toBe(true); + }); + + it("accepts bracketed IPv6 with optional port", () => { + expect(isSafeHostHeader("[::1]")).toBe(true); + expect(isSafeHostHeader("[::1]:3000")).toBe(true); + expect(isSafeHostHeader("[2001:db8::1]")).toBe(true); + expect(isSafeHostHeader("[2001:db8::1]:8080")).toBe(true); + }); + + it("rejects spoofed or malformed hosts", () => { + expect(isSafeHostHeader(undefined)).toBe(false); + expect(isSafeHostHeader("")).toBe(false); + expect(isSafeHostHeader("evil.example.com@attacker.test")).toBe(false); + expect(isSafeHostHeader("host with spaces")).toBe(false); + expect(isSafeHostHeader("a,b")).toBe(false); + expect(isSafeHostHeader("example.com/path")).toBe(false); + expect(isSafeHostHeader("::1")).toBe(false); // must be bracketed in Host + expect(isSafeHostHeader("[::1]:99999")).toBe(false); + expect(isSafeHostHeader("user:pass@example.com")).toBe(false); + }); +}); + +describe("resolveBaseUrl", () => { + const original = process.env["PUBLIC_BASE_URL"]; + + afterEach(() => { + if (original === undefined) delete process.env["PUBLIC_BASE_URL"]; + else process.env["PUBLIC_BASE_URL"] = original; + }); + + function fakeReq(host: string, protocol: "http" | "https" = "http"): FastifyRequest { + return { + protocol, + headers: { host }, + hostname: "ignored", + } as unknown as FastifyRequest; + } + + it("prefers PUBLIC_BASE_URL", () => { + process.env["PUBLIC_BASE_URL"] = "https://rhess.example.com/"; + expect(resolveBaseUrl(fakeReq("localhost:3000"))).toBe("https://rhess.example.com"); + }); + + it("preserves IPv6 Host literals", () => { + delete process.env["PUBLIC_BASE_URL"]; + expect(resolveBaseUrl(fakeReq("[::1]:3000"))).toBe("http://[::1]:3000"); + }); + + it("emits canonical lowercase DNS hosts", () => { + delete process.env["PUBLIC_BASE_URL"]; + expect(resolveBaseUrl(fakeReq("Example.COM:3000"))).toBe("http://example.com:3000"); + }); + + it("falls back to localhost for spoofed Host", () => { + delete process.env["PUBLIC_BASE_URL"]; + expect(resolveBaseUrl(fakeReq("evil@attacker.test"))).toBe("http://localhost"); + }); +});