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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
### Fixes

- API: fix `GET /api/v1/skills` pagination so `cursor` advances to the next page instead of repeating the first page for supported non-trending sorts (#2275) (thanks @vyctorbrzezowski, @enerj).
- API: block public raw skill files when moderation already blocks downloads and reject skill tags that point at another skill's version (thanks @vyctorbrzezowski).
- Web: stop stale unban restore batches from reactivating skills after the owner is banned again or deactivated (thanks @vyctorbrzezowski).
- Security/API: reject direct skill owner transfers when the skill is hidden, suspicious, or malicious (thanks @vyctorbrzezowski).
- Security/API: revalidate package publish actor, owner, and owner publisher active state in the final release insert (thanks @vyctorbrzezowski).
Expand Down
61 changes: 61 additions & 0 deletions convex/downloads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ describe("downloads helpers", () => {
if ("versionId" in args) {
return {
_id: "skillVersions:1",
skillId: "skills:1",
version: "1.0.0",
createdAt: 3,
files: [{ path: "SKILL.md", storageId: "_storage:1" }],
Expand Down Expand Up @@ -141,4 +142,64 @@ describe("downloads helpers", () => {
hourStart: expect.any(Number),
});
});

it("does not serve a tag that points at another skill's version", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
if ("slug" in args) {
return {
skill: {
_id: "skills:1",
ownerUserId: "users:1",
slug: "demo",
tags: { old: "skillVersions:other" },
latestVersionId: "skillVersions:1",
},
moderationInfo: null,
};
}
if (args.versionId === "skillVersions:1") {
return {
_id: "skillVersions:1",
skillId: "skills:1",
version: "1.0.0",
createdAt: 3,
files: [],
softDeletedAt: undefined,
};
}
if (args.versionId === "skillVersions:other") {
return {
_id: "skillVersions:other",
skillId: "skills:other",
version: "9.9.9",
createdAt: 4,
files: [{ path: "SKILL.md", storageId: "_storage:other" }],
softDeletedAt: undefined,
};
}
return null;
});
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
return null;
});
const storageGet = vi.fn();

const response = await downloadZipHandler(
{
runQuery,
runMutation,
scheduler: { runAfter: vi.fn() },
storage: { get: storageGet },
} as unknown as ActionCtx,
new Request("https://example.com/api/v1/download?slug=demo&tag=old", {
headers: { "cf-connecting-ip": "1.2.3.4" },
}),
);

expect(response.status).toBe(404);
expect(await response.text()).toBe("Version not found");
expect(storageGet).not.toHaveBeenCalled();
});
});
36 changes: 6 additions & 30 deletions convex/downloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { httpAction, internalMutation } from "./functions";
import { getOptionalApiTokenUserId } from "./lib/apiTokenAuth";
import { corsHeaders, mergeHeaders } from "./lib/httpHeaders";
import { applyRateLimit, getClientIp } from "./lib/httpRateLimit";
import { getPublicSkillFileAccessBlock, isSkillVersionForSkill } from "./lib/skillFileAccess";
import { buildDeterministicZip } from "./lib/skillZip";
import { hashToken } from "./lib/tokens";
import { insertStatEvent } from "./skillStatEvents";
Expand Down Expand Up @@ -41,35 +42,10 @@ export async function downloadZipHandler(
});
}

// Block downloads based on moderation status.
const mod = skillResult.moderationInfo;
if (mod?.isMalwareBlocked) {
return new Response(
"Blocked: this skill has been flagged as malicious by ClawScan and cannot be downloaded.",
{
status: 403,
headers: mergeHeaders(rate.headers, corsHeaders()),
},
);
}
if (mod?.isPendingScan) {
return new Response(
"This skill is pending a ClawScan security review. Please try again in a few minutes.",
{
status: 423,
headers: mergeHeaders(rate.headers, corsHeaders()),
},
);
}
if (mod?.isRemoved) {
return new Response("This skill has been removed by a moderator.", {
status: 410,
headers: mergeHeaders(rate.headers, corsHeaders()),
});
}
if (mod?.isHiddenByMod) {
return new Response("This skill is currently unavailable.", {
status: 403,
const moderationBlock = getPublicSkillFileAccessBlock(skillResult.moderationInfo);
if (moderationBlock) {
return new Response(moderationBlock.message, {
status: moderationBlock.status,
headers: mergeHeaders(rate.headers, corsHeaders()),
});
}
Expand All @@ -93,7 +69,7 @@ export async function downloadZipHandler(
}
}

if (!version) {
if (!version || !isSkillVersionForSkill(version, skill._id)) {
return new Response("Version not found", {
status: 404,
headers: mergeHeaders(rate.headers, corsHeaders()),
Expand Down
4 changes: 2 additions & 2 deletions convex/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
adjustPublisherStatsForPackageChange,
adjustPublisherStatsForSkillChange,
} from "./lib/publisherStats";
import { extractDigestFields, upsertSkillSearchDigest } from "./lib/skillSearchDigest";
import { extractValidatedDigestFields, upsertSkillSearchDigest } from "./lib/skillSearchDigest";

const triggers = new Triggers<DataModel>();

Expand Down Expand Up @@ -207,7 +207,7 @@ async function syncSkillSearchDigestForSkill(
skill: Doc<"skills"> | null | undefined,
) {
if (!skill) return;
const fields = extractDigestFields(skill);
const fields = await extractValidatedDigestFields(ctx, skill);
const owner = await getOwnerPublisher(ctx, {
ownerPublisherId: skill.ownerPublisherId,
ownerUserId: skill.ownerUserId,
Expand Down
Loading
Loading