From 6afabcb38f26a3e32252ebabe704ec25fc08514d Mon Sep 17 00:00:00 2001 From: patricktr Date: Sun, 3 May 2026 09:53:18 -0400 Subject: [PATCH 1/3] Fix XSS in Markdown renderer and harden AttachmentPreview The Markdown component rendered untrusted cell content via dangerouslySetInnerHTML with two compounding flaws: 1. escapeHtml did not escape ", but captured groups were interpolated into href="...". Payload [x](https://e" onclick="x") produced a tag with an injected onclick attribute. 2. URL schemes in links were not validated, so [click](javascript:...) produced an executable link. Either flaw lets a base collaborator who can write to a long-text field execute JS in another user's extension iframe and call updateRecordAsync impersonating the victim. Fix: - Replace dangerouslySetInnerHTML with structured React-node rendering so React handles attribute escaping. - Allowlist http:/https:/mailto: for both explicit links and autolinks; non-matching schemes render as plain text. - Move useMemo above the early returns to fix a rules-of-hooks violation that crashed on empty-to-content transitions. Also harden AttachmentPreview to allow only http:/https: image URLs and set referrerPolicy="no-referrer" so embedded images can't leak referer to attacker-controlled origins. Document the trust model in the README so consumers know what the toolkit handles vs. what they must handle themselves. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 17 ++ frontend/components/AttachmentPreview.js | 24 ++- frontend/components/Markdown.js | 196 +++++++++++++++++++---- 3 files changed, 202 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index cf5877c..e9b483e 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,23 @@ Copy `src/` into your extension project. Then use the helpers in `index.js` See [`examples/basic-usage.js`](examples/basic-usage.js) for a complete working extension. +## Security model + +Components in this toolkit render content that originates from Airtable cells, which is *not* a trusted boundary — base collaborators, automations, and AI workflows can write attack payloads into long-text fields. + +What the toolkit handles for you: + +- **``** renders to React nodes (no `dangerouslySetInnerHTML`). URL schemes in links are restricted to `http:`, `https:`, and `mailto:` — `javascript:` and `data:` URLs render as plain text. +- **``** restricts `` to `http:`/`https:` and sets `referrerPolicy="no-referrer"`. + +What you, the consumer, are responsible for: + +- **Permission gating on writes.** `` and the editable components call `onSave` directly; you must run `table.checkPermissionsForUpdateRecord(...)` in your `onSave` handler before calling `updateRecordAsync` (see [`examples/basic-usage.js`](examples/basic-usage.js)). +- **Sanitizing AI-generated content.** If you render LLM output through ``, treat it as adversarial input — the renderer is hardened against XSS, but business-logic prompt injection is your call. +- **Origin trust.** These components assume they run inside Airtable's extension iframe sandbox. If you embed them elsewhere, re-evaluate the threat model. + +Report security issues privately to the maintainer rather than via public issue. + ## Acknowledgments - Built from [Airtable's official documentation](https://airtable.com/developers/interface-extensions) and example repos diff --git a/frontend/components/AttachmentPreview.js b/frontend/components/AttachmentPreview.js index c86e7cf..952a2ca 100644 --- a/frontend/components/AttachmentPreview.js +++ b/frontend/components/AttachmentPreview.js @@ -1,5 +1,18 @@ import React from 'react'; +const SAFE_IMG_SCHEMES = new Set(['http:', 'https:']); + +function safeImageUrl(raw) { + if (!raw) return null; + try { + const parsed = new URL(String(raw).trim(), 'https://example.invalid'); + if (parsed.origin === 'https://example.invalid') return null; + return SAFE_IMG_SCHEMES.has(parsed.protocol) ? parsed.href : null; + } catch { + return null; + } +} + /** * Renders a thumbnail preview of an Airtable attachment field value. * @@ -12,7 +25,14 @@ export default function AttachmentPreview({ attachments, className = '', index = if (!attachments || attachments.length === 0) return null; const att = attachments[index]; if (!att) return null; - const url = att.thumbnails?.large?.url || att.url; + const url = safeImageUrl(att.thumbnails?.large?.url || att.url); if (!url) return null; - return {att.filename; + return ( + {att.filename + ); } diff --git a/frontend/components/Markdown.js b/frontend/components/Markdown.js index 892ba45..f2e7c3a 100644 --- a/frontend/components/Markdown.js +++ b/frontend/components/Markdown.js @@ -1,32 +1,162 @@ import React from 'react'; // Lightweight markdown renderer — zero dependencies. -// Falls back to plain whitespace-pre-wrap text if content doesn't look like markdown. +// Renders to React nodes (no dangerouslySetInnerHTML, no string HTML synthesis), +// so attribute escaping is handled by React. Falls back to plain +// whitespace-pre-wrap text if content doesn't look like markdown. -function escapeHtml(str) { - return str - .replace(/&/g, '&') - .replace(//g, '>'); +const SAFE_SCHEMES = new Set(['http:', 'https:', 'mailto:']); + +function safeUrl(raw) { + if (!raw) return null; + const trimmed = String(raw).trim(); + try { + const parsed = new URL(trimmed, 'https://example.invalid'); + if (parsed.origin === 'https://example.invalid' && !trimmed.startsWith('/')) { + return null; + } + return SAFE_SCHEMES.has(parsed.protocol) ? parsed.href : null; + } catch { + return null; + } } -function renderInline(text) { - let html = escapeHtml(text); - html = html.replace(/`([^`]+)`/g, '$1'); - html = html.replace(/\*\*\*(.+?)\*\*\*/g, '$1'); - html = html.replace(/\*\*(.+?)\*\*/g, '$1'); - html = html.replace(/\*(.+?)\*/g, '$1'); - html = html.replace( - /\[([^\]]+)\]\(([^)]+)\)/g, - '$1' - ); - html = html.replace( - /(?$1' - ); - return html; +// ── Inline tokenizer ───────────────────────────────────────────────────────────── +// Parses inline markdown into an array of React-renderable tokens. +// Order matters: code spans first (their content is opaque), then links, then +// emphasis. Each pass slices the string into plain segments + token segments +// and recurses into the plain segments for the next pass. + +function tokenizeCode(text) { + const out = []; + const re = /`([^`]+)`/g; + let last = 0; + let m; + while ((m = re.exec(text)) !== null) { + if (m.index > last) out.push({ type: 'text', value: text.slice(last, m.index) }); + out.push({ type: 'code', value: m[1] }); + last = m.index + m[0].length; + } + if (last < text.length) out.push({ type: 'text', value: text.slice(last) }); + return out; } +function tokenizeLinks(text) { + const out = []; + const explicit = /\[([^\]]+)\]\(([^)]+)\)/g; + let last = 0; + let m; + while ((m = explicit.exec(text)) !== null) { + if (m.index > last) out.push({ type: 'text', value: text.slice(last, m.index) }); + const href = safeUrl(m[2]); + if (href) { + out.push({ type: 'link', label: m[1], href }); + } else { + out.push({ type: 'text', value: m[1] }); + } + last = m.index + m[0].length; + } + if (last < text.length) out.push({ type: 'text', value: text.slice(last) }); + + const withAuto = []; + const auto = /(https?:\/\/[^\s)]+)/g; + for (const tok of out) { + if (tok.type !== 'text') { withAuto.push(tok); continue; } + let l = 0; + let mm; + while ((mm = auto.exec(tok.value)) !== null) { + if (mm.index > l) withAuto.push({ type: 'text', value: tok.value.slice(l, mm.index) }); + const href = safeUrl(mm[1]); + if (href) { + withAuto.push({ type: 'link', label: mm[1], href }); + } else { + withAuto.push({ type: 'text', value: mm[1] }); + } + l = mm.index + mm[0].length; + } + if (l < tok.value.length) withAuto.push({ type: 'text', value: tok.value.slice(l) }); + } + return withAuto; +} + +function tokenizeEmphasis(text) { + const patterns = [ + { re: /\*\*\*(.+?)\*\*\*/g, type: 'boldItalic' }, + { re: /\*\*(.+?)\*\*/g, type: 'bold' }, + { re: /\*(.+?)\*/g, type: 'italic' }, + ]; + let tokens = [{ type: 'text', value: text }]; + for (const { re, type } of patterns) { + const next = []; + for (const tok of tokens) { + if (tok.type !== 'text') { next.push(tok); continue; } + let last = 0; + let m; + re.lastIndex = 0; + while ((m = re.exec(tok.value)) !== null) { + if (m.index > last) next.push({ type: 'text', value: tok.value.slice(last, m.index) }); + next.push({ type, value: m[1] }); + last = m.index + m[0].length; + } + if (last < tok.value.length) next.push({ type: 'text', value: tok.value.slice(last) }); + } + tokens = next; + } + return tokens; +} + +function tokenizeInline(text) { + const codeStage = tokenizeCode(text); + const linkStage = []; + for (const tok of codeStage) { + if (tok.type !== 'text') { linkStage.push(tok); continue; } + linkStage.push(...tokenizeLinks(tok.value)); + } + const finalStage = []; + for (const tok of linkStage) { + if (tok.type === 'text') { + finalStage.push(...tokenizeEmphasis(tok.value)); + } else if (tok.type === 'link') { + finalStage.push({ ...tok, children: tokenizeEmphasis(tok.label) }); + } else { + finalStage.push(tok); + } + } + return finalStage; +} + +function renderTokens(tokens, keyPrefix = '') { + return tokens.map((tok, i) => { + const key = `${keyPrefix}${i}`; + switch (tok.type) { + case 'text': return {tok.value}; + case 'code': return {tok.value}; + case 'bold': return {tok.value}; + case 'italic': return {tok.value}; + case 'boldItalic': return {tok.value}; + case 'link': + return ( + + {renderTokens(tok.children || [{ type: 'text', value: tok.label }], `${key}-`)} + + ); + default: return null; + } + }); +} + +function Inline({ text }) { + return <>{renderTokens(tokenizeInline(text))}; +} + +// ── Block parser ───────────────────────────────────────────────────────────────── + function parseMarkdown(source) { const lines = source.split('\n'); const blocks = []; @@ -118,10 +248,6 @@ function parseMarkdown(source) { return blocks; } -function InlineHtml({ text }) { - return ; -} - /** Heuristic: does this text contain markdown-like syntax? */ export function looksLikeMarkdown(text) { if (!text) return false; @@ -144,14 +270,18 @@ export function looksLikeMarkdown(text) { */ export default function Markdown({ children, className = '' }) { const source = typeof children === 'string' ? children : ''; + + const blocks = React.useMemo( + () => (source && looksLikeMarkdown(source) ? parseMarkdown(source) : null), + [source], + ); + if (!source) return null; - if (!looksLikeMarkdown(source)) { + if (!blocks) { return
{source}
; } - const blocks = React.useMemo(() => parseMarkdown(source), [source]); - return (
{blocks.map((block, idx) => { @@ -168,14 +298,14 @@ export default function Markdown({ children, className = '' }) { const Tag = `h${block.level}`; return ( - + ); } case 'paragraph': return (

- +

); case 'code': @@ -188,7 +318,7 @@ export default function Markdown({ children, className = '' }) { return (
    {block.items.map((item, j) => ( -
  • +
  • ))}
); @@ -196,14 +326,14 @@ export default function Markdown({ children, className = '' }) { return (
    {block.items.map((item, j) => ( -
  1. +
  2. ))}
); case 'blockquote': return (
- +
); case 'hr': From 32e6dd779f2c67dad7f07b952e5be5f4d541b671 Mon Sep 17 00:00:00 2001 From: patricktr Date: Sun, 3 May 2026 09:53:30 -0400 Subject: [PATCH 2/3] Fix package.json paths and tighten publish hygiene The published package was broken: main and exports referenced ./src/... but the source lives in ./frontend/. Fix the paths and add the missing publish-readiness fields: - files allowlist so npm publish doesn't ship dotfiles or examples - sideEffects: false for tree-shaking - type: module (the source uses ESM) - engines.node and publishConfig.access - React peer upper bound (>=17 <20) - repository.url updated to git+https form Also fix stale path references in the README (src/ -> frontend/, skill/SKILL.md -> SKILL.md) so the install instructions match what's actually on disk. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 10 +++++----- package.json | 29 +++++++++++++++++++++-------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e9b483e..e2a01b3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Build [Airtable Custom Interface Extensions](https://airtable.com/developers/int ## What's in the box -### AI Skill (`skill/SKILL.md`) +### AI Skill (`SKILL.md`) A comprehensive reference that teaches AI how to build Airtable Interface Extensions correctly. Covers the entire SDK — reading data, writing data, custom properties, dark mode, every field type, styling with Tailwind or MUI, and 14 common mistakes to avoid. @@ -12,7 +12,7 @@ Upload it to your AI tool of choice and describe what you want to build: > *"Build me a dashboard that shows tasks grouped by status with a bar chart of completion rates"* -### Reusable Helpers (`src/`) +### Reusable Helpers (`frontend/`) Drop-in utilities for patterns every extension needs: @@ -29,14 +29,14 @@ Cursor-compatible rules file that guides the AI when editing extension code. ### With Claude Projects (claude.ai) -1. Upload `skill/SKILL.md` to **Project Knowledge** +1. Upload `SKILL.md` to **Project Knowledge** 2. Describe the interface you want 3. Claude writes working code using the SDK patterns ### With Claude Code (CLI) ```bash -cp skill/SKILL.md your-project/.claude/skills/airtable-extensions/SKILL.md +cp SKILL.md your-project/.claude/skills/airtable-extensions/SKILL.md ``` ### With Cursor @@ -48,7 +48,7 @@ cp interface-extensions.mdc your-project/.cursor/rules/ ### Using the helpers -Copy `src/` into your extension project. Then use the helpers in `index.js` +Copy `frontend/` into your extension project. Then use the helpers in `index.js` See [`examples/basic-usage.js`](examples/basic-usage.js) for a complete working extension. diff --git a/package.json b/package.json index fafb9d2..bb181fd 100644 --- a/package.json +++ b/package.json @@ -2,16 +2,26 @@ "name": "airtable-extension-toolkit", "version": "0.1.0", "description": "Reusable helpers and components for Airtable Interface Extensions", - "main": "src/index.js", + "type": "module", + "main": "./frontend/index.js", "exports": { - ".": "./src/index.js", - "./colors": "./src/colors.js", - "./fields": "./src/fields.js", - "./components/*": "./src/components/*.js", - "./tailwind/airtable-preset": "./src/tailwind/airtable-preset.js" + ".": "./frontend/index.js", + "./colors": "./frontend/colors.js", + "./fields": "./frontend/fields.js", + "./components/*": "./frontend/components/*.js", + "./tailwind/airtable-preset": "./frontend/tailwind/airtable-preset.js" + }, + "files": [ + "frontend", + "README.md", + "LICENSE" + ], + "sideEffects": false, + "engines": { + "node": ">=18" }, "peerDependencies": { - "react": ">=17.0.0" + "react": ">=17.0.0 <20.0.0" }, "license": "MIT", "keywords": [ @@ -23,6 +33,9 @@ ], "repository": { "type": "git", - "url": "https://github.com/victoriaplummer/airtable-extension-toolkit" + "url": "git+https://github.com/patricktr/airtable-interface-extension-toolkit.git" + }, + "publishConfig": { + "access": "public" } } From 68f27450630c11d61521e5b041e6e4e12ed1ec1f Mon Sep 17 00:00:00 2001 From: patricktr Date: Sun, 3 May 2026 09:56:58 -0400 Subject: [PATCH 3/3] Point repository.url at the canonical upstream repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous URL referenced victoriaplummer/airtable-extension-toolkit, which doesn't exist — the actual repo name is airtable-interface-extension-toolkit (with -interface-). Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bb181fd..ba6054d 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ ], "repository": { "type": "git", - "url": "git+https://github.com/patricktr/airtable-interface-extension-toolkit.git" + "url": "git+https://github.com/victoriaplummer/airtable-interface-extension-toolkit.git" }, "publishConfig": { "access": "public"