Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 79 additions & 4 deletions server/src/__tests__/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,89 @@ describe('Projects API', () => {
});

it('rejects an invalid repository URL without persisting a project', async () => {
await request(app)
.post(`/api/companies/${companyId}/projects`)
.send({ name: 'Invalid repository', repoUrl: 'not-a-url' })
.expect(400);
for (const repoUrl of ['not-a-url', 'javascript:alert(document.domain)']) {
await request(app)
.post(`/api/companies/${companyId}/projects`)
.send({ name: 'Invalid repository', repoUrl })
.expect(400);
}

const list = await request(app)
.get(`/api/companies/${companyId}/projects`)
.expect(200);
expect(list.body.data).toEqual([]);
});

it('updates and soft-archives a project with durable detail reads', async () => {
const created = await request(app)
.post(`/api/companies/${companyId}/projects`)
.send({ name: 'Lifecycle draft', status: 'planning', repoUrl: null })
.expect(201);
const projectId = created.body.data.id;

await request(app)
.patch(`/api/companies/${companyId}/projects/${projectId}`)
.send({
name: 'Lifecycle verified',
description: 'Prove edits survive a canonical detail reload.',
status: 'active',
repoUrl: 'https://github.com/vertical-labs/eidolon',
})
.expect(200)
.expect(({ body }) => {
expect(body.data).toEqual(expect.objectContaining({
id: projectId,
name: 'Lifecycle verified',
status: 'active',
repoUrl: 'https://github.com/vertical-labs/eidolon',
}));
});

await request(app)
.get(`/api/companies/${companyId}/projects/${projectId}`)
.expect(200)
.expect(({ body }) => {
expect(body.data).toEqual(expect.objectContaining({
id: projectId,
name: 'Lifecycle verified',
description: 'Prove edits survive a canonical detail reload.',
}));
});

await request(app)
.delete(`/api/companies/${companyId}/projects/${projectId}`)
.expect(200)
.expect(({ body }) => {
expect(body.data).toEqual(expect.objectContaining({ id: projectId, status: 'archived' }));
});

await request(app)
.get(`/api/companies/${companyId}/projects/${projectId}`)
.expect(200)
.expect(({ body }) => {
expect(body.data).toEqual(expect.objectContaining({ id: projectId, status: 'archived' }));
});
});

it('rejects an invalid repository URL during an update without changing the project', async () => {
const created = await request(app)
.post(`/api/companies/${companyId}/projects`)
.send({ name: 'Keep canonical state', status: 'planning', repoUrl: null })
.expect(201);

await request(app)
.patch(`/api/companies/${companyId}/projects/${created.body.data.id}`)
.send({ name: 'Do not persist', repoUrl: 'javascript:alert(document.domain)' })
.expect(400);

await request(app)
.get(`/api/companies/${companyId}/projects/${created.body.data.id}`)
.expect(200)
.expect(({ body }) => {
expect(body.data).toEqual(expect.objectContaining({
name: 'Keep canonical state',
repoUrl: null,
}));
});
});
});
13 changes: 11 additions & 2 deletions server/src/routes/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,27 @@ import eventBus from '../realtime/events.js';
import type { DbInstance } from '../types.js';
import { routeParams } from '../utils/route-params.js';

const HttpUrl = z.string().url().refine((value) => {
try {
const protocol = new URL(value).protocol;
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
}, 'Repository URL must start with http:// or https://');
Comment on lines +10 to +17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Repo url allows credentials 🐞 Bug ⛨ Security

Server/client URL validation only enforces http(s) protocol, so credential-bearing URLs (e.g.,
https://token@github.com/org/repo) can be persisted. ProjectDetail then renders the full unredacted
URL as link text and href, potentially exposing embedded secrets to anyone who can view the project.
Agent Prompt
### Issue description
Repository URLs currently validate only that the scheme is `http:` or `https:`. This still permits URLs containing userinfo credentials (username/password/token in the URL), which can then be stored and rendered verbatim in the UI.

### Issue Context
Both the server (`HttpUrl`) and client (`isHttpUrl` + Zod refine) share the same limitation. The detail page renders the stored URL directly as an external link.

### Fix Focus Areas
- server/src/routes/projects.ts[10-17]
- ui/src/lib/urls.ts[1-7]
- ui/src/components/projects/ProjectFormModal.tsx[28-33]
- ui/src/pages/ProjectDetail.tsx[81-120]

### Suggested fix
1. Update server `HttpUrl` refine to additionally require:
   - `new URL(value).username === ""`
   - `new URL(value).password === ""`
   (Optionally also reject non-empty `hash`/suspicious query params if desired.)
2. Update `ui/src/lib/urls.ts:isHttpUrl` to apply the same username/password checks.
3. Consider hardening the display layer (`ProjectDetail`) by redacting userinfo (e.g., render `url.origin + url.pathname + url.search + url.hash` without credentials) or by suppressing the link when userinfo is present, as defense-in-depth.
4. Add/extend tests to cover credential-bearing URLs being rejected/redacted.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


const CreateProjectBody = z.object({
name: z.string().min(1).max(255),
description: z.string().max(5000).optional(),
status: z.enum(['planning', 'active', 'completed', 'archived']).default('planning'),
repoUrl: z.string().url().nullable().default(null),
repoUrl: HttpUrl.nullable().default(null),
});

const UpdateProjectBody = z.object({
name: z.string().min(1).max(255).optional(),
description: z.string().max(5000).nullable().optional(),
status: z.enum(['planning', 'active', 'completed', 'archived']).optional(),
repoUrl: z.string().url().nullable().optional(),
repoUrl: HttpUrl.nullable().optional(),
});

const ProjectListQuery = z.object({
Expand Down
193 changes: 7 additions & 186 deletions ui/src/components/projects/CreateProjectModal.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import { useState } from "react";
import { z } from "zod";
import { Modal } from "@/components/ui/Modal";
import { Input, Select, Textarea } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
import { useCreateProject } from "@/lib/hooks";
import type { Project, ProjectStatus } from "@/lib/api";
import { ProjectFormModal } from "./ProjectFormModal";
import type { Project } from "@/lib/api";

interface CreateProjectModalProps {
open: boolean;
Expand All @@ -13,192 +8,18 @@ interface CreateProjectModalProps {
companyId: string;
}

const projectStatuses: { value: ProjectStatus; label: string }[] = [
{ value: "planning", label: "Planning" },
{ value: "active", label: "Active" },
{ value: "completed", label: "Completed" },
{ value: "archived", label: "Archived" },
];

const projectSchema = z.object({
name: z.string().trim().min(1, "Enter a project name.").max(255, "Use 255 characters or fewer."),
description: z.string().max(5000, "Use 5,000 characters or fewer."),
status: z.enum(["planning", "active", "completed", "archived"]),
repoUrl: z.union([
z.literal(""),
z.url("Enter a complete repository URL, such as https://github.com/org/repo."),
]),
});

type ProjectFormErrors = Partial<Record<keyof z.infer<typeof projectSchema>, string>>;

function FieldError({ id, message }: { id: string; message?: string }) {
if (!message) return null;

return (
<p id={id} role="alert" className="mt-1.5 text-xs text-error">
{message}
</p>
);
}

export function CreateProjectModal({
open,
onClose,
onCreated,
companyId,
}: CreateProjectModalProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState<ProjectStatus>("planning");
const [repoUrl, setRepoUrl] = useState("");
const [errors, setErrors] = useState<ProjectFormErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const mutation = useCreateProject(companyId);

function resetForm() {
setName("");
setDescription("");
setStatus("planning");
setRepoUrl("");
setErrors({});
setSubmitError(null);
mutation.reset();
}

function handleClose() {
resetForm();
onClose();
}

function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setSubmitError(null);

const parsed = projectSchema.safeParse({ name, description, status, repoUrl });
if (!parsed.success) {
const fieldErrors = parsed.error.flatten().fieldErrors;
setErrors({
name: fieldErrors.name?.[0],
description: fieldErrors.description?.[0],
status: fieldErrors.status?.[0],
repoUrl: fieldErrors.repoUrl?.[0],
});
return;
}

setErrors({});
mutation.mutate(
{
name: parsed.data.name,
description: parsed.data.description.trim() || undefined,
status: parsed.data.status,
repoUrl: parsed.data.repoUrl || null,
},
{
onSuccess: (project) => {
resetForm();
onClose();
onCreated(project);
},
onError: (error) => {
setSubmitError(
error instanceof Error
? error.message
: "Project creation failed. Check your connection and try again.",
);
},
},
);
}

return (
<Modal
<ProjectFormModal
open={open}
onClose={handleClose}
title="Create Project"
dismissible={!mutation.isPending}
>
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<div>
<Input
label="Project name"
placeholder="e.g., Runtime reliability"
value={name}
onChange={(event) => setName(event.target.value)}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "project-name-error" : undefined}
className="text-base placeholder:text-text-primary/60 sm:text-sm"
autoFocus
maxLength={255}
disabled={mutation.isPending}
required
/>
<FieldError id="project-name-error" message={errors.name} />
</div>
<div>
<Textarea
label="Description"
placeholder="What outcome does this project own?"
value={description}
onChange={(event) => setDescription(event.target.value)}
aria-invalid={!!errors.description}
aria-describedby={errors.description ? "project-description-error" : undefined}
className="text-base placeholder:text-text-primary/60 sm:text-sm"
maxLength={5000}
rows={3}
disabled={mutation.isPending}
/>
<FieldError id="project-description-error" message={errors.description} />
</div>
<div>
<Select
label="Status"
options={projectStatuses}
value={status}
onChange={(event) => setStatus(event.target.value as ProjectStatus)}
aria-invalid={!!errors.status}
aria-describedby={errors.status ? "project-status-error" : undefined}
className="text-base sm:text-sm"
disabled={mutation.isPending}
/>
<FieldError id="project-status-error" message={errors.status} />
</div>
<div>
<Input
label="Repository URL"
type="url"
inputMode="url"
placeholder="https://github.com/organization/repository"
value={repoUrl}
onChange={(event) => setRepoUrl(event.target.value)}
aria-invalid={!!errors.repoUrl}
aria-describedby={errors.repoUrl ? "project-repository-url-error" : undefined}
className="text-base placeholder:text-text-primary/60 sm:text-sm"
disabled={mutation.isPending}
/>
<FieldError id="project-repository-url-error" message={errors.repoUrl} />
</div>

{submitError && (
<div
role="alert"
aria-live="polite"
className="rounded-lg border border-error/30 bg-error/10 px-3 py-2 text-sm text-error"
>
Project was not created: {submitError} Your entries are still here; correct the issue and retry.
</div>
)}

<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<Button type="button" variant="ghost" onClick={handleClose} disabled={mutation.isPending}>
Cancel
</Button>
<Button type="submit" loading={mutation.isPending}>
Create Project
</Button>
</div>
</form>
</Modal>
companyId={companyId}
onClose={onClose}
onSaved={onCreated}
/>
);
}
Loading