Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
30
packages/core/tests/unit/middleware/csp.test.ts
Normal file
30
packages/core/tests/unit/middleware/csp.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { buildEmDashCsp } from "../../../src/astro/middleware/csp.js";
|
||||
|
||||
describe("buildEmDashCsp", () => {
|
||||
it("includes https: in img-src to allow external images", () => {
|
||||
const csp = buildEmDashCsp();
|
||||
const imgSrc = csp.split("; ").find((d) => d.startsWith("img-src"));
|
||||
expect(imgSrc).toContain("https:");
|
||||
});
|
||||
|
||||
it("still includes self, data:, and blob: in img-src", () => {
|
||||
const csp = buildEmDashCsp();
|
||||
const imgSrc = csp.split("; ").find((d) => d.startsWith("img-src"));
|
||||
expect(imgSrc).toContain("'self'");
|
||||
expect(imgSrc).toContain("data:");
|
||||
expect(imgSrc).toContain("blob:");
|
||||
});
|
||||
|
||||
it("keeps connect-src restricted to self", () => {
|
||||
const csp = buildEmDashCsp();
|
||||
const connectSrc = csp.split("; ").find((d) => d.startsWith("connect-src"));
|
||||
expect(connectSrc).toBe("connect-src 'self'");
|
||||
});
|
||||
|
||||
it("blocks framing with frame-ancestors none", () => {
|
||||
const csp = buildEmDashCsp();
|
||||
expect(csp).toContain("frame-ancestors 'none'");
|
||||
});
|
||||
});
|
||||
145
packages/core/tests/unit/middleware/oauth-csrf.test.ts
Normal file
145
packages/core/tests/unit/middleware/oauth-csrf.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("virtual:emdash/auth", () => ({ authenticate: vi.fn() }));
|
||||
vi.mock("virtual:emdash/config", () => ({ default: {} }));
|
||||
vi.mock("astro:middleware", () => ({
|
||||
defineMiddleware: (handler: unknown) => handler,
|
||||
}));
|
||||
vi.mock("@emdash-cms/auth", () => ({
|
||||
TOKEN_PREFIXES: {},
|
||||
generatePrefixedToken: vi.fn(),
|
||||
hashPrefixedToken: vi.fn(),
|
||||
VALID_SCOPES: [],
|
||||
validateScopes: vi.fn(),
|
||||
hasScope: vi.fn(() => false),
|
||||
computeS256Challenge: vi.fn(),
|
||||
Role: { ADMIN: 50 },
|
||||
}));
|
||||
vi.mock("@emdash-cms/auth/adapters/kysely", () => ({
|
||||
createKyselyAdapter: vi.fn(() => ({
|
||||
getUserById: vi.fn(),
|
||||
getUserByEmail: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
type AuthMiddlewareModule = typeof import("../../../src/astro/middleware/auth.js");
|
||||
|
||||
let onRequest: AuthMiddlewareModule["onRequest"];
|
||||
|
||||
beforeAll(async () => {
|
||||
({ onRequest } = await import("../../../src/astro/middleware/auth.js"));
|
||||
});
|
||||
|
||||
async function runAuthMiddleware(opts: {
|
||||
pathname: string;
|
||||
method?: string;
|
||||
origin?: string;
|
||||
extraHeaders?: Record<string, string>;
|
||||
}): Promise<{ response: Response; next: ReturnType<typeof vi.fn> }> {
|
||||
const url = new URL(opts.pathname, "https://site.example.com");
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...opts.extraHeaders,
|
||||
};
|
||||
if (opts.origin) headers.Origin = opts.origin;
|
||||
|
||||
const session = {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
set: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
const next = vi.fn(async () => new Response("ok"));
|
||||
const response = await onRequest(
|
||||
{
|
||||
url,
|
||||
request: new Request(url, {
|
||||
method: opts.method ?? "POST",
|
||||
headers,
|
||||
body: "{}",
|
||||
}),
|
||||
locals: {
|
||||
emdash: { db: {}, config: {} },
|
||||
},
|
||||
session,
|
||||
redirect: (location: string) =>
|
||||
new Response(null, { status: 302, headers: { Location: location } }),
|
||||
} as Parameters<AuthMiddlewareModule["onRequest"]>[0],
|
||||
next,
|
||||
);
|
||||
|
||||
return { response, next };
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth protocol endpoints (RFC 6749, 7591, 8628) are designed to be called
|
||||
* cross-origin. They must bypass the Origin-based CSRF check that applies to
|
||||
* other public API routes.
|
||||
*
|
||||
* Regression test for PR #671: dynamic client registration and the token
|
||||
* endpoint were unreachable from real MCP clients because an Origin header
|
||||
* from a different origin triggered CSRF_REJECTED in middleware.
|
||||
*/
|
||||
describe("CSRF exemption for OAuth protocol endpoints", () => {
|
||||
const EXEMPT_PATHS = [
|
||||
"/_emdash/api/oauth/token",
|
||||
"/_emdash/api/oauth/register",
|
||||
"/_emdash/api/oauth/device/code",
|
||||
"/_emdash/api/oauth/device/token",
|
||||
] as const;
|
||||
|
||||
it.each(EXEMPT_PATHS)(
|
||||
"allows cross-origin POST to %s (passes request through to handler)",
|
||||
async (pathname) => {
|
||||
const { response, next } = await runAuthMiddleware({
|
||||
pathname,
|
||||
origin: "https://claude.ai",
|
||||
});
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(response.status).toBe(200);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(EXEMPT_PATHS)("allows same-origin POST to %s", async (pathname) => {
|
||||
const { response, next } = await runAuthMiddleware({
|
||||
pathname,
|
||||
origin: "https://site.example.com",
|
||||
});
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it.each(EXEMPT_PATHS)("allows POST without any Origin header to %s", async (pathname) => {
|
||||
const { response, next } = await runAuthMiddleware({ pathname });
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("still rejects cross-origin POST to non-exempt public routes (comments)", async () => {
|
||||
const { response, next } = await runAuthMiddleware({
|
||||
pathname: "/_emdash/api/comments/some-id",
|
||||
origin: "https://evil.example.com",
|
||||
});
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(response.status).toBe(403);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: { code: "CSRF_REJECTED", message: "Cross-origin request blocked" },
|
||||
});
|
||||
});
|
||||
|
||||
it("still rejects cross-origin POST to device/authorize (session-authenticated consent step)", async () => {
|
||||
// /oauth/device/authorize is NOT in the exempt list — it's where the user
|
||||
// approves the CLI's device code from their browser session. It must be
|
||||
// protected by the normal CSRF check.
|
||||
const { response, next } = await runAuthMiddleware({
|
||||
pathname: "/_emdash/api/oauth/device/authorize",
|
||||
origin: "https://evil.example.com",
|
||||
});
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user