Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/safe-urls-and-prose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"catalog": patch
---

Reject `javascript:`, `data:` and other non-http(s) schemes in every `url` field (E128) and raw HTML in prose (E305); the build now escapes markup it is handed. The one `ftp://` link (dbx 160A) is removed.
90 changes: 81 additions & 9 deletions .github/workflows/url-health.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,31 @@ on:
permissions:
contents: read

# Two jobs, split by what they hold, on the same reasoning as
# assign-ids.yml. `check-urls` runs `pnpm install` (the repo's lifecycle
# scripts) and then thousands of guarded requests to contributor-chosen
# URLs. Anything running in that job can read every credential the job
# references, so it references a read-only token and nothing else, and
# hands its one product, the refreshed cache, to `commit-cache` as an
# artifact. `commit-cache` is the only job that holds a contents-write
# token: it installs nothing, runs no script from the tree, checks the
# artifact is the JSON it expects, and pushes that one file.
jobs:
check-urls:
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
contents: read
issues: write
outputs:
cache: ${{ steps.cache.outputs.present }}

steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 1
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
Expand All @@ -40,28 +52,41 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

# The input is a workflow_dispatch boolean, so it reaches the shell
# through env rather than `${{ }}` inside `run:`, like every other
# value these workflows did not choose.
- name: Run URL validation
id: urls
continue-on-error: true
env:
FORCE_RECHECK: ${{ inputs.force_recheck }}
run: |
if [ "${{ inputs.force_recheck }}" = "true" ]; then
if [ "$FORCE_RECHECK" = "true" ]; then
pnpm validate-urls --update-cache --ignore-cache
else
pnpm validate-urls --use-cache --update-cache
fi

- name: Commit cache updates
# Only the cache file is handed on. The first run may not create it.
- name: Check for cache updates
id: cache
if: always()
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Only add cache file if it exists (first run may not create it)
if [ -f .github/url-cache.json ]; then
git add .github/url-cache.json
git diff --staged --quiet || git commit -m "chore: update URL cache"
git push || echo "Push failed - may need manual intervention"
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
fi

- name: Upload cache
if: always() && steps.cache.outputs.present == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: url-cache
path: .github/url-cache.json
retention-days: 1
if-no-files-found: error

- name: Create or update issue for broken URLs
if: steps.urls.outcome == 'failure'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
Expand Down Expand Up @@ -181,3 +206,50 @@ jobs:
});
console.log(`Closed issue #${existingIssue.number}`);
}

commit-cache:
needs: check-urls
if: always() && needs.check-urls.outputs.cache == 'true'
runs-on: ubuntu-latest
permissions:
contents: write

steps:
# No `pnpm install` runs here, so no husky hooksPath gets configured
# either; hooks are disabled below all the same.
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 1

- name: Download cache
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: url-cache
path: .github

# The artifact was produced by a job that ran contributor-reachable
# code, so it is data to check, not a file to trust: it has to parse
# as the cache shape `scripts/lib/url-cache.ts` writes, and nothing
# else may have changed.
- name: Verify cache artifact
run: |
node -e '
const fs = require("node:fs");
const cache = JSON.parse(fs.readFileSync(".github/url-cache.json", "utf-8"));
const ok = cache && cache.version === 1 && cache.entries && typeof cache.entries === "object" && !Array.isArray(cache.entries);
if (!ok) { console.error("::error::url-cache.json is not a version 1 cache; refusing to commit it"); process.exit(1); }
'
if git status --porcelain | grep -vE '^.. \.github/url-cache\.json$'; then
echo "::error::artifact changed something other than .github/url-cache.json; refusing to commit"
exit 1
fi

- name: Commit cache updates
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .github/url-cache.json
git diff --staged --quiet && exit 0
git -c core.hooksPath=/dev/null commit -m "chore: update URL cache"
git -c core.hooksPath=/dev/null push || echo "Push failed - may need manual intervention"
21 changes: 21 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,27 @@ cohort working:
with an argument array, never `execSync` with an interpolated string.
Quoting does not disarm `$(...)` or a backtick, and catalog filenames
arrive through pull requests.
- Every data file is read through `readRegularFile` (`loadYamlFile`,
`loadYamlFileWithPositions`), which `lstat`s first and refuses a symlink
or anything that is not a regular file. A directory listing is a list of
names, and `readFileSync` follows a name wherever it points, so a
committed `data/software/x.yaml -> /dev/zero` was an unbounded read in
`pnpm validate` and `pnpm build` until this existed.
- Prose is Markdown and a `url` is an http(s) page, at both ends. The build
renders `description`/`details`/`specs` with marked, which passes raw
HTML through by design, and Studio shows the result; `z.url()` accepts
`javascript:` and `data:` because the URL parser does. `pnpm validate`
refuses raw HTML (E305) and any scheme but http and https (E128), on
`url` fields and on links inside prose, read off marked's own lexer so
the verdict is the renderer's. `markdownToHtml` in `build-sqlite.ts`
escapes raw HTML and drops unsafe link destinations regardless, because
the build reads whatever is on disk and a validator is only a statement
about files that went through it. The shared rule lives in
`scripts/lib/prose-markup.ts`.
- `fix-urls` never writes a redirect target that leaves the maker's
registrable domain. The target is whatever the server at the old address
says today, and a lapsed domain answers with its buyer's store; a
cross-site redirect is reported and left for a person.
- A workflow that lays a catalog value out in a Markdown table passes it
through that workflow's `cell()` encoder. The value is data, not markup:
a newline ends the row, a pipe adds a column, a backtick opens a code
Expand Down
3 changes: 0 additions & 3 deletions data/hardware/dbx-160a.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,6 @@ links:
- type: resource
title: 160A Owner's Manual - English
url: https://dbxpro.com/en-US/product_documents/dbx160a-18-2040v-cpdf
- type: resource
title: Compression 101 White Paper
url: ftp://ftp.hmgsl.com/dbxpro.com/pub/PDFs/WhitePapers/Compression_101.pdf
- type: support
title: Contact Technical Support
url: https://dbxpro.com/en-US/support
Expand Down
67 changes: 67 additions & 0 deletions docs/VALIDATION_ERRORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,38 @@ read is omitted, never written as a placeholder.

---

### E128: URL Scheme Is Not http or https

A `url` field (the entry's own, a link's, a version's, a manufacturer's) carries a
scheme other than `http` or `https`: `javascript:`, `data:`, `file:`, `ftp:` and so
on. `z.url()` accepts any of them, because the URL parser does, and nothing
rejected them before. A `url` is a page Studio sends a person to, so a
`javascript:` or `data:` value is a payload rather than an address once a client
opens it, and the catalog's signature would be vouching for it.

The same rule applies to a link written inside prose (`[text](javascript:...)` in
`description`, `details` or `specs`): the build renders those with marked, and a
link it refuses is rendered as its text alone.

**Examples:**

```yaml
# Wrong
url: javascript:alert(1)
links:
- type: resource
title: White paper
url: ftp://ftp.example.com/paper.pdf

# Correct
url: https://example.com/product
```

**Fix:** Point the field at the maker's http(s) page. A document only reachable
over another scheme has no place in `links`; mention it in `details` instead.

---

### E199: Validation Error

A generic validation error that doesn't fall into a more specific category.
Expand Down Expand Up @@ -700,6 +732,41 @@ description: Use the `foo` command to run it

---

### E305: Raw HTML in Prose

A prose field (`description`, `details`, `specs`, or the same under
`translations`) contains raw HTML: a tag, a comment, an inline element. The build
renders these fields with marked, which passes HTML through unchanged by design,
and Studio displays the result as HTML. So until this rule existed, a `<script>`
or an `<img onerror=...>` in an entry's prose shipped verbatim in the signed
database, and nothing between a pull request and a release looked for it.

The check reads marked's own token stream rather than matching `<` in the text,
so it refuses exactly what the renderer would have emitted as markup and leaves
alone what the renderer reads as text (a `<The Lord's Funeral>` in a tracklist is
not a tag). The build also escapes any raw HTML it is handed, as a second layer;
this error is the one a contributor sees.

**Examples:**

```yaml
# Wrong
details: |-
Three modes, <b>including shimmer</b>.

<iframe src="https://example.com/embed"></iframe>

# Correct
details: |-
Three modes, **including shimmer**.
```

**Fix:** Write Markdown. Bold is `**text**`, a list is `- item`, a link is
`[text](https://...)`. An embed or a widget has no place in prose; link to the
page that hosts it.

---

## Identifier Errors (E4xx)

### E400: Invalid Identifier Format
Expand Down
24 changes: 18 additions & 6 deletions schema/json/accessory.json
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,9 @@
},
"url": {
"type": "string",
"format": "uri"
"format": "uri",
"pattern": "^[Hh][Tt][Tt][Pp][Ss]?://",
"description": "An http or https URL."
},
"releaseDate": {
"type": "string",
Expand Down Expand Up @@ -1913,7 +1915,9 @@
},
"url": {
"type": "string",
"format": "uri"
"format": "uri",
"pattern": "^[Hh][Tt][Tt][Pp][Ss]?://",
"description": "An http or https URL."
},
"description": {
"type": "string"
Expand Down Expand Up @@ -2008,7 +2012,9 @@
},
"url": {
"type": "string",
"format": "uri"
"format": "uri",
"pattern": "^[Hh][Tt][Tt][Pp][Ss]?://",
"description": "An http or https URL."
},
"description": {
"type": "string"
Expand Down Expand Up @@ -2140,7 +2146,9 @@
},
"url": {
"type": "string",
"format": "uri"
"format": "uri",
"pattern": "^[Hh][Tt][Tt][Pp][Ss]?://",
"description": "An http or https URL."
},
"description": {
"type": "string"
Expand Down Expand Up @@ -2198,7 +2206,9 @@
},
"url": {
"type": "string",
"format": "uri"
"format": "uri",
"pattern": "^[Hh][Tt][Tt][Pp][Ss]?://",
"description": "An http or https URL."
},
"links": {
"type": "array",
Expand All @@ -2225,7 +2235,9 @@
},
"url": {
"type": "string",
"format": "uri"
"format": "uri",
"pattern": "^[Hh][Tt][Tt][Pp][Ss]?://",
"description": "An http or https URL."
},
"description": {
"type": "string"
Expand Down
24 changes: 18 additions & 6 deletions schema/json/content.json
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,9 @@
},
"url": {
"type": "string",
"format": "uri"
"format": "uri",
"pattern": "^[Hh][Tt][Tt][Pp][Ss]?://",
"description": "An http or https URL."
},
"releaseDate": {
"type": "string",
Expand Down Expand Up @@ -1913,7 +1915,9 @@
},
"url": {
"type": "string",
"format": "uri"
"format": "uri",
"pattern": "^[Hh][Tt][Tt][Pp][Ss]?://",
"description": "An http or https URL."
},
"description": {
"type": "string"
Expand Down Expand Up @@ -2008,7 +2012,9 @@
},
"url": {
"type": "string",
"format": "uri"
"format": "uri",
"pattern": "^[Hh][Tt][Tt][Pp][Ss]?://",
"description": "An http or https URL."
},
"description": {
"type": "string"
Expand Down Expand Up @@ -2140,7 +2146,9 @@
},
"url": {
"type": "string",
"format": "uri"
"format": "uri",
"pattern": "^[Hh][Tt][Tt][Pp][Ss]?://",
"description": "An http or https URL."
},
"description": {
"type": "string"
Expand Down Expand Up @@ -2198,7 +2206,9 @@
},
"url": {
"type": "string",
"format": "uri"
"format": "uri",
"pattern": "^[Hh][Tt][Tt][Pp][Ss]?://",
"description": "An http or https URL."
},
"links": {
"type": "array",
Expand All @@ -2225,7 +2235,9 @@
},
"url": {
"type": "string",
"format": "uri"
"format": "uri",
"pattern": "^[Hh][Tt][Tt][Pp][Ss]?://",
"description": "An http or https URL."
},
"description": {
"type": "string"
Expand Down
Loading
Loading