Add canvas schema validation to extension submission workflow (#2161)

* Add canvas schema and extension submission checks

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix: use namespace import for js-yaml

Co-authored-by: aaronpowell <434140+aaronpowell@users.noreply.github.com>

* Fix contributors page build markup

Co-authored-by: aaronpowell <434140+aaronpowell@users.noreply.github.com>

* Address PR feedback on canvas schema validation

- Add ajv-cli@5 as a pinned devDependency; install via npm ci in CI instead of npx --yes
- Fix screenshot path regex to prevent .. traversal segments
- Validate canvas.schema.json is parseable JSON even on schema-only PRs

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Harden canvas extension workflow against injection attacks

Switch from newline to null-terminated git diff output (git diff -z) so filenames
containing newlines are read atomically, matching the existing skill-check.yml pattern.

Add an allowlist regex guard on the extracted extension directory name immediately after
it is parsed from git diff output. Any name not matching ^[a-z0-9][a-z0-9-]*$ (e.g.
names containing dollar signs, parentheses, spaces, or other shell metacharacters) is
silently skipped before being used anywhere in the script.

Add a matching allowlist guard on each screenshot path extracted from canvas.json before
the file-existence check, so a crafted manifest cannot supply a path with shell
metacharacters or traversal segments even after the schema check passes.

Follows the same defence-in-depth pattern introduced after the injection PoCs in #1236
and #1240.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Replace ajv-cli with in-repo schema validator

- Remove ajv-cli to avoid vulnerable/deprecated transitive dependencies
- Add eng/validate-json-schema.mjs using ajv + ajv-formats
- Update validate-canvas-extensions workflow to use local script
- Use npm ci --ignore-scripts in PR validation job

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: aaronpowell <434140+aaronpowell@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Aaron Powell
2026-07-01 10:40:40 +10:00
committed by GitHub
parent 28c3a14af4
commit 79cda6bb19
8 changed files with 426 additions and 146 deletions
+103 -100
View File
@@ -6,130 +6,133 @@ on:
types: [opened, synchronize, reopened]
paths:
- "extensions/**"
- ".schemas/canvas.schema.json"
permissions:
contents: read
pull-requests: write
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout code
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
- name: Validate changed canvas extensions
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
script: |
const fs = require('fs');
const path = require('path');
node-version: "22"
cache: "npm"
// Collect changed extension directories from the PR diff
const { execSync } = require('child_process');
const changedFiles = execSync(
`git diff --name-only origin/${{ github.base_ref }}...HEAD`
).toString().trim().split('\n').filter(Boolean);
- name: Install dependencies
run: npm ci --ignore-scripts
const EXTENSIONS_DIR = 'extensions';
const EXTERNAL_ASSETS_DIR = 'external-assets';
- name: Validate changed canvas extensions
run: |
set -euo pipefail
const changedExtDirs = new Set();
for (const file of changedFiles) {
const parts = file.split('/');
if (parts[0] === EXTENSIONS_DIR && parts.length >= 2) {
const extName = parts[1];
// Skip the external-assets directory — it's not a canvas extension
// Also skip external.json and other files at extensions root level
if (extName !== EXTERNAL_ASSETS_DIR && !extName.includes('.')) {
changedExtDirs.add(path.join(EXTENSIONS_DIR, extName));
}
}
}
# Validate schema structure once, even for schema-only PRs.
if ! node ./eng/validate-json-schema.mjs --schema .schemas/canvas.schema.json; then
echo "❌ .schemas/canvas.schema.json failed schema compilation"
exit 1
fi
if (changedExtDirs.size === 0) {
console.log('No canvas extension directories changed — skipping validation.');
return;
}
# Collect changed extension directories.
# Use null-terminated (-z) output from git diff so filenames containing newlines
# or other special characters are read atomically (matches the pattern in skill-check.yml).
# Each extracted name is then validated against a strict allowlist regex before use,
# rejecting anything containing shell metacharacters ($, (, ), spaces, etc.).
declare -A seen_dirs=()
changed_extensions=()
errors=()
console.log(`Validating ${changedExtDirs.size} extension(s): ${[...changedExtDirs].join(', ')}`);
while IFS= read -r -d '' file; do
case "$file" in
extensions/*)
ext_name="${file#extensions/}"
ext_name="${ext_name%%/*}"
const errors = [];
if [ "$ext_name" = "external-assets" ]; then
continue
fi
for (const extDir of changedExtDirs) {
if (!fs.existsSync(extDir)) {
// Directory was deleted — skip
console.log(`${extDir} no longer exists (deleted?), skipping.`);
continue;
}
# Allowlist: extension directory names must be lowercase alphanumeric + hyphens only.
# Fail for disallowed names so a PR cannot bypass validation by using a nonconforming folder.
if [[ ! "$ext_name" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
errors+=("\`extensions/$ext_name\`: invalid extension directory name (must match ^[a-z0-9][a-z0-9-]*$).")
continue
fi
const extName = path.basename(extDir);
if [ -z "${seen_dirs[$ext_name]+x}" ]; then
seen_dirs["$ext_name"]=1
changed_extensions+=("extensions/$ext_name")
fi
;;
esac
done < <(git diff --name-only -z "origin/${{ github.base_ref }}...HEAD")
// Rule 1: must contain extension.mjs
const mainFile = path.join(extDir, 'extension.mjs');
if (!fs.existsSync(mainFile)) {
errors.push(
`**\`${extDir}\`**: missing required \`extension.mjs\`. ` +
`Canvas extensions must have their entry point named \`extension.mjs\`.`
);
}
if [ "${#changed_extensions[@]}" -eq 0 ]; then
if [ "${#errors[@]}" -ne 0 ]; then
echo "❌ Canvas extension validation failed:"
for error in "${errors[@]}"; do
echo "- $error"
done
exit 1
fi
echo "No canvas extension directories changed — skipping validation."
exit 0
fi
// Rule 2: must contain assets/preview.png
const previewFile = path.join(extDir, 'assets', 'preview.png');
if (!fs.existsSync(previewFile)) {
errors.push(
`**\`${extDir}\`**: missing required \`assets/preview.png\`. ` +
`Canvas extensions must include a screenshot at \`assets/preview.png\` ` +
`so reviewers and users can preview the extension before installing it.`
);
}
}
echo "Validating ${#changed_extensions[@]} extension(s): ${changed_extensions[*]}"
if (errors.length === 0) {
console.log('✅ All changed canvas extensions pass validation.');
return;
}
for ext_dir in "${changed_extensions[@]}"; do
if [ ! -d "$ext_dir" ]; then
echo "$ext_dir no longer exists (deleted?), skipping."
continue
fi
const isFork = context.payload.pull_request.head.repo.fork;
const body = [
'❌ **Canvas extension validation failed**',
'',
'The following issue(s) were found in changed canvas extension(s):',
'',
...errors.map(e => `- ${e}`),
'',
'---',
'',
'### Required structure for canvas extensions',
'',
'Each extension folder under `extensions/` must contain:',
'',
'| Path | Required | Description |',
'|------|----------|-------------|',
'| `extension.mjs` | ✅ | Entry point for the canvas extension |',
'| `assets/preview.png` | ✅ | Screenshot shown on the website and in the marketplace |',
'',
'Please add the missing file(s) and push an update to this PR.',
].join('\n');
if [ ! -f "$ext_dir/extension.mjs" ]; then
errors+=("\`$ext_dir\`: missing required \`extension.mjs\`.")
fi
if (!isFork) {
try {
await github.rest.pulls.createReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
event: 'REQUEST_CHANGES',
body
});
} catch (error) {
core.warning(`Could not post PR review: ${error.message}`);
core.warning(body);
}
} else {
core.warning('PR is from a fork — skipping createReview to avoid permission errors.');
core.warning(body);
}
if [ ! -f "$ext_dir/canvas.json" ]; then
errors+=("\`$ext_dir\`: missing required \`canvas.json\`.")
continue
fi
core.setFailed(`Canvas extension validation failed with ${errors.length} error(s).`);
if [ ! -f "$ext_dir/assets/preview.png" ]; then
errors+=("\`$ext_dir\`: missing required \`assets/preview.png\`.")
fi
if ! schema_output="$(node ./eng/validate-json-schema.mjs --schema .schemas/canvas.schema.json --data "$ext_dir/canvas.json" 2>&1)"; then
condensed_output="$(echo "$schema_output" | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g')"
errors+=("\`$ext_dir/canvas.json\`: schema validation failed against \`.schemas/canvas.schema.json\` ($condensed_output).")
continue
fi
mapfile -t screenshot_paths < <(
node -e 'const fs = require("fs"); const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const paths = [manifest?.screenshots?.icon?.path, manifest?.screenshots?.gallery?.path].filter((value) => typeof value === "string" && value.trim().length > 0); for (const value of [...new Set(paths)]) { console.log(value); }' "$ext_dir/canvas.json"
)
for screenshot_path in "${screenshot_paths[@]}"; do
if [[ ! "$screenshot_path" =~ ^assets/([A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*\.(png|jpe?g|gif|webp|svg)$ ]]; then
errors+=("\`$ext_dir/canvas.json\`: screenshot path \`$screenshot_path\` is not a valid assets path.")
continue
fi
if [ ! -f "$ext_dir/$screenshot_path" ]; then
errors+=("\`$ext_dir/canvas.json\`: screenshot path \`$screenshot_path\` does not exist in the extension directory.")
fi
done
done
if [ "${#errors[@]}" -ne 0 ]; then
echo "❌ Canvas extension validation failed:"
for error in "${errors[@]}"; do
echo "- $error"
done
exit 1
fi
echo "✅ All changed canvas extensions passed validation."