@acms-subir/core 0.3.48 → 0.3.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.49
4
+
5
+ ### Patch Changes
6
+
7
+ - a25078e: Amazon SES delivery webhooks and the apps now reach the site. Astro's cross-site form check (`security.checkOrigin`, on by default) refused them on every built site with `403 Cross-site POST form submissions are forbidden`, before ACMS saw the request. It doesn't run in `astro dev`, which is why nobody noticed:
8
+
9
+ - **SES:** Amazon SNS posts to `/api/email/webhooks/ses` as `text/plain` without an Origin header. SES deliveries, bounces and complaints were never recorded, bounced addresses weren't suppressed, and the SNS subscription could never be confirmed. Resend and Mailgun send JSON and weren't affected.
10
+ - **Apps:** apps send no Origin either, so every app request with no body, or a form body, was refused. With the published `@acms-subir/client`, that's turning notifications off (`unregisterDevice`, so the phone stayed registered and kept getting pushes), unlinking a staff phone, cancelling or confirming a booking, and guest sign-out. Editor apps couldn't sign in, refresh or sign out, upload media, or delete a resource, booking link, time block or calendar.
11
+
12
+ ACMS now runs the check itself, first thing in its middleware, and turns Astro's copy off. Anything a page on another site can post with a visitor's cookies gets exactly Astro's answer, as before. That covers a POST whose Origin isn't the site's, or that has none, when its body is a form, `text/plain` or missing, to admin pages, Actions, public forms, member pages and the APIs. A unit test compares the two rules on every combination of method, Origin, Content-Type, headers and route that such a page can send. What goes through now:
13
+
14
+ - requests a browser only sends to another site after asking it first (PUT, PATCH and DELETE, and requests with `Authorization: Bearer …` or `Booking-Token`). ACMS's CORS answers never let cookies ride along. Basic auth doesn't count, since browsers add it to form posts on their own.
15
+ - `/api/email/webhooks/[provider]`, which checks the provider's signature.
16
+ - the editor apps' OAuth form posts to `/api/v1/auth/token` and `/api/v1/auth/revoke`, which carry their own code or token and never use cookies.
17
+
18
+ The check now also runs in `astro dev`, so local work and the e2e suites see what a built site does. A site that set `security: { checkOrigin: false }` in `astro.config` keeps it off everywhere. If a dev server behind a proxy starts refusing your own admin forms, the proxy's Host doesn't match the browser's Origin.
19
+
20
+ Sites with their own signature-checked webhooks (an SMS or payment provider that posts form data) can list those route patterns in `acms({ signedRoutes: ['/api/sms/[event]'] })`. Admin pages, Astro's own routes and ACMS's routes can't be listed.
21
+
22
+ After updating, nothing needs to change, and apps work again without an update. On a site that uses SES, check the SNS topic's subscription to `https://<site>/api/email/webhooks/ses`. If it isn't "Confirmed", choose **Request confirmation**, or create it again if SNS has removed it. The site now confirms it.
23
+
3
24
  ## 0.3.48
4
25
 
5
26
  ### Patch Changes
package/integration.mjs CHANGED
@@ -465,6 +465,22 @@ function resolveAnnouncements(announcements, reserved) {
465
465
  if (announcements.pages !== undefined && typeof announcements.pages !== 'boolean') throw new Error('acms: announcements.pages must be true or false');
466
466
  return { enabled: true, base, pages: announcements.pages ?? true, topics: { ...topics } };
467
467
  }
468
+ // acms({ signedRoutes }): the site's own routes that take posts from other
469
+ // servers and check the sender's signature themselves, so the cross-site
470
+ // form check skips them (lib/origin-check.ts). Route patterns as Astro writes
471
+ // them. Admin pages, Astro's own routes (/_actions…) and core's routes can't
472
+ // be listed (acms() checks the last against its route table).
473
+ function resolveSignedRoutes(routes) {
474
+ if (routes === undefined) return [];
475
+ if (!Array.isArray(routes) || routes.length > 20) throw new Error('acms: signedRoutes must be a list of at most 20 route patterns, e.g. [\'/api/sms/[event]\']');
476
+ return routes.map((r, i) => {
477
+ if (!isSitePath(r) || /[?#*]/.test(r) || r.endsWith('/')) {
478
+ throw new Error(`acms: signedRoutes[${i}] must be a route pattern such as "/api/sms/[event]" (no query, wildcard or trailing slash) — got "${r}"`);
479
+ }
480
+ if (/^\/(admin|_)/.test(r)) throw new Error(`acms: signedRoutes[${i}] "${r}" is an admin or Astro route — those always keep the cross-site check`);
481
+ return r;
482
+ });
483
+ }
468
484
  function resolveRoles(roles, members) {
469
485
  if (roles === undefined) return [];
470
486
  if (!Array.isArray(roles)) throw new Error('acms: roles must be an array of { key, label } objects');
@@ -691,6 +707,15 @@ function acmsVitePlugin(userConfig) {
691
707
  * `topics` picks the push topic each kind starts with in the composer, e.g.
692
708
  * `{ registration: 'registrations', help: 'help' }` (otherwise a topic named
693
709
  * after the kind, such as `events` or `help`, else everyone).
710
+ * @param {string[]} [options.signedRoutes]
711
+ * The site's own routes that receive posts from another server and check
712
+ * its signature themselves (an SMS or payment provider's webhook), as route
713
+ * patterns, e.g. `['/api/sms/[event]']`. ACMS refuses cross-site form posts
714
+ * the way Astro's `security.checkOrigin` does (it takes that check over);
715
+ * these routes skip it, since such senders send no Origin header and often
716
+ * post form data or text/plain. List only routes that verify a signature.
717
+ * Admin pages, Actions and ACMS's own routes can't be listed; ACMS's email
718
+ * webhooks already skip it.
694
719
  * @returns {import('astro').AstroIntegration}
695
720
  */
696
721
  export default function acms(options = {}) {
@@ -713,6 +738,7 @@ export default function acms(options = {}) {
713
738
  const roles = resolveRoles(options.roles, members);
714
739
  const permissions = resolvePermissions(options.permissions, roles);
715
740
  const maps = resolveMaps(options.maps);
741
+ const signedRoutes = resolveSignedRoutes(options.signedRoutes);
716
742
  const legal = { enabled: true, operatorName: '', contactEmail: '', address: '', country: '', updatedAt: '', ...(options.legal === false ? { enabled: false } : options.legal ?? {}) };
717
743
  if (typeof legal.enabled !== 'boolean' || ['operatorName','contactEmail','address','country','updatedAt'].some(k => typeof legal[k] !== 'string')) throw new Error('acms: invalid legal configuration');
718
744
  legal.country = legal.country.trim().toUpperCase();
@@ -909,6 +935,9 @@ export default function acms(options = {}) {
909
935
  [`${members.base}/events/[id]`, 'members/events/[id].astro'],
910
936
  );
911
937
  }
938
+ // core's own routes keep the check (its signed webhooks are listed in lib/origin-check.ts)
939
+ const builtIn = signedRoutes.find((r) => routes.some(([pattern]) => pattern === r));
940
+ if (builtIn) throw new Error(`acms: signedRoutes "${builtIn}" is an ACMS route — list only the site's own routes`);
912
941
  let cacheDir = '';
913
942
  return {
914
943
  name: 'acms',
@@ -920,6 +949,14 @@ export default function acms(options = {}) {
920
949
  mkdirSync(discoveryDir, {recursive:true});
921
950
  writeFileSync(join(discoveryDir,'acms-project.json'), JSON.stringify({ themeApiVersion:1, collections:userConfig.content.collections, calendar:userConfig.calendar, mobile:userConfig.mobile ?? {}, roles:userConfig.roles, permissions:userConfig.permissions, members:userConfig.members }));
922
951
  }
952
+ // Astro's CSRF origin check (security.checkOrigin, on by default) runs
953
+ // before any middleware and no route can opt out, so signed webhooks
954
+ // from other servers and the apps got its 403. Core's middleware runs
955
+ // the same rule for what a browser can send instead (lib/origin-check.ts).
956
+ // A site that turned the check off in astro.config keeps it off.
957
+ const originCheck = config.security?.checkOrigin !== false;
958
+ userConfig.originCheck = { enabled: originCheck, signedRoutes };
959
+ if (originCheck) updateConfig({ security: { checkOrigin: false } });
923
960
  updateConfig({ vite: { plugins: [acmsVitePlugin(userConfig)] } });
924
961
 
925
962
  // Installed from npm, core's admin islands are source files inside
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.48",
3
+ "version": "0.3.49",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,54 @@
1
+ import { userConfig } from 'virtual:acms-config';
2
+
3
+ // Astro's CSRF origin check (security.checkOrigin), taken over by core.
4
+ // Astro's own copy runs before any middleware and no route can opt out of it,
5
+ // so requests that never come from a browser on another site were refused
6
+ // too: Amazon SNS posts SES bounces as text/plain with no Origin header, and
7
+ // the apps send none either. integration.mjs turns Astro's copy off and the
8
+ // middleware runs this one first instead, in dev as well as on built sites.
9
+ //
10
+ // The rule is Astro 5's for anything a page on another site can send: a POST
11
+ // whose Origin isn't this site's (or that has none) is refused when its body
12
+ // is form-like (form-urlencoded, multipart, text/plain) or it has no
13
+ // Content-Type. JSON and other types need a CORS preflight and pass. What
14
+ // passes besides: the routes below, and requests a browser only sends to
15
+ // another site after a preflight (preflighted()).
16
+
17
+ const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
18
+ const FORM_TYPES = ['application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain'];
19
+
20
+ // Routes whose senders are servers that send no Origin and prove who they are
21
+ // with a signature the route checks itself (lib/email/webhooks.ts), keyed by
22
+ // route pattern as Astro matched it, so a lookalike path never qualifies. A
23
+ // site adds its own with acms({ signedRoutes }).
24
+ export const SIGNED_ROUTES = new Set([
25
+ '/api/email/webhooks/[provider]',
26
+ ...(userConfig.originCheck?.signedRoutes ?? []),
27
+ ]);
28
+
29
+ // The editor apps' OAuth endpoints (RFC 6749): form posts that carry their
30
+ // own code, refresh token or token to revoke, and never use cookies.
31
+ export const TOKEN_ROUTES = new Set(['/api/v1/auth/token', '/api/v1/auth/revoke']);
32
+
33
+ // A request no browser sends to another site without asking it first (a CORS
34
+ // preflight; ACMS never allows credentials in one, so no cookie rides along):
35
+ // any method but POST, or one carrying an app's own credential, a bearer
36
+ // token or a booking token. Basic auth doesn't count: a browser adds that to
37
+ // a form post by itself.
38
+ export function preflighted(request: Request): boolean {
39
+ return request.method !== 'POST' || /^bearer\s/i.test(request.headers.get('authorization') ?? '') || request.headers.has('booking-token');
40
+ }
41
+
42
+ // a site that set security.checkOrigin: false itself keeps the check off
43
+ export const ORIGIN_CHECK = { enabled: userConfig.originCheck?.enabled !== false };
44
+
45
+ type CheckedContext = { request: Request; url: URL; routePattern: string; isPrerendered: boolean };
46
+
47
+ /** Astro's 403 for a cross-site form submission, or null when the request may go on. */
48
+ export function crossSiteRefusal({ request, url, routePattern, isPrerendered }: CheckedContext): Response | null {
49
+ if (!ORIGIN_CHECK.enabled || isPrerendered || SAFE_METHODS.includes(request.method)) return null;
50
+ if (request.headers.get('origin') === url.origin || SIGNED_ROUTES.has(routePattern) || TOKEN_ROUTES.has(routePattern) || preflighted(request)) return null;
51
+ const type = request.headers.get('content-type');
52
+ if (type !== null && !FORM_TYPES.some((form) => type.toLowerCase().includes(form))) return null;
53
+ return new Response(`Cross-site ${request.method} form submissions are forbidden`, { status: 403 });
54
+ }
package/src/middleware.ts CHANGED
@@ -15,6 +15,7 @@ import { processPushQueue, runPushJobs } from './lib/push/service';
15
15
  import { previewParamRedirect, hasPreviewCookie } from './lib/designPreview';
16
16
  import { applyCachePolicy } from './lib/cache/policy';
17
17
  import { bindExecutionContext, syncCachePurge } from './lib/cache/purge';
18
+ import { crossSiteRefusal } from './lib/origin-check';
18
19
 
19
20
  // Legacy content-URL redirects, checked only on would-be 404s (below) so they
20
21
  // can never shadow real content. Tags moved from `${base}/tag/` to a
@@ -81,6 +82,10 @@ export const onRequest = defineMiddleware(async (context, next) =>
81
82
 
82
83
  async function handle(context: APIContext, next: MiddlewareNext): Promise<Response> {
83
84
  const { pathname } = context.url;
85
+ // cross-site form submissions, before anything else runs (Astro's own
86
+ // check, which signed webhooks and the apps couldn't pass: lib/origin-check.ts)
87
+ const refused = crossSiteRefusal(context);
88
+ if (refused) return refused;
84
89
  bindAuthEnvironment(context.request, context.locals);
85
90
  bindExecutionContext(context.request, context.locals);
86
91
 
package/src/virtual.d.ts CHANGED
@@ -24,6 +24,8 @@ declare module 'virtual:acms-config' {
24
24
  permissions?: { key: string; label: string; description: string; defaults: string[]; sensitive: boolean }[];
25
25
  members?: { enabled: boolean; base: string; notify: string[] };
26
26
  announcements?: { enabled: boolean; base: string; pages: boolean; topics: Partial<Record<'event' | 'registration' | 'help' | 'info', string>> };
27
+ // set in astro:config:setup (lib/origin-check.ts): off only when the site turned security.checkOrigin off
28
+ originCheck?: { enabled: boolean; signedRoutes: string[] };
27
29
  // resolved by integration.mjs (lib/calendar/config.ts documents the shape)
28
30
  calendar?: {
29
31
  enabled: boolean;