Skip to content

feat(validator): OTE validator, discovery package and fetcher Worker - #61

Merged
hhkaos merged 5 commits into
mainfrom
feat/ote-validator
Aug 27, 2026
Merged

feat(validator): OTE validator, discovery package and fetcher Worker#61
hhkaos merged 5 commits into
mainfrom
feat/ote-validator

Conversation

@hhkaos

@hhkaos hhkaos commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes #60.

A public page that answers "is this document a valid OTE feed or event?" with nothing to install — the objective referee to link to from an issue instead of an argument.

What is here

Component Role
apps/validator The page. Three modes: URL, file upload, paste. Served at /validator/.
packages/discover-feed Pure feed discovery: bytes + content-type + base URL → candidate feed URLs. No network.
workers/fetch-url Cloudflare Worker, the only component with network access.

The first commit (refactor(feed-urls)) is pre-existing work that was sitting uncommitted in the working tree — the custom-domain URL logic extracted out of apps/publish into its own package. Kept separate so it can be reviewed on its own.

Decisions worth reviewing

  • @opentechevents/validate reused verbatim. A validator holding a second opinion about validity would leave the format without a referee.
  • Discovery and validation are two verdicts, never merged. A page with a typo'd <link> gets "no OTE feed discovered", not "invalid". A page declaring several feeds stops and asks rather than picking the first.
  • MUST and SHOULD stay apart. Schema violations make a document invalid; unmet recommendations do not.
  • Upload and paste never touch the network — verified by a test that deletes globalThis.fetch and validates a fixture anyway.
  • Media type matching is lax while opentechevents-spec#6 is open: application/ote+json and application/feed+json both accepted, and the UI says which one was served.

SSRF

The Worker is a public endpoint that fetches whatever URL a stranger passes, so src/ssrf.ts is the point of the component: scheme allowlist, resolve first and judge the resolved IP (never the hostname — the attacker owns their DNS zone), per-hop revalidated redirects, no credentials forwarded, blocked non-HTTP ports, a 5 MB cap applied while streaming, timeouts and per-IP rate limiting.

Verified against the deployed Worker, not only in tests:

Probe Result
file:///etc/passwd blocked-scheme
http://169.254.169.254/latest/meta-data/ blocked-address
hostname resolving to 127.0.0.1 blocked-address
public URL redirecting to a private IP blocked-address
50 MB response cut at the cap, never buffered

Known residual risk, documented: a Worker cannot pin a connection to the IP it validated, so DNS rebinding is not fully excluded.

Two traps unit tests cannot catch

Both were found only by loading the real page, and both are commented at the call site:

  1. Passing the global fetch by reference throws Illegal invocation in a browser and in the Workers runtime. Node tolerates it, so 63 tests passed against code that could not make a single request.
  2. ajv compiles schemas with new Function, so the page CSP needs 'unsafe-eval'; without it the module throws at import and the page silently registers no listeners. boot-errors.js now makes that state announce itself. The durable fix is build-time standalone validators, which would let the CSP drop 'unsafe-eval'.

Deployment state

  • Worker deployed: https://ote-fetch-url.hhkaos.workers.dev, plus the fetch.opentechevents.org custom domain, declared in wrangler.jsonc (live once the .org delegation to Cloudflare lands).
  • OTE_FETCH_ENDPOINT repository variable points the page at the Worker; deploy-tools.yml bakes it into both the bundle and the CSP connect-src, so the two cannot drift.
  • localhost:8000 is currently in the Worker's CORS allowlist for local development — say the word and it comes out.

Checks

pnpm install --frozen-lockfile, lint, build, typecheck and the full suite (1066 tests across 18 projects) pass on this branch. Both commits are independently installable — the lockfile was regenerated for each.

🤖 Generated with Claude Code

hhkaos and others added 5 commits August 27, 2026 18:43
The custom-domain fix from 7e307e4 lived inside apps/publish, but editor
and preview guess the same URLs from the same `?repo=` context and had
their own narrower copies — one that still broke on custom domains.

@opentechevents/feed-urls now owns the rule: a fork on a custom domain
answers `owner.github.io/name/<file>` with a 301 that carries no CORS
header, so the browser blocks the fetch before reaching the destination
that would have allowed it. Script cannot read the redirect's Location,
so the domain has to arrive from outside the fetch — an explicit `?feed=`
or the origin of the dashboard that linked here. Hence a list of
candidates to try in order, not one string.

Consumers change shape accordingly: editor's `pagesFeedUrl: string`
becomes `pagesFeedUrls: string[]` behind a `fetchFirstJson` helper, and
publish drops its local copy of the logic.
…cher

Answers "is this document a valid OTE feed or event?" with nothing to
install, in three modes — a URL, an uploaded file, pasted JSON — and
gives the format an objective referee to link to from an issue instead
of an argument. Implements issue #60.

Three components, split by what each is allowed to do:

apps/validator — the page, static, served at /validator/. Upload and
paste run entirely in the tab against @opentechevents/validate (reused
verbatim: a validator with a second opinion about validity would defeat
the purpose), so they keep working with the fetcher down. Discovery and
validation are rendered as two separate verdicts, because telling an
organizer whose <link> has a typo that their JSON is broken sends them
to fix the wrong thing. MUST (schema) and SHOULD (recommended profile)
stay in separate sections for the same reason. Findings carry a JSON
Pointer plus line:column and highlight that line in the source view.

packages/discover-feed — pure functions, no network: bytes +
content-type + base URL in, candidate feed URLs out. The reference
implementation of spec discovery, kept out of the app because the
crawler and the bot will want it, and out of the Worker so spec rules
never live behind an SSRF boundary. Media-type matching is lax on
purpose while opentechevents-spec#6 is open: application/ote+json and
application/feed+json are both accepted and the caller is told which
one was served.

workers/fetch-url — a Cloudflare Worker, the only component with
network access, because a browser cannot fetch a third-party feed
without CORS. No database, accounts, auth or persistence: that removes
half of the OWASP Top 10 by construction and concentrates the rest in
SSRF, which ssrf.ts is entirely about — scheme allowlist, resolve-first
then judge the resolved IP (the attacker owns their DNS zone),
per-hop revalidated redirects, no credentials, a 5 MB cap applied while
streaming, timeouts and per-IP rate limiting.

Nothing remote is ever rendered as HTML: every fetched string reaches
the DOM through textContent, and the Worker answers JSON with nosniff
and a locked-down CSP of its own.

Two traps here are invisible to unit tests and cost live debugging, so
both are commented at the call site and in the app README: passing the
global fetch by reference throws "Illegal invocation" in a browser and
in the Workers runtime while Node tolerates it, and ajv compiling
schemas with new Function needs 'unsafe-eval' in the page CSP — without
it the module throws at import and the page silently registers no
listeners at all. boot-errors.js makes that failure state visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tools are reached from that site and carry its name, so a visitor
must not feel handed off to a different product. Tokens, type scale,
header, buttons, cards and footer come from its stylesheet rather than
from a parallel palette invented here: ink #10131a, accent #2b5bd7,
radius 10, 1120px wrap, 17px/1.65 system sans.

The document viewer reuses that site's dark code block — caption bar,
mono, #d5dae4 on #10131a — because the JSON being validated is a code
sample and now reads as one; the highlighted line uses the same #7fb3ff
the site marks lines with.

Two fixes to what the layout was doing wrong:

- "Validate as" appears only once there is a verdict. It was offering to
  correct a detection that had not happened yet.
- From 1000px the results split: findings left, document right and
  sticky. Pointing at line 42 is worth little if line 42 has scrolled
  away. Below that it stacks, findings first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
validator.opentechevents.org replaces tools.opentechevents.org/validator/
as the canonical URL, and workers/validator (renamed from fetch-url)
serves both the page — through an assets binding — and /fetch.

Two reasons, one of them structural:

The validator is the only tool with no `?repo=` context. The others read
an organizer's fork from the query string, which is what the shared
tools host expresses; this one serves anyone holding a JSON document,
kit user or not, so a path under that host misfiled it.

Sharing an origin deletes a class of configuration rather than managing
it. The page issues a relative /fetch, so no cross-origin request
happens, its CSP tightens to `connect-src 'self'`, and ALLOWED_ORIGINS
shrinks to callers that genuinely are elsewhere (the dev server, the
legacy path). The endpoint URL is no longer baked into the bundle and
the CSP, where the two could drift apart.

GitHub Pages keeps /validator/ as a redirect so links already shared
survive and permalinks have one canonical form. Deployment moves to
deploy-validator.yml, which builds the page, deploys the Worker and then
re-runs the SSRF probes against the live endpoint — a deploy that stopped
refusing file:// or the metadata address is worse than one that failed.

Needs a CLOUDFLARE_API_TOKEN repository secret.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fetch.opentechevents.org was declared alongside validator.opentechevents.org
to give the fetcher an "API name". It did not: assets are served before the
Worker script runs, so that hostname served the whole page as well — two
canonical URLs for one tool, which is precisely what keeping /validator/ as
a redirect exists to prevent.

An API hostname, if ever genuinely wanted, needs its own Worker without an
assets binding rather than another route onto this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hhkaos
hhkaos merged commit 0d44c1d into main Aug 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Tool] Validador OTE: web de validación por URL / fichero / pegado

1 participant