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
25 changes: 25 additions & 0 deletions .changeset/22826-refuse-body-only-signature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/trigger-api': major
'@objectstack/spec': patch
---

feat(trigger-api)!: the inbound hook door refuses the body-only `x-objectstack-signature` form, so only the timestamped, replay-protected form verifies

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) Nothing an ObjectStack author writes moves: no spec key, metadata shape, authorable spelling, stored row, type or export signature is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a runtime HTTP door's accept set: a signature value a third-party sender computes, outside any project's metadata or code. The sending side's guidance already teaches the one accepted form, and this changeset points to it rather than prescribing a rewrite. The other categories are closed on facts: both bumped packages publish (not unpublished); no ADR-0087 id covers this door and this diff adds none (not registered / already-registered); and no TypeScript declaration moves, since `verifySignature` keeps its signature and only its answer for one input narrows (not runtime-interface-only / type-surface-only). -->

**BREAKING**: an accept-set narrowing on a runtime door, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`).

**What narrows, from → to.**

- `POST /api/v1/automation/hooks/:flowName/:hookId`, through the self-hosted mount and through `ITriggerApiService.handleInboundHook` alike. A post whose `x-objectstack-signature` was the body-only value (`sha256=` and the hex HMAC-SHA256 of the raw body under the flow's secret) was accepted, `202` and enqueued, with a deprecation `warn` line once per armed hook. It now gets the same `401 INVALID_SIGNATURE` every post that does not verify gets, and nothing is enqueued, even when the value is the secret's HMAC of the body. The body-only form signs no time, so it carries no replay protection.
- The one accepted form is the timestamped one: `t=<unix seconds>,v1=<hex>`, the HMAC-SHA256 of `<t>.<raw body>` under the flow's secret, refused when `t` is more than 300 seconds from the server clock.
- The deprecation line is gone. The refusal's `warn` line carries the cause `signature-body-only`, beside `unknown-flow`, `wrong-hook-id`, `signature-absent`, `signature-malformed`, `signature-mismatch` and `signature-outside-window`. It never carries the secret, the signature value or the body.
- `verifySignature(secret, rawBody, header)`, exported by `@objectstack/trigger-api`, answers as the door does: `true` only for the timestamped form inside its window, `false` for a body-only value. Its signature is unchanged.

**Unchanged: the outbound contract.** `@objectstack/core`'s `verifyHttpSignature` still verifies both forms, and `signHttpBody` still writes the body-only value: the messaging outbox and the flow `http` node sign it for third-party receivers, as the outbound webhook contract publishes. Only this inbound door refuses it.

**Who sees a difference.** A sender that signs the timestamped form, as the inbound-hook guidance teaches (the `objectstack-automation` skill's Inbound webhook section, `os explain flow-api-trigger-secret-missing`, `signHttpBodyAt` in `@objectstack/core`), sees none. A sender still on the body-only value now gets `401`, and the trigger's `warn` line for its post names `signature-body-only`, which tells an operator its secret is right and its signing form is the retired one.

**`@objectstack/spec`.** The `ITriggerApiService.handleInboundHook` docblock names the timestamped form as the only one accepted and the body-only form as refused. The type is unchanged.
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,11 @@ function inbound(name: string, secrets: { hook?: string; sign?: string } = {}, l
};
}

const sign = (secret: string, body: string) => 'sha256=' + createHmac('sha256', secret).update(body, 'utf8').digest('hex');
/** The timestamped `x-objectstack-signature`, the one form the inbound door accepts, signed now from the published recipe. */
const sign = (secret: string, body: string) => {
const t = Math.floor(Date.now() / 1000);
return `t=${t},v1=` + createHmac('sha256', secret).update(`${t}.${body}`, 'utf8').digest('hex');
};
const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : Array.isArray(r?.value) ? r.value : []);
const hasAny = (v: unknown, secrets: readonly string[]) => secrets.some((s) => JSON.stringify(v ?? null).includes(s));

Expand Down
25 changes: 15 additions & 10 deletions packages/runtime/src/domains/automation-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
* is a test double, because the real one is a separate card (#22774), but its
* verifier is not: it reads the raw body off the `Request` it is handed and
* checks the `x-objectstack-signature` with `verifyHttpSignature`, the very
* function `ApiTrigger.handleRequest` verifies with (`@objectstack/core`), and
* answers every post that does not verify with the trigger's one refusal. So a
* function `ApiTrigger.handleRequest` verifies with (`@objectstack/core`),
* accepts the timestamped form only, as that door does, and answers every
* post that does not verify with the trigger's one refusal. So a
* domain that handed it anything but the transport's own unread request, or a
* re-serialised body, would be caught by the verifier, not by an assertion
* about plumbing.
Expand Down Expand Up @@ -43,7 +44,7 @@
*/

import { describe, it, expect, vi } from 'vitest';
import { isAuthGateAllowlisted, matchInboundHookRoute, signHttpBody, verifyHttpSignature } from '@objectstack/core';
import { isAuthGateAllowlisted, matchInboundHookRoute, signHttpBodyAt, verifyHttpSignature } from '@objectstack/core';
import { HttpDispatcher } from '../http-dispatcher.js';
import type { HttpProtocolContext, KernelResolver } from '../http-dispatcher.js';
import { createDispatcherPlugin } from '../dispatcher-plugin.js';
Expand All @@ -61,11 +62,15 @@ const JSON_HEADERS = { 'Content-Type': 'application/json' };
const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
const REFUSAL = { success: false, error: { code: 'INVALID_SIGNATURE', message: 'Signature verification failed.' } };

/** The timestamped signature form, the one the inbound door accepts, signed now. */
const sign = (body: string, secret: string) => signHttpBodyAt(body, secret, Math.floor(Date.now() / 1000));

/**
* A `handleInboundHook` member with the trigger's verifier inside it: the hook
* armed for each flow, the raw body read once off the request, the signature
* checked by core's `verifyHttpSignature`, one refusal for every post that does
* not verify, `202` for one that does. It records every call.
* checked by core's `verifyHttpSignature` and accepted in the timestamped form
* only, one refusal for every post that does not verify, `202` for one that
* does. It records every call.
*/
function verifierMember(armed: Record<string, { hookId: string; secret: string }> = { [FLOW]: { hookId: HOOK, secret: SECRET } }) {
const calls: Array<{ request: Request; hook: { flowName: string; hookId: string }; body: string }> = [];
Expand All @@ -77,7 +82,7 @@ function verifierMember(armed: Record<string, { hookId: string; secret: string }
const verdict = arm && arm.hookId === hook.hookId
? verifyHttpSignature(body, arm.secret, request.headers.get('x-objectstack-signature') ?? undefined)
: { valid: false as const };
if (!verdict.valid) return json(401, REFUSAL);
if (!verdict.valid || verdict.scheme !== 'v1') return json(401, REFUSAL);
accepted.push(body);
return json(202, { accepted: true, messageId: `msg_${accepted.length}` });
});
Expand Down Expand Up @@ -112,7 +117,7 @@ const authAs = (user?: { id: string; authGate?: { code: string; message: string
const ctx = (request: Request): HttpProtocolContext => ({ request });
function post(path = ROUTE, opts: { body?: string; signature?: string | null; method?: string } = {}) {
const body = opts.body ?? BODY;
const signature = opts.signature === undefined ? signHttpBody(body, SECRET) : opts.signature;
const signature = opts.signature === undefined ? sign(body, SECRET) : opts.signature;
return new Request(`${ORIGIN}/api/v1${path}`, {
method: opts.method ?? 'POST',
headers: { ...JSON_HEADERS, ...(signature !== null ? { 'x-objectstack-signature': signature } : {}) },
Expand Down Expand Up @@ -151,8 +156,8 @@ describe('[#22773] the inbound hook reaches the verifier on a kernel with no htt
const member = verifierMember();
const { dispatcher } = makeDispatcher({ 'trigger-api': { handleInboundHook: member.handleInboundHook } });
const cases: Array<[string, Request, string]> = [
['wrong secret', post(ROUTE, { signature: signHttpBody(BODY, 'not-the-secret') }), ROUTE],
['body changed after signing', post(ROUTE, { body: `${BODY} `, signature: signHttpBody(BODY, SECRET) }), ROUTE],
['wrong secret', post(ROUTE, { signature: sign(BODY, 'not-the-secret') }), ROUTE],
['body changed after signing', post(ROUTE, { body: `${BODY} `, signature: sign(BODY, SECRET) }), ROUTE],
['unsigned', post(ROUTE, { signature: null }), ROUTE],
['malformed signature', post(ROUTE, { signature: 'sha256=zz' }), ROUTE],
['unknown flow', post('/automation/hooks/no_such_flow/default'), '/automation/hooks/no_such_flow/default'],
Expand All @@ -177,7 +182,7 @@ describe('[#22773] the inbound hook reaches the verifier on a kernel with no htt

const signed = await dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(post()));
expect((signed.result as Response).status).toBe(202);
const forged = await dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(post(ROUTE, { signature: signHttpBody(BODY, 'session-is-no-credential') })));
const forged = await dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(post(ROUTE, { signature: sign(BODY, 'session-is-no-credential') })));
expect((forged.result as Response).status).toBe(401);
expect(member.handleInboundHook).toHaveBeenCalledTimes(2);
});
Expand Down
19 changes: 12 additions & 7 deletions packages/spec/src/contracts/trigger-api-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,19 @@ export interface ITriggerApiService {
* id gets this `503`, signed or not. It stays a `503` on purpose: it is
* the deployment's fault, a sender retries it, and it lasts only while
* the secret cannot be read.
* - It verifies the `x-objectstack-signature` header (`sha256=` and the hex
* HMAC-SHA256 of the raw body under that secret). A missing or wrong
* signature gets the unverified-post refusal below.
* - It verifies the `x-objectstack-signature` header in its timestamped
* form, the only one it accepts: `t=<unix seconds>,v1=<hex>`, where the
* hex is the HMAC-SHA256 of `<t>.<raw body>` under that secret, and a `t`
* more than 300 seconds from the server's clock is refused. A missing or
* wrong signature, one outside that window, and one in the body-only form
* (`sha256=` and the hex HMAC-SHA256 of the raw body alone, which signs
* no time; refused even when it is the secret's HMAC of the body) get the
* unverified-post refusal below.
* - **One refusal for every post that does not verify** (#22806): an
* unknown flow, a wrong hook id, an absent signature and a bad one all
* answer `401 INVALID_SIGNATURE`, with one body. So the answer does not
* tell an armed flow name from an unarmed one, whichever hook id a flow
* is armed with. The cause (which of those four) goes to the server log,
* is armed with. The cause (which of those) goes to the server log,
* ⛔ never to the sender. FROM → TO: an unknown flow and a wrong hook id
* used to answer `404 RESOURCE_NOT_FOUND`; they now answer the same
* `401 INVALID_SIGNATURE` an absent or bad signature always has.
Expand All @@ -88,9 +93,9 @@ export interface ITriggerApiService {
* again differs from them (key order, whitespace, escapes), so it would
* fail verification for every real sender. ⛔ Nor does the caller check,
* pre-filter or short-circuit the signature: there is one verifier, and it
* is behind this member. Anything a later signing scheme adds (a
* timestamp, for one) arrives in the same forwarded request, so this
* signature gains no field for it.
* is behind this member. Anything the signing scheme carries beyond the
* body (the accepted form's timestamp, for one) arrives in the same
* forwarded request, so this signature gains no field for it.
*
* **The signature is the only credential.** This member takes no
* `ExecutionContext` and reads no session, cookie or `Authorization` header:
Expand Down
Loading
Loading