fix: replace gray-matter with direct yaml.load for js-yaml 4.x compatibility (#26555)

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
This commit is contained in:
Pedro Castro
2026-01-07 15:16:26 -03:00
committed by GitHub
parent f54a3d1d0c
commit 2aed9908b7
4 changed files with 156 additions and 7 deletions
@@ -0,0 +1,112 @@
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("parses simple frontmatter correctly", () => {
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("returns empty data on unsafe YAML types", () => {
const source = `---
date: !!js/date 2024-01-01
---
Content`;
const result = parseFrontmatter(source);
expect(result.data).toEqual({});
expect(result.content).toBe("Content");
});
});
});
+44 -5
View File
@@ -1,12 +1,51 @@
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 { 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 { 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|$)/;
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>; content: string } {
const match = source.match(FRONTMATTER_REGEX);
if (!match) {
return { data: {}, content: source };
}
let data: Record<string, unknown> = {};
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 {
data,
content: source.slice(match[0].length),
};
}
export const sourceSchema = z.object({
content: z.string(),
@@ -65,7 +104,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) => {
-1
View File
@@ -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",
-1
View File
@@ -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"