Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

markdown.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

markdown.tsBlame188 lines · 2 contributors
79136bbClaude1/**
5bb52faccanty labs2 * Markdown rendering — server-side.
3 *
4 * Output is HTML-sanitized via `sanitize-html` as the FINAL stage of
5 * `renderMarkdown()`. Raw `<script>`, `<iframe>`, `<style>`, `<object>`,
6 * `<embed>`, `<form>`, inline event handlers (`on*`), and `javascript:` /
7 * `data:` URLs are stripped, defeating stored XSS from untrusted markdown
8 * (READMEs, issues, PR comments, wikis, discussions, releases). The allowed
9 * tag/attribute subset is a GitHub-like safe set that preserves our
10 * syntax-highlighted code blocks (`<pre><code class="language-x">` plus
11 * highlight.js `<span class="hljs-...">`).
79136bbClaude12 */
13
14import { Marked } from "marked";
5bb52faccanty labs15import sanitizeHtml from "sanitize-html";
79136bbClaude16import { highlightCode } from "./highlight";
17
18const marked = new Marked({
19 gfm: true,
20 breaks: false,
21 renderer: {
22 code({ text, lang }) {
23 if (lang) {
24 const { html } = highlightCode(text, `code.${lang}`);
25 return `<pre><code class="language-${escapeAttr(lang)}">${html}</code></pre>`;
26 }
27 return `<pre><code>${escapeHtml(text)}</code></pre>`;
28 },
29 link({ href, title, text }) {
30 // Sanitize: only allow http(s) and relative links
31 const safeHref =
32 href.startsWith("http://") ||
33 href.startsWith("https://") ||
34 href.startsWith("/") ||
35 href.startsWith("#") ||
36 href.startsWith(".")
37 ? href
38 : "#";
39 const titleAttr = title ? ` title="${escapeAttr(title)}"` : "";
40 return `<a href="${escapeAttr(safeHref)}"${titleAttr}>${text}</a>`;
41 },
42 image({ href, title, text }) {
43 const titleAttr = title ? ` title="${escapeAttr(title)}"` : "";
44 return `<img src="${escapeAttr(href)}" alt="${escapeAttr(text)}"${titleAttr} style="max-width: 100%;" loading="lazy" />`;
45 },
46 },
47});
48
5bb52faccanty labs49// GitHub-like safe subset. This is the security boundary for all rendered
50// markdown — do not widen without a security review.
51const sanitizeOptions: sanitizeHtml.IOptions = {
52 allowedTags: [
53 "h1", "h2", "h3", "h4", "h5", "h6",
54 "p", "a", "ul", "ol", "li", "blockquote",
55 "pre", "code", "span", "strong", "em", "b", "i", "del", "s",
56 "hr", "br",
57 "table", "thead", "tbody", "tr", "th", "td",
58 "img", "details", "summary", "sub", "sup", "kbd", "div", "input",
59 ],
60 allowedAttributes: {
61 a: ["href", "title", "rel"],
62 img: ["src", "alt", "title", "width", "height", "loading", "style"],
63 code: ["class"],
64 span: ["class"],
65 div: ["class"],
66 li: ["class"],
67 td: ["align", "colspan", "rowspan"],
68 th: ["align", "colspan", "rowspan"],
69 input: ["type", "checked", "disabled"],
70 },
71 // Links: http/https/mailto only. javascript:, vbscript:, file:, etc. stripped.
72 allowedSchemes: ["http", "https", "mailto"],
73 // Images: http/https only — data: URIs disallowed to block data:text/html payloads.
74 allowedSchemesByTag: {
75 img: ["http", "https"],
76 },
77 allowProtocolRelative: true,
78 // Restrict inline styles on <img> to a small harmless allowlist.
79 allowedStyles: {
80 img: {
81 "max-width": [/^\d+(?:px|%|em|rem)?$/],
82 width: [/^\d+(?:px|%|em|rem)?$/],
83 height: [/^\d+(?:px|%|em|rem)?$/],
84 },
85 },
86 // Drop disallowed tags entirely (and their attributes); keep safe text.
87 disallowedTagsMode: "discard",
88 // Never emit these, even as escaped text content of stripped tags.
89 nonTextTags: ["script", "style", "textarea", "noscript", "iframe", "object", "embed", "form"],
90};
91
79136bbClaude92export function renderMarkdown(source: string): string {
93 const html = marked.parse(source);
94 if (typeof html !== "string") return "";
5bb52faccanty labs95 // FINAL sanitization stage — strips any raw HTML that slipped through marked
96 // (raw <script>/<iframe>/on*= handlers, javascript:/data: URLs, etc.).
97 return sanitizeHtml(html, sanitizeOptions);
79136bbClaude98}
99
100function escapeHtml(str: string): string {
101 return str
102 .replace(/&/g, "&amp;")
103 .replace(/</g, "&lt;")
104 .replace(/>/g, "&gt;")
105 .replace(/"/g, "&quot;");
106}
107
108function escapeAttr(str: string): string {
109 return str
110 .replace(/&/g, "&amp;")
111 .replace(/"/g, "&quot;")
112 .replace(/'/g, "&#39;")
113 .replace(/</g, "&lt;")
114 .replace(/>/g, "&gt;");
115}
116
117export const markdownCss = `
118 .markdown-body {
119 font-size: 15px;
120 line-height: 1.7;
121 word-wrap: break-word;
122 padding: 20px;
123 }
124 .markdown-body h1, .markdown-body h2, .markdown-body h3,
125 .markdown-body h4, .markdown-body h5, .markdown-body h6 {
126 margin-top: 24px;
127 margin-bottom: 16px;
128 font-weight: 600;
129 line-height: 1.25;
130 padding-bottom: 8px;
131 border-bottom: 1px solid var(--border);
132 }
133 .markdown-body h1 { font-size: 2em; }
134 .markdown-body h2 { font-size: 1.5em; }
135 .markdown-body h3 { font-size: 1.25em; border-bottom: none; }
136 .markdown-body h4 { font-size: 1em; border-bottom: none; }
137 .markdown-body p { margin-bottom: 16px; }
138 .markdown-body ul, .markdown-body ol { padding-left: 2em; margin-bottom: 16px; }
139 .markdown-body li { margin-bottom: 4px; }
140 .markdown-body code {
141 font-family: var(--font-mono);
142 font-size: 85%;
143 padding: 2px 6px;
144 background: var(--bg-tertiary);
145 border-radius: 3px;
146 }
147 .markdown-body pre {
148 padding: 16px;
149 overflow-x: auto;
150 background: var(--bg-secondary);
151 border: 1px solid var(--border);
152 border-radius: var(--radius);
153 margin-bottom: 16px;
154 line-height: 1.5;
155 }
156 .markdown-body pre code {
157 padding: 0;
158 background: transparent;
159 font-size: 13px;
160 }
161 .markdown-body blockquote {
162 padding: 0 1em;
163 color: var(--text-muted);
164 border-left: 3px solid var(--border);
165 margin-bottom: 16px;
166 }
167 .markdown-body table {
168 width: 100%;
169 border-collapse: collapse;
170 margin-bottom: 16px;
171 }
172 .markdown-body table th, .markdown-body table td {
173 padding: 8px 16px;
174 border: 1px solid var(--border);
175 text-align: left;
176 }
177 .markdown-body table th { background: var(--bg-secondary); font-weight: 600; }
178 .markdown-body hr {
179 height: 2px;
180 background: var(--border);
181 border: none;
182 margin: 24px 0;
183 }
184 .markdown-body a { color: var(--text-link); }
185 .markdown-body img { max-width: 100%; border-radius: var(--radius); }
186 .markdown-body .task-list-item { list-style: none; }
187 .markdown-body .task-list-item input { margin-right: 8px; }
188`;