From 540351a3c62490ac0bd44e00c23218695fb73f86 Mon Sep 17 00:00:00 2001 From: Marc Villain Date: Tue, 3 Nov 2026 06:33:10 +0100 Subject: [PATCH] docs: every write example states its status; security explains sensitive columns and how to serve one --- conformance/pages.mjs | 2 +- docs/security.md | 40 ++++++++++++++++++++++++++++++++++++++++ docs/writes.md | 20 ++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/conformance/pages.mjs b/conformance/pages.mjs index d0d868a..8b05e58 100644 --- a/conformance/pages.mjs +++ b/conformance/pages.mjs @@ -1,5 +1,5 @@ /** The docs pages whose examples are conformance cases, and the options every database runs them with. */ -export const PAGES = ['query-grammar.md', 'writes.md']; +export const PAGES = ['query-grammar.md', 'writes.md', 'security.md']; const PROCEDURES = { 'todos/{todo}/complete': { function: 'demo.complete_todo' }, diff --git a/docs/security.md b/docs/security.md index 9c81df6..deceef5 100644 --- a/docs/security.md +++ b/docs/security.md @@ -5,6 +5,7 @@ restloom decides what can be asked. Your backend decides who may ask it, through ## What restloom enforces - **Exposure:** only the schemas and tables you name are served, and only their visible columns. +- **Sensitive columns:** a column whose name looks like a secret is hidden without being asked, see below. - **Bound values:** every value is a bound parameter, checked against its column's type before any SQL runs. Names come from the catalog and are quoted; nothing from a request is written into SQL. - **Bounded requests:** every request is limited: @@ -21,6 +22,45 @@ A request over a limit is refused, never quietly cut down. - **Errors:** they answer stable codes. A database message never reaches the client. Unexpected errors answer `internal` and reach your `onError` hook. +## Sensitive columns + +restloom reads every column name when it reads the catalog. A name made of a word such as `password`, `hash`, `salt`, `secret`, `token`, `api_key`, `private_key`, `otp`, `mfa` or `recovery`, alone or between underscores, marks the column as sensitive: `password_hash` and `reset_token` are hidden, `tokenizer` is not. The full list is in [Columns](./discovery.md#columns). + +A hidden column is not served at all. It cannot be read, filtered, sorted or written, `select=*` leaves it out, and naming it answers as a column that does not exist, so a client cannot even learn it is there: + +```http +GET /users?select=name,password_hash +``` + +Answer: 400 + +```json +{ "error": "unknown_column", "message": "password_hash" } +``` + +The check runs on names, so it guards against the column nobody thought about when the table grew. It does not know what a column holds: hide a sensitive column with a harmless name yourself, with `hide`. + +To serve a column the check hides on purpose, name it in `show`. It then behaves as any other column, and is writable only if `write` lists it: + +```ts +resources: { + files: { show: ['sha256_hash'] }, + users: { hide: ['phone'] }, +} +``` + +`sensitive` replaces the whole pattern, for every resource. Start from `DEFAULT_SENSITIVE` to add words rather than lose the defaults: + +```ts +import { createREST, DEFAULT_SENSITIVE } from '@crudy/restloom'; + +createREST({ + db, + expose: { schemas: ['app'] }, + sensitive: new RegExp(`${DEFAULT_SENSITIVE.source}|(^|_)(iban|ssn)(_|$)`, 'i'), +}); +``` + ## Hooks ```ts diff --git a/docs/writes.md b/docs/writes.md index 0634252..2e481cc 100644 --- a/docs/writes.md +++ b/docs/writes.md @@ -56,6 +56,8 @@ POST /tags [{ "id": 7, "name": "a" }, { "name": "b" }] ``` +Answer: 400 + ```json { "error": "mixed_columns" } ``` @@ -68,6 +70,8 @@ POST /todos { "name": "book dentist", "status": "open", "pinned": false } ``` +Answer: 400 + ```json { "error": "not_writable", "message": "pinned" } ``` @@ -101,6 +105,8 @@ A row still referenced by another is not deleted: DELETE /tags/3 ``` +Answer: 409 + ```json { "error": "still_referenced" } ``` @@ -109,6 +115,8 @@ DELETE /tags/3 DELETE /todos/1 ``` +Answer: 405 + ```json { "error": "method_not_allowed" } ``` @@ -130,6 +138,8 @@ Answer: 200 DELETE /steps ``` +Answer: 400 + ```json { "error": "filter_required" } ``` @@ -144,6 +154,8 @@ POST /tags { "id": 1, "name": "duplicate key" } ``` +Answer: 409 + ```json { "error": "duplicate" } ``` @@ -154,6 +166,8 @@ POST /steps { "id": 9, "title": "Call the clinic", "todo_id": 99, "priority": 1, "due_on": "2026-04-01" } ``` +Answer: 409 + ```json { "error": "reference_missing" } ``` @@ -170,6 +184,8 @@ POST /todos/3/complete { "note": "bought a new one" } ``` +Answer: 200 + ```json 3 ``` @@ -180,6 +196,8 @@ A function returning a set answers an array of its rows. File taxes belongs to A POST /tags/home/todos ``` +Answer: 200 + ```json [ { "id": 2, "name": "file taxes", "status": "open", "owner_id": 1, "estimate": 1, "pinned": true, "created_at": "2026-01-05T09:30:00+00:00" }, @@ -193,6 +211,8 @@ POST /todos/3/complete { "why": "too busy" } ``` +Answer: 400 + ```json { "error": "unknown_argument", "message": "why" } ```