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
27 changes: 7 additions & 20 deletions packages/figma/main/code.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,15 @@
import { SimpleVariable } from "../src/types";
import { parseHttpUrl, parseWordPressConnectionKey } from "./wordpressConnection";

const PRESET_API_KEY_STORAGE_KEY = "cf_plugin_project_api_key";
const PRESET_LOCAL_STORAGE_KEY = "cf_plugin_project_local";

function parseWordPressConnectionKey(connectionKey: string) {
if (connectionKey.length <= 24) return null;

try {
const siteUrl = new URL(decodeURIComponent(connectionKey.slice(24)));
if (!["https:", "http:"].includes(siteUrl.protocol)) return null;

return { connectionKey, siteUrl: siteUrl.origin };
} catch {
return null;
}
}

async function fetchPreset(connectionKey: string) {
const connection = parseWordPressConnectionKey(connectionKey);
if (!connection) throw new Error("Invalid WordPress connection key");

const endpoint = new URL("/wp-json/core-framework/v2/preset", connection.siteUrl);
const response = await fetch(endpoint.toString(), {
const endpoint = `${connection.siteUrl}/wp-json/core-framework/v2/preset`;
const response = await fetch(endpoint, {
method: "GET",
headers: {
"Content-Type": "application/json",
Expand Down Expand Up @@ -76,20 +64,19 @@ async function handleWordPressRequest(msg: {
}) {
try {
const connection = await getWordPressConnection();
const target = new URL(msg.url);
const connectedSite = connection ? new URL(connection.siteUrl) : null;
const target = parseHttpUrl(msg.url);

if (
!connection ||
!connectedSite ||
target.origin !== connectedSite.origin ||
!target ||
target.origin !== connection.siteUrl ||
!ALLOWED_WORDPRESS_PATHS.has(target.pathname) ||
!["GET", "POST", "PUT"].includes(msg.method)
) {
throw new Error("Blocked WordPress request");
}

const response = await fetch(target.toString(), {
const response = await fetch(target.href, {
method: msg.method,
headers: {
"Content-Type": "application/json",
Expand Down
42 changes: 42 additions & 0 deletions packages/figma/main/wordpressConnection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
const CONNECTION_KEY_SECRET_LENGTH = 24;

export interface ParsedHttpUrl {
href: string;
origin: string;
pathname: string;
}

export interface WordPressConnection {
connectionKey: string;
siteUrl: string;
}

export function parseHttpUrl(value: string): ParsedHttpUrl | null {
const match = value.match(/^(https?):\/\/([^/?#]+)(\/[^?#]*)?(\?[^#]*)?(?:#.*)?$/i);
if (!match) return null;

const [, scheme, authority, path = "/", query = ""] = match;
if (!authority || authority.includes("@") || /[\\\s]/.test(authority)) return null;

const origin = `${scheme.toLowerCase()}://${authority.toLowerCase()}`;

return {
href: `${origin}${path}${query}`,
origin,
pathname: path,
};
}

export function parseWordPressConnectionKey(rawConnectionKey: string): WordPressConnection | null {
const connectionKey = rawConnectionKey.trim();
if (connectionKey.length <= CONNECTION_KEY_SECRET_LENGTH) return null;

try {
const siteUrl = parseHttpUrl(decodeURIComponent(connectionKey.slice(CONNECTION_KEY_SECRET_LENGTH)));
if (!siteUrl) return null;

return { connectionKey, siteUrl: siteUrl.origin };
} catch {
return null;
}
}
47 changes: 47 additions & 0 deletions packages/figma/tests/wordpressConnection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test";
import { parseHttpUrl, parseWordPressConnectionKey } from "../main/wordpressConnection";

describe("parseWordPressConnectionKey", () => {
test("parses the 79-character WordPress connection key format used by 2.0", () => {
const secret = "a".repeat(24);
const connectionKey = `${secret}${encodeURIComponent(
"https://darkgrey-chicken-782355.hostingersite.com",
)}`;

expect(connectionKey).toHaveLength(79);
expect(parseWordPressConnectionKey(connectionKey)).toEqual({
connectionKey,
siteUrl: "https://darkgrey-chicken-782355.hostingersite.com",
});
});

test("does not depend on the browser URL constructor", () => {
const originalUrl = globalThis.URL;
Reflect.deleteProperty(globalThis, "URL");

try {
const connectionKey = `${"b".repeat(24)}${encodeURIComponent("https://example.com")}`;
expect(parseWordPressConnectionKey(connectionKey)?.siteUrl).toBe("https://example.com");
} finally {
globalThis.URL = originalUrl;
}
});

test("rejects missing, non-HTTP, and credential-bearing site URLs", () => {
expect(parseWordPressConnectionKey("a".repeat(24))).toBeNull();
expect(parseWordPressConnectionKey(`${"a".repeat(24)}ftp%3A%2F%2Fexample.com`)).toBeNull();
expect(
parseWordPressConnectionKey(`${"a".repeat(24)}https%3A%2F%2Fuser%40example.com`),
).toBeNull();
});
});

describe("parseHttpUrl", () => {
test("returns the origin, path, and query without browser APIs", () => {
expect(parseHttpUrl("https://Example.com/wp-json/core-framework/v2/preset?context=figma#ignored")).toEqual({
href: "https://example.com/wp-json/core-framework/v2/preset?context=figma",
origin: "https://example.com",
pathname: "/wp-json/core-framework/v2/preset",
});
});
});
Loading