diff --git a/README.md b/README.md index 9568ed1..ce29578 100644 --- a/README.md +++ b/README.md @@ -212,9 +212,28 @@ loaded from the working directory it is started in. Required, eight: `LEDGER_API_URL`, `LEDGER_API_TOKEN`, `ADMIN_PARTY`, and the five template ids (`INSTRUMENT_CONFIG_TEMPLATE_ID`, `PREAPPROVAL_TEMPLATE_ID`, `LOCKED_TOKEN_TEMPLATE_ID`, `TRANSFER_INSTRUCTION_TEMPLATE_ID`, -`ALLOCATION_TEMPLATE_ID`). Optional, four: `PORT`, `LEDGER_USER_ID`, -`SHUTDOWN_TIMEOUT_MS`, `DIRECT_TRANSFER_MARGIN_MS`. The package ships -`registry/.env.example` with the full list and what each variable is for. +`ALLOCATION_TEMPLATE_ID`). Optional, five: `PORT`, `LEDGER_USER_ID`, +`SHUTDOWN_TIMEOUT_MS`, `DIRECT_TRANSFER_MARGIN_MS`, `CORS_ORIGINS`. The package +ships `registry/.env.example` with the full list and what each variable is for. + +`CORS_ORIGINS` is the comma-separated list of origins a browser dApp may call +the service from, defaulting to `http://localhost:3012`; an entry of `*` +anywhere in it means any origin, and it is the only wildcard there is: a +pattern such as `https://*.app.example.com` is compared literally, matches no +origin a browser sends, and is refused at boot rather than accepted as a list +that allows nothing. A browser reads a cross-origin response only if the +service names the requesting origin back, so an origin missing from this list +fails in the page with an opaque network error. What reaches the service +differs by route: the three `GET` routes are simple requests, delivered and +answered in full with only the browser withholding the body from the page, +while every `POST` route carries a JSON body and is therefore preflighted, and +a refused preflight ends the call before the `POST` is ever sent. Neither +leaves anything behind that names the origin, because the service logs no +requests at all; the list it accepted is on its startup line instead. +Each entry is written as a browser computes an origin, `http(s)://host` with a +port only when it is not the scheme's default and with no path, query or +trailing slash; the service refuses to start on anything else, since the +comparison is an exact string match and a near miss matches nothing. Quote all five template ids in a `.env` file. Every one of them begins with `#`, which dotenv reads as the start of a comment, so an unquoted diff --git a/RUNBOOK.md b/RUNBOOK.md index 2ef7f01..72136c0 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -276,6 +276,19 @@ reason the seed script looks the way it does. They are concrete template ids, never interface ids: the choice-context handlers read payload fields that exist on the template create arguments and not on the standard interface views. +- `CORS_ORIGINS` entries are compared against the `Origin` a browser sends, as + exact strings, so the service refuses to start on any entry no browser could + ever send: a near miss of a real origin, such as a trailing slash, a host + that is not lower case, a spelled-out default port or a path; a scheme other + than `http` or `https`; or a pattern such as `https://*.app.example.com`, + which is matched literally and so matches nothing. A near miss is refused + naming what a browser would have sent, so the message is the value to write. + Unset and empty both mean the default, `http://localhost:3012`, and an entry + of `*` anywhere in the list means any origin, so there is no value that + allows none. A refused origin leaves nothing here to find: a simple request + is served in full and only the browser withholds the body, a preflighted one + never arrives at all, and the service logs no requests either way. The list + it accepted is on the startup line instead. - `LEDGER_USER_ID` has no effect on the running service, which submits nothing. The seed prints it as a record of the user it submitted under, not as an input the service reads back. Setting it in `registry/.env` changes nothing at all: diff --git a/SPEC.md b/SPEC.md index f85e2d3..be32d20 100644 --- a/SPEC.md +++ b/SPEC.md @@ -53,7 +53,7 @@ All three suites were re-run against the tree this document ships with, exit 0: | Suite | Result | Needs | |---|---|---| | Daml Script | **80 scenarios**, 12 modules | nothing, runs in-process | -| Registry unit | **205 tests**, 10 files | nothing, in-process server with a stub ledger | +| Registry unit | **245 tests**, 11 files | nothing, in-process server with a stub ledger | | End-to-end | **18 tests**, 4 files | a live participant, verified against Canton 3.5.12 | The end-to-end suite drives both transfer paths against a real participant: it @@ -63,8 +63,8 @@ resulting exercise itself over the JSON Ledger API, forwarding the service's ### Size and status -976 lines of production Daml, 2508 lines of Daml tests, 1739 lines of -TypeScript service, 4349 lines of TypeScript tests, each figure a +976 lines of production Daml, 2508 lines of Daml tests, 1840 lines of +TypeScript service, 4665 lines of TypeScript tests, each figure a `find -name '*.daml'` (or `'*.ts'`) `| xargs wc -l` count over `daml/canton-token-forge/daml`, `daml/canton-token-forge-test/daml`, `registry/src` and `registry/test` respectively. The two Daml paths name the @@ -317,12 +317,12 @@ holding for any surplus, so no value is created or destroyed. ## 6. Registry HTTP service -A TypeScript service (Express, `express-openapi-validator`, pino; Node 20+) that -validates incoming requests against the four CN Token Standard OpenAPI specs it -ships. Responses are covered by the unit suite rather than by runtime schema -validation. The service is **read-only**: it queries the JSON Ledger API for -active contracts and submits nothing. The client holds the keys and sends the -exercise itself. +A TypeScript service (Express, `express-openapi-validator`, `cors`, pino; +Node 20+) that validates incoming requests against the four CN Token Standard +OpenAPI specs it ships. Responses are covered by the unit suite rather than +by runtime schema validation. The service is **read-only**: it queries the +JSON Ledger API for active contracts and submits nothing. The client holds +the keys and sends the exercise itself. | Method | Path | |---|---| @@ -362,9 +362,10 @@ about rather than fatal, so a ledger outage does not turn into a crashloop. Configuration is entirely by environment: eight required variables (ledger URL and token, admin party, and five concrete template ids in package-name form) and -four optional ones. The service refuses to start if any required variable is +five optional ones. The service refuses to start if any required variable is missing, if a template id is not in package-name form or names nothing the -participant hosts, or if the admin party fails the boot check above, rather than +participant hosts, if an allowed browser origin is not written in the form a +browser sends, or if the admin party fails the boot check above, rather than serving empty results from a filter that matches nothing. ### Choice contexts and disclosure @@ -448,7 +449,7 @@ exist. | Level | What it covers | |---|---| | Daml Script, 80 scenarios | Every choice and both factory paths, including negative cases: wrong `expectedAdmin`, a batch transfer routed through another instrument of the same admin, non-positive amounts, duplicate and locked inputs, cross-instrument spending, an escrow that does not back the transfer it settles, both sides of every deadline instant, missing authority, the `decimals` bound, and the batch transfer's own refusals: outputs whose total exceeds the inputs and a lock output already past its expiry | -| Registry unit, 205 tests | Every route against an in-process server with a stub ledger: response shapes, error schemas, 404 and 409 behaviour, context and disclosure contents, the state an escrow lookup has to be in before a context may report a reclaim, config validation, and that each request is validated against the one spec that describes it, whichever form its request target arrives in and even when it carries a fragment, which is no form at all | +| Registry unit, 245 tests | Every route against an in-process server with a stub ledger: response shapes, error schemas, 404 and 409 behaviour, context and disclosure contents, the state an escrow lookup has to be in before a context may report a reclaim, config validation, that a configured browser origin is answered and an unconfigured one is not, on rejections as well as on successes, that an entry no browser could ever send is refused at boot, a pattern and a scheme a browser sends no Origin in included, rather than accepted as a list that allows nothing, that no response allows credentials under either origin mode, that a simple request from an unconfigured origin is served in full regardless and refused only in the browser, a preflighted one being stopped in the browser before it is sent, that a path the service does not route answers a preflight all the same, and that each request is validated against the one spec that describes it, whichever form its request target arrives in and even when it carries a fragment, which is no form at all | | End-to-end, 18 tests | Both transfer paths and the faucet against a live participant, submitting real exercises built from the service's own answers, including a misconfigured escrow template id that must not produce a reclaim report | The end-to-end suite allocates its own parties and instrument per run, so it @@ -466,7 +467,7 @@ instrument, then prints a ready-to-paste service configuration. ```bash npm run setup # vendors the Splice interface DARs into deps/ npm test # builds the production DAR, runs 80 Daml scenarios -cd registry && npm install && npm test # 205 unit tests, no ledger needed +cd registry && npm install && npm test # 245 unit tests, no ledger needed npm run sandbox # a local Canton sandbox with the JSON Ledger API npm run seed # an admin, demo users, one instrument diff --git a/package-lock.json b/package-lock.json index 3f586f5..58ed9c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.0", "license": "MIT", "dependencies": { + "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", "express-openapi-validator": "^5.6.2", @@ -18,6 +19,7 @@ "canton-token-forge-registry": "registry/dist/index.js" }, "devDependencies": { + "@types/cors": "^2.8.19", "@types/express": "^4.17.21", "@types/node": "^26.1.1", "typescript": "^5.5.4" @@ -70,6 +72,15 @@ "@types/node": "*" } }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/express": { "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", @@ -360,6 +371,22 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -846,6 +873,14 @@ "node": ">= 0.6" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/package.json b/package.json index ebbc59e..d8e91fd 100644 --- a/package.json +++ b/package.json @@ -23,12 +23,14 @@ "bin": { "canton-token-forge-registry": "registry/dist/index.js" }, "files": ["registry/dist", "registry/openapi", "registry/.env.example"], "dependencies": { + "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", "express-openapi-validator": "^5.6.2", "pino": "^10.3.1" }, "devDependencies": { + "@types/cors": "^2.8.19", "@types/express": "^4.17.21", "@types/node": "^26.1.1", "typescript": "^5.5.4" diff --git a/registry/.env.example b/registry/.env.example index ead6a26..b7694fc 100644 --- a/registry/.env.example +++ b/registry/.env.example @@ -75,3 +75,28 @@ SHUTDOWN_TIMEOUT_MS=8000 # sender submits. Preapprovals expiring within it are served as offers instead. # Zero disables the margin. DIRECT_TRANSFER_MARGIN_MS=30000 + +# Comma-separated list of origins a browser dApp may call this service from. +# The service reflects an origin back only if it is on this list, because a +# browser will not hand a cross-origin response to the page otherwise. An entry +# of "*" anywhere in the list means any origin at all. Optional; defaults to +# http://localhost:3012, the dApp dev server this exists for. Any real +# deployment sets this to its own origin(s). +# +# Unset and empty both mean the default, so there is no value that allows no +# origin: the narrowest setting is one origin nothing is served from. +# +# Write each entry exactly as a browser computes an origin, http:// or https:// +# followed by the host, with a port only when it is not the scheme's default, +# and with no path, query or trailing slash. The service refuses to start on an +# entry that is not in that form, because the origin is compared as an exact +# string and a near miss matches nothing at all. Where the entry is a near miss +# of a real origin the refusal names what a browser would have sent, and where +# it names no origin at all, such as a host written without its scheme, it says +# so instead. +# +# The "*" above is the only wildcard there is. No entry may carry one inside it: +# a pattern such as https://*.app.example.com is compared literally, matches no +# origin a browser sends, and is refused at boot rather than accepted as a list +# that allows nothing. List each origin the dApp is served from instead. +CORS_ORIGINS=http://localhost:3012 diff --git a/registry/package-lock.json b/registry/package-lock.json index 2f5659f..36d1cfa 100644 --- a/registry/package-lock.json +++ b/registry/package-lock.json @@ -8,6 +8,7 @@ "name": "canton-token-forge-registry", "version": "0.0.1", "dependencies": { + "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", "express-openapi-validator": "^5.6.2", @@ -15,6 +16,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.10", + "@types/cors": "^2.8.19", "@types/express": "^4.17.21", "@types/node": "^26.1.1", "@types/supertest": "^6.0.2", @@ -1023,6 +1025,15 @@ "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", "dev": true }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1535,6 +1546,22 @@ "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "dev": true }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -2227,6 +2254,14 @@ "node": ">= 0.6" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/registry/package.json b/registry/package.json index f0fec3d..62d801d 100644 --- a/registry/package.json +++ b/registry/package.json @@ -17,6 +17,7 @@ "lint:fix": "biome check --write" }, "dependencies": { + "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", "express-openapi-validator": "^5.6.2", @@ -24,6 +25,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.10", + "@types/cors": "^2.8.19", "@types/express": "^4.17.21", "@types/node": "^26.1.1", "@types/supertest": "^6.0.2", diff --git a/registry/src/config.ts b/registry/src/config.ts index 07b3575..5c225b5 100644 --- a/registry/src/config.ts +++ b/registry/src/config.ts @@ -14,6 +14,10 @@ export interface Config { port: number shutdownTimeoutMs: number directTransferMarginMs: number + // A browser will not hand a cross-origin response to the page unless the + // service names the requesting origin back in the response, so the origins + // a dApp may call from have to be configured rather than inferred. + corsOrigins: string[] } const DEFAULT_PORT = 8080 @@ -36,6 +40,10 @@ const DEFAULT_DIRECT_TRANSFER_MARGIN_MS = 30_000 // intent; rejecting it at boot beats silently disabling the direct path. const MAX_DIRECT_TRANSFER_MARGIN_MS = 3_600_000 +// The dApp dev server the CORS report was filed from, so the reported case +// works with no configuration. Any real deployment sets CORS_ORIGINS itself. +const DEFAULT_CORS_ORIGINS = 'http://localhost:3012' + export function loadConfig(env: NodeJS.ProcessEnv): Config { const require_ = (k: string): string => { const v = env[k] @@ -80,6 +88,60 @@ export function loadConfig(env: NodeJS.ProcessEnv): Config { } return n } + // A browser sends the origin it computed, so an entry the browser can never + // send matches nothing and blocks the dApp with the same opaque failure this + // list exists to prevent, from a service that started clean. URL normalizes + // the near misses an operator writes by hand, a trailing slash, a host that + // is not already lower case, a spelled-out default port, a path, so + // comparing an entry against its own origin catches all of those. Two kinds + // survive that comparison unchanged and are refused ahead of it instead: a + // pattern, which parses as a host that happens to carry a "*", and a scheme + // a browser never sends an Origin for. + const parseOrigins = (raw: string | undefined): string[] => { + const entries = (raw || DEFAULT_CORS_ORIGINS) + .split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0) + if (entries.length === 0) { + throw new Error(`invalid CORS_ORIGINS: names no origin, got "${raw}"`) + } + const notAnOrigin = (entry: string) => + new Error( + `invalid CORS_ORIGINS entry "${entry}": expected an origin, http(s)://host[:port], or *`, + ) + for (const entry of entries) { + if (entry === '*') continue + // The list is matched by exact string, so a pattern matches nothing at + // all, and "*" meaning any origin is what invites one. URL parses + // "https://*.example.com" happily and reports itself as its own origin, + // so this is the only place it can be caught. + if (entry.includes('*')) { + throw new Error( + `invalid CORS_ORIGINS entry "${entry}": no pattern is matched, list each origin, or "*" alone for any`, + ) + } + let url: URL + try { + url = new URL(entry) + } catch { + throw notAnOrigin(entry) + } + // Only these two schemes reach the service as an Origin, and confining + // the entry to them is also what keeps the message honest: URL accepts + // anything carrying a colon, so a scheme-less entry parses as a + // non-special URL whose origin is the literal string "null", and naming + // that back as the value to write would be a remedy the operator cannot + // take, since "null" is itself refused as not a URL. + if (url.protocol !== 'http:' && url.protocol !== 'https:') throw notAnOrigin(entry) + const normalized = url.origin + if (normalized !== entry) { + throw new Error( + `invalid CORS_ORIGINS entry "${entry}": a browser would send "${normalized}", so write that instead`, + ) + } + } + return entries + } return { ledgerApiUrl: require_('LEDGER_API_URL'), ledgerApiToken: require_('LEDGER_API_TOKEN'), @@ -93,5 +155,6 @@ export function loadConfig(env: NodeJS.ProcessEnv): Config { port: parsePort(env.PORT), shutdownTimeoutMs: parseTimeoutMs(env.SHUTDOWN_TIMEOUT_MS), directTransferMarginMs: parseMarginMs(env.DIRECT_TRANSFER_MARGIN_MS), + corsOrigins: parseOrigins(env.CORS_ORIGINS), } } diff --git a/registry/src/index.ts b/registry/src/index.ts index 649ae2a..1359934 100644 --- a/registry/src/index.ts +++ b/registry/src/index.ts @@ -28,7 +28,17 @@ if (!(await checkAdminParty(ledger, config, logger))) process.exit(1) const app = createServer({ ledger, config, logger }) const server = app.listen(config.port, () => { - logger.info({ port: config.port }, 'canton-token-forge registry listening') + // The origin list is the one setting whose effect is invisible from the + // service side. A refused simple request is delivered and answered in full, + // with only the browser withholding the body afterwards, while a refused + // preflight ends its POST before it is sent, so what arrives differs but + // neither leaves a trace: nothing here is logged per request. Recording what + // was accepted at boot is what lets an operator tell a rejected origin from + // an unreachable service. + logger.info( + { port: config.port, corsOrigins: config.corsOrigins }, + 'canton-token-forge registry listening', + ) }) let shuttingDown = false diff --git a/registry/src/server.ts b/registry/src/server.ts index 22f935f..ae0590d 100644 --- a/registry/src/server.ts +++ b/registry/src/server.ts @@ -1,4 +1,5 @@ import path from 'node:path' +import cors from 'cors' import express, { type Express, type NextFunction, type Request, type Response } from 'express' import * as OpenApiValidator from 'express-openapi-validator' import type { Config } from './config.js' @@ -73,6 +74,33 @@ export function createServer(deps: ServerDeps): Express { const app = express() const logger = deps.logger ?? createLogger() app.use(canonicalizeRequestTarget) + // A browser only reads a cross-origin response if that response names its + // origin back, and a rejection is a response too, so this has to run ahead + // of the body parser and the validators below: otherwise their own 400s + // reach the page as an opaque network error instead of the message they + // carry. It also answers the preflight itself, which is why no route below + // ever sees an OPTIONS request: every one of them is terminated here, so a + // path that routes nowhere answers 204 to an OPTIONS where it 404s to a GET. + // The four vendored specs declare only GET and POST operations and express + // serves HEAD for every GET, so the methods and the one header a handler can + // reach are both named rather than left at the cors defaults, which advertise + // methods no route answers and echo back whatever headers a caller asks for. + // Every factory route is a POST and so preflights on every call; a browser + // caches a preflight carrying no max-age for seconds, which would make each + // call two round trips. + // Credentials are deliberately not allowed, and a "*" entry is safe only + // while that holds: it selects the reflected-origin mode, and reflecting an + // origin while allowing credentials makes any page a credentialed reader of + // this service. The reference service this configuration was modelled on + // does allow them, so a test pins the omission. + app.use( + cors({ + origin: deps.config.corsOrigins.includes('*') ? true : deps.config.corsOrigins, + methods: ['GET', 'HEAD', 'POST', 'OPTIONS'], + allowedHeaders: ['Content-Type'], + maxAge: 600, + }), + ) app.use(express.json()) // One validator per vendored standard spec, requests only, each mounted on diff --git a/registry/test/config.test.ts b/registry/test/config.test.ts index dab631b..53262dd 100644 --- a/registry/test/config.test.ts +++ b/registry/test/config.test.ts @@ -171,3 +171,142 @@ describe('loadConfig direct transfer margin parsing', () => { ) }) }) + +describe('loadConfig CORS origins parsing', () => { + it('defaults to the dApp dev server when CORS_ORIGINS is unset', () => { + expect(loadConfig({ ...baseEnv }).corsOrigins).toEqual(['http://localhost:3012']) + }) + + it('defaults to the dApp dev server when CORS_ORIGINS is an empty string', () => { + expect(loadConfig({ ...baseEnv, CORS_ORIGINS: '' }).corsOrigins).toEqual([ + 'http://localhost:3012', + ]) + }) + + it('parses a single origin', () => { + expect(loadConfig({ ...baseEnv, CORS_ORIGINS: 'http://a' }).corsOrigins).toEqual(['http://a']) + }) + + it('splits and trims a comma-separated list', () => { + expect(loadConfig({ ...baseEnv, CORS_ORIGINS: 'http://a, http://b' }).corsOrigins).toEqual([ + 'http://a', + 'http://b', + ]) + }) + + it('drops empty entries left by stray or trailing commas', () => { + expect(loadConfig({ ...baseEnv, CORS_ORIGINS: 'http://a,,http://b,' }).corsOrigins).toEqual([ + 'http://a', + 'http://b', + ]) + }) + + it('keeps "*" verbatim: the server, not the config, interprets it', () => { + expect(loadConfig({ ...baseEnv, CORS_ORIGINS: '*' }).corsOrigins).toEqual(['*']) + }) + + // A value that is non-empty but names nothing does not reach the default, + // and the empty list it used to produce matched every origin against + // nothing: the service started clean and no browser could read a response. + it.each([ + ',', + ' ', + ' , , ', + ',,,', + ])('throws when CORS_ORIGINS is %j, which names no origin', (value) => { + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: value })).toThrow( + /invalid CORS_ORIGINS: names no origin/, + ) + }) + + // The two shapes an operator actually writes by hand. A browser sends + // neither, and cors compares origins by exact string, so both would match + // nothing at all. + it('throws on an entry with a trailing slash, naming what a browser would send', () => { + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: 'http://localhost:3012/' })).toThrow( + /a browser would send "http:\/\/localhost:3012"/, + ) + }) + + it('throws on an entry whose host is not lower case', () => { + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: 'http://LOCALHOST:3012' })).toThrow( + /a browser would send "http:\/\/localhost:3012"/, + ) + }) + + it('throws on an entry that is not a URL at all', () => { + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: 'not a url' })).toThrow( + /expected an origin, http\(s\):\/\/host\[:port\], or \*/, + ) + }) + + // The entry the "*" spelling invites, and the one URL cannot catch: a + // pattern parses, and its own origin is itself, so it reaches cors and is + // compared to a real origin as a literal string, matching nothing. That is + // the empty-allowlist boot this validation exists to refuse. + it.each([ + 'https://*.app.example.com', + 'http://*.example.com', + 'https://*', + ])('throws on the pattern %j, which cors would match literally', (value) => { + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: value })).toThrow( + /no pattern is matched, list each origin, or "\*" alone for any/, + ) + }) + + it('still accepts "*" alone, which is not a pattern but the any-origin spelling', () => { + expect(loadConfig({ ...baseEnv, CORS_ORIGINS: 'https://a.example, *' }).corsOrigins).toEqual([ + 'https://a.example', + '*', + ]) + }) + + // A special scheme other than http(s) round-trips through URL.origin, so + // these pass the near-miss comparison and would be accepted on its word + // alone. No browser sends an Origin in any of them. + it.each([ + 'ws://a.example', + 'wss://a.example', + 'ftp://a.example', + ])('throws on %j, a scheme no browser sends an Origin for', (value) => { + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: value })).toThrow( + /expected an origin, http\(s\):\/\/host\[:port\], or \*/, + ) + }) + + // URL accepts anything carrying a colon, so an entry that omits the scheme + // parses as a non-special URL whose origin is the literal string "null". + // These have to land on the message above rather than the one that names a + // replacement, because "null" is not a value the operator can write: it is + // itself refused as not a URL, so naming it costs a second failed boot. + it.each([ + 'localhost:3012', + 'app.example:8080', + 'file:///x', + 'chrome-extension://abc', + ])('throws on %j, which has no origin to name back', (value) => { + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: value })).toThrow( + /expected an origin, http\(s\):\/\/host\[:port\], or \*/, + ) + }) + + it('never tells the operator to write "null"', () => { + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: 'localhost:3012' })).not.toThrow( + /a browser would send "null"/, + ) + }) + + // A default port is part of what URL normalizes away, so naming it is the + // same class of unmatchable entry as a trailing slash. + it('throws on an entry that spells out the scheme default port', () => { + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: 'http://app.example:80' })).toThrow( + /a browser would send "http:\/\/app.example"/, + ) + }) + + it('rejects a bad entry even when a good one precedes it', () => { + expect(() => + loadConfig({ ...baseEnv, CORS_ORIGINS: 'http://localhost:3012, http://app.example/' }), + ).toThrow(/invalid CORS_ORIGINS entry "http:\/\/app.example\/"/) + }) +}) diff --git a/registry/test/cors.test.ts b/registry/test/cors.test.ts new file mode 100644 index 0000000..b935099 --- /dev/null +++ b/registry/test/cors.test.ts @@ -0,0 +1,173 @@ +import request from 'supertest' +import { describe, expect, it } from 'vitest' +import { createServer } from '../src/server' +import { cfgEntry, config, ledgerFrom } from './helpers/fixtures' + +const ALLOWED_ORIGIN = config.corsOrigins[0] +const DISALLOWED_ORIGIN = 'http://not-allowed.example' + +describe('cors', () => { + it('reflects an allowed origin on a real GET, with Vary: Origin', async () => { + const app = createServer({ ledger: ledgerFrom({}), config }) + const res = await request(app).get('/healthz').set('Origin', ALLOWED_ORIGIN) + expect(res.status).toBe(200) + expect(res.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN) + expect(res.headers.vary).toBe('Origin') + }) + + // The service does not reject a disallowed origin: it answers the request + // normally and simply omits the header that would let the browser hand the + // body to the page. Asserting the status stays 200 is what tells the two + // apart. + it('answers a disallowed origin with 200 and no Access-Control-Allow-Origin', async () => { + const app = createServer({ ledger: ledgerFrom({}), config }) + const res = await request(app).get('/healthz').set('Origin', DISALLOWED_ORIGIN) + expect(res.status).toBe(200) + expect(res.headers['access-control-allow-origin']).toBeUndefined() + }) + + // A simple request is not preflighted, so nothing stops it: it is delivered, + // routed, and served off the ledger like any other, and only the browser + // withholds the body from the page. Counting the ledger read is what shows + // the work was done, which /healthz above cannot: an operator cannot tell a + // refused origin from an allowed one by watching the service. + it('serves a disallowed origin in full, refusing it only in the browser', async () => { + const base = ledgerFrom({ [config.instrumentConfigTemplateId]: [cfgEntry()] }) + let reads = 0 + const ledger = { + ...base, + activeContracts: (templateId: string, party: string) => { + reads += 1 + return base.activeContracts(templateId, party) + }, + } + const res = await request(createServer({ ledger, config })) + .get('/registry/metadata/v1/instruments') + .set('Origin', DISALLOWED_ORIGIN) + expect(res.status).toBe(200) + expect(res.body.instruments).toHaveLength(1) + expect(reads).toBe(1) + expect(res.headers['access-control-allow-origin']).toBeUndefined() + }) + + it('answers a preflight from an allowed origin with 204 and no Allow header', async () => { + const app = createServer({ ledger: ledgerFrom({}), config }) + const res = await request(app) + .options('/registry/transfer-instruction/v1/transfer-factory') + .set('Origin', ALLOWED_ORIGIN) + .set('Access-Control-Request-Method', 'POST') + expect(res.status).toBe(204) + expect(res.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN) + // The exact list, not a substring: the four vendored specs declare only GET + // and POST operations and express serves HEAD for every GET, so advertising + // PUT, PATCH or DELETE would name methods no route answers. Asserting only + // that POST is present cannot see that. + expect(res.headers['access-control-allow-methods']).toBe('GET,HEAD,POST,OPTIONS') + // express's default OPTIONS handler is what sets Allow; its absence is + // what proves cors answered the preflight itself, ahead of routing. + expect(res.headers.allow).toBeUndefined() + }) + + // Every factory route is a POST, so every factory call preflights, and a + // browser caches a preflight carrying no max-age for a few seconds at most. + // Without this the dApp pays two round trips for each call it makes. + it('lets a browser cache the preflight', async () => { + const app = createServer({ ledger: ledgerFrom({}), config }) + const res = await request(app) + .options('/registry/transfer-instruction/v1/transfer-factory') + .set('Origin', ALLOWED_ORIGIN) + .set('Access-Control-Request-Method', 'POST') + expect(res.headers['access-control-max-age']).toBe('600') + }) + + // Left at the cors default, this echoes whatever the request asks for, which + // advertises headers no handler reads. Nothing in src/ reads a request header + // at all; the body parser and the validators need only the content type. + it('advertises only the request header the service reads', async () => { + const app = createServer({ ledger: ledgerFrom({}), config }) + const res = await request(app) + .options('/registry/transfer-instruction/v1/transfer-factory') + .set('Origin', ALLOWED_ORIGIN) + .set('Access-Control-Request-Method', 'POST') + .set('Access-Control-Request-Headers', 'content-type, authorization') + expect(res.headers['access-control-allow-headers']).toBe('Content-Type') + }) + + it('answers a preflight from a disallowed origin with 204 and no Access-Control-Allow-Origin', async () => { + const app = createServer({ ledger: ledgerFrom({}), config }) + const res = await request(app) + .options('/registry/transfer-instruction/v1/transfer-factory') + .set('Origin', DISALLOWED_ORIGIN) + .set('Access-Control-Request-Method', 'POST') + expect(res.status).toBe(204) + expect(res.headers['access-control-allow-origin']).toBeUndefined() + }) + + // The important test: a browser can only show the validator's own rejection + // message if that rejection is itself readable cross-origin, which is true + // only while the CORS layer runs ahead of the OpenAPI validators. + it('carries Access-Control-Allow-Origin on a request the OpenAPI validator rejects', async () => { + const app = createServer({ ledger: ledgerFrom({}), config }) + const res = await request(app) + .get('/registry/metadata/v1/instruments?pageSize=abc') + .set('Origin', ALLOWED_ORIGIN) + expect(res.status).toBe(400) + expect(res.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN) + }) + + // Same argument, pinned against express.json() instead of the validators: + // its malformed-body 400 is raised the same way, so it has to be reachable + // cross-origin too. + it('carries Access-Control-Allow-Origin on a malformed JSON body', async () => { + const app = createServer({ ledger: ledgerFrom({}), config }) + const res = await request(app) + .post('/registry/transfer-instruction/v1/transfer-factory') + .set('Origin', ALLOWED_ORIGIN) + .set('content-type', 'application/json') + .send('{"choiceArguments":') + expect(res.status).toBe(400) + expect(res.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN) + }) + + // cors terminates every OPTIONS, not only preflights and not only on paths + // that route, so a path the service does not serve answers 204 here where + // express used to 404. The GET is what still reports the path as missing, + // and pinning both is what keeps the difference deliberate. + it('answers OPTIONS on an unrouted path with 204, whose GET still 404s', async () => { + const app = createServer({ ledger: ledgerFrom({}), config }) + const preflight = await request(app).options('/no/such/path') + expect(preflight.status).toBe(204) + expect(preflight.headers['access-control-allow-methods']).toBe('GET,HEAD,POST,OPTIONS') + const get = await request(app).get('/no/such/path') + expect(get.status).toBe(404) + }) + + // The one option whose value is its absence, on both origin modes. The + // reference service this configuration was modelled on allows credentials + // with the same reflected-origin line, and allowing them here would make any + // page a credentialed reader of this service under a "*" entry, so the + // omission is pinned rather than left to whoever edits those options next. + it.each([ + [config.corsOrigins], + [['*']], + ])('allows no credentials with corsOrigins %j', async (corsOrigins) => { + const app = createServer({ ledger: ledgerFrom({}), config: { ...config, corsOrigins } }) + const get = await request(app).get('/healthz').set('Origin', ALLOWED_ORIGIN) + expect(get.status).toBe(200) + expect(get.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN) + expect(get.headers['access-control-allow-credentials']).toBeUndefined() + const preflight = await request(app) + .options('/registry/transfer-instruction/v1/transfer-factory') + .set('Origin', ALLOWED_ORIGIN) + .set('Access-Control-Request-Method', 'POST') + expect(preflight.status).toBe(204) + expect(preflight.headers['access-control-allow-credentials']).toBeUndefined() + }) + + it('reflects whatever origin asks when corsOrigins is ["*"]', async () => { + const app = createServer({ ledger: ledgerFrom({}), config: { ...config, corsOrigins: ['*'] } }) + const res = await request(app).get('/healthz').set('Origin', 'http://anything.example') + expect(res.status).toBe(200) + expect(res.headers['access-control-allow-origin']).toBe('http://anything.example') + }) +}) diff --git a/registry/test/e2e/helpers/fixture.ts b/registry/test/e2e/helpers/fixture.ts index 15ad5b6..3b0fecc 100644 --- a/registry/test/e2e/helpers/fixture.ts +++ b/registry/test/e2e/helpers/fixture.ts @@ -56,6 +56,7 @@ export async function setupInstrument(): Promise { port: 0, shutdownTimeoutMs: 8_000, directTransferMarginMs: 30_000, + corsOrigins: ['http://localhost:3012'], } const ledger = new HttpLedgerClient(config) diff --git a/registry/test/helpers/fixtures.ts b/registry/test/helpers/fixtures.ts index 16d3d8c..38a725f 100644 --- a/registry/test/helpers/fixtures.ts +++ b/registry/test/helpers/fixtures.ts @@ -27,6 +27,9 @@ export const config: Config = { port: 0, shutdownTimeoutMs: 8_000, directTransferMarginMs: 30_000, + // Deliberately not the production default: a binding that ignores this + // field and hardcodes DEFAULT_CORS_ORIGINS would otherwise pass by luck. + corsOrigins: ['http://allowed.example'], } export const instrumentId: InstrumentIdValue = { admin: 'admin::1', id: 'CC' }