From 318853ce97584b756c2b61b9b57f47ce31d9fb0d Mon Sep 17 00:00:00 2001 From: Pedro Castro Date: Wed, 7 Jan 2026 13:58:46 -0300 Subject: [PATCH 1/2] fix: replace gray-matter with direct yaml.load for js-yaml 4.x compatibility gray-matter uses yaml.safeLoad() which was removed in js-yaml 4.x, causing 500 errors on app store pages after the js-yaml 4.1.1 update (CWE-1321 fix) - Add parseFrontmatter function using yaml.load with JSON_SCHEMA - Add type guard for safe type narrowing - Add unit tests for frontmatter parsing and security - Remove gray-matter dependency --- .../[slug]/__tests__/parseFrontmatter.test.ts | 109 ++++++++++++++++++ apps/web/lib/apps/[slug]/getStaticProps.ts | 41 ++++++- apps/web/package.json | 1 - yarn.lock | 1 - 4 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 apps/web/lib/apps/[slug]/__tests__/parseFrontmatter.test.ts diff --git a/apps/web/lib/apps/[slug]/__tests__/parseFrontmatter.test.ts b/apps/web/lib/apps/[slug]/__tests__/parseFrontmatter.test.ts new file mode 100644 index 00000000000000..abbfae21ea4274 --- /dev/null +++ b/apps/web/lib/apps/[slug]/__tests__/parseFrontmatter.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { parseFrontmatter } from "../getStaticProps"; + +describe("parseFrontmatter", () => { + describe("valid frontmatter parsing", () => { + it("parses frontmatter with items array", () => { + const source = `--- +items: + - 1.jpg + - 2.png +--- +Some content here`; + + const result = parseFrontmatter(source); + + expect(result.data).toEqual({ items: ["1.jpg", "2.png"] }); + expect(result.content).toBe("Some content here"); + }); + + it("parses frontmatter with description only", () => { + const source = `--- +description: A simple description +--- +Content`; + + const result = parseFrontmatter(source); + + expect(result.data).toEqual({ description: "A simple description" }); + expect(result.content).toBe("Content"); + }); + + it("parses items with iframe objects", () => { + const source = `--- +items: + - iframe: { src: https://youtube.com/embed/abc } + - 1.jpg +--- +Content`; + + const result = parseFrontmatter(source); + + expect(result.data).toEqual({ + items: [{ iframe: { src: "https://youtube.com/embed/abc" } }, "1.jpg"], + }); + expect(result.content).toBe("Content"); + }); + }); + + describe("edge cases", () => { + it("returns empty data when no frontmatter present", () => { + const source = "Just plain text content"; + + const result = parseFrontmatter(source); + + expect(result.data).toEqual({}); + expect(result.content).toBe("Just plain text content"); + }); + + it("handles trailing spaces after frontmatter delimiters", () => { + const source = `--- +items: + - test.jpg +--- +Content`; + + const result = parseFrontmatter(source); + + expect(result.data).toEqual({ items: ["test.jpg"] }); + expect(result.content).toBe("Content"); + }); + + it("preserves blank line after frontmatter", () => { + const source = `--- +title: Test +--- + +Content`; + + const result = parseFrontmatter(source); + + expect(result.data).toEqual({ title: "Test" }); + expect(result.content).toBe("\nContent"); + }); + + it("returns empty data for non-object frontmatter (array root)", () => { + const source = `--- +- item1 +- item2 +--- +Content`; + + const result = parseFrontmatter(source); + + expect(result.data).toEqual({}); + expect(result.content).toBe("Content"); + }); + }); + + describe("security (JSON_SCHEMA protection)", () => { + it("throws on unsafe YAML types", () => { + const source = `--- +date: !!js/date 2024-01-01 +--- +Content`; + + expect(() => parseFrontmatter(source)).toThrow(); + }); + }); +}); diff --git a/apps/web/lib/apps/[slug]/getStaticProps.ts b/apps/web/lib/apps/[slug]/getStaticProps.ts index dcfa567454db93..d6a04744d61010 100644 --- a/apps/web/lib/apps/[slug]/getStaticProps.ts +++ b/apps/web/lib/apps/[slug]/getStaticProps.ts @@ -1,12 +1,43 @@ -import fs from "node:fs" -import matter from "gray-matter"; -import path from "node:path" +import fs from "node:fs"; +import path from "node:path"; + +import yaml from "js-yaml"; import { z } from "zod"; +import { prisma } from "@calcom/prisma"; import { getAppWithMetadata } from "@calcom/app-store/_appRegistry"; import { getAppAssetFullPath } from "@calcom/app-store/getAppAssetFullPath"; import { IS_PRODUCTION } from "@calcom/lib/constants"; -import prisma from "@calcom/prisma"; + +const FRONTMATTER_REGEX = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** + * Parses markdown content with YAML frontmatter + * Replaces gray-matter to use js-yaml 4.x directly (yaml.load is safe by default) + */ +export function parseFrontmatter(source: string): { data: Record; content: string } { + const match = source.match(FRONTMATTER_REGEX); + + if (!match) { + return { data: {}, content: source }; + } + + let data: Record = {}; + const parsed = yaml.load(match[1], { schema: yaml.JSON_SCHEMA }); + + if (isRecord(parsed)) { + data = parsed; + } + + return { + data, + content: source.slice(match[0].length), + }; +} export const sourceSchema = z.object({ content: z.string(), @@ -65,7 +96,7 @@ export const getStaticProps = async (slug: string) => { source = appMeta.description; } - const result = matter(source); + const result = parseFrontmatter(source); const { content, data } = sourceSchema.parse({ content: result.content, data: result.data }); if (data.items) { data.items = data.items.map((item) => { diff --git a/apps/web/package.json b/apps/web/package.json index d9589cf3338754..e55ac7aca115ee 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -91,7 +91,6 @@ "classnames": "2.3.2", "dompurify": "3.3.1", "entities": "4.5.0", - "gray-matter": "4.0.3", "handlebars": "4.7.7", "i18next": "23.2.3", "ical.js": "1.5.0", diff --git a/yarn.lock b/yarn.lock index 9c37834daa154c..1ab69242871dd2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3546,7 +3546,6 @@ __metadata: env-cmd: "npm:10.1.0" glob: "npm:10.4.5" google-auth-library: "npm:9.15.0" - gray-matter: "npm:4.0.3" handlebars: "npm:4.7.7" i18next: "npm:23.2.3" ical.js: "npm:1.5.0" From 692d038537205dad90379b5ee8a64fee375b8c76 Mon Sep 17 00:00:00 2001 From: Pedro Castro Date: Wed, 7 Jan 2026 14:41:57 -0300 Subject: [PATCH 2/2] fix: add error handling for malformed YAML frontmatter - Wrap yaml.load in try-catch to prevent 500 errors - Log warnings using structured logger for debugging - Rename misleading test name --- .../[slug]/__tests__/parseFrontmatter.test.ts | 9 ++++++--- apps/web/lib/apps/[slug]/getStaticProps.ts | 16 ++++++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/web/lib/apps/[slug]/__tests__/parseFrontmatter.test.ts b/apps/web/lib/apps/[slug]/__tests__/parseFrontmatter.test.ts index abbfae21ea4274..9848533e3bf4b7 100644 --- a/apps/web/lib/apps/[slug]/__tests__/parseFrontmatter.test.ts +++ b/apps/web/lib/apps/[slug]/__tests__/parseFrontmatter.test.ts @@ -56,7 +56,7 @@ Content`; expect(result.content).toBe("Just plain text content"); }); - it("handles trailing spaces after frontmatter delimiters", () => { + it("parses simple frontmatter correctly", () => { const source = `--- items: - test.jpg @@ -97,13 +97,16 @@ Content`; }); describe("security (JSON_SCHEMA protection)", () => { - it("throws on unsafe YAML types", () => { + it("returns empty data on unsafe YAML types", () => { const source = `--- date: !!js/date 2024-01-01 --- Content`; - expect(() => parseFrontmatter(source)).toThrow(); + const result = parseFrontmatter(source); + + expect(result.data).toEqual({}); + expect(result.content).toBe("Content"); }); }); }); diff --git a/apps/web/lib/apps/[slug]/getStaticProps.ts b/apps/web/lib/apps/[slug]/getStaticProps.ts index d6a04744d61010..551c7fb1b18e53 100644 --- a/apps/web/lib/apps/[slug]/getStaticProps.ts +++ b/apps/web/lib/apps/[slug]/getStaticProps.ts @@ -3,11 +3,14 @@ import path from "node:path"; import yaml from "js-yaml"; import { z } from "zod"; -import { prisma } from "@calcom/prisma"; import { getAppWithMetadata } from "@calcom/app-store/_appRegistry"; import { getAppAssetFullPath } from "@calcom/app-store/getAppAssetFullPath"; import { IS_PRODUCTION } from "@calcom/lib/constants"; +import { prisma } from "@calcom/prisma"; +import logger from "@calcom/lib/logger"; + +const log = logger.getSubLogger({ prefix: ["lib", "parseFrontmatter"] }); const FRONTMATTER_REGEX = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/; @@ -27,10 +30,15 @@ export function parseFrontmatter(source: string): { data: Record = {}; - const parsed = yaml.load(match[1], { schema: yaml.JSON_SCHEMA }); - if (isRecord(parsed)) { - data = parsed; + try { + const parsed = yaml.load(match[1], { schema: yaml.JSON_SCHEMA }); + + if (isRecord(parsed)) { + data = parsed; + } + } catch (error) { + log.warn("Invalid YAML frontmatter", { error }); } return {