@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 +21 -0
- package/integration.mjs +37 -0
- package/package.json +1 -1
- package/src/lib/origin-check.ts +54 -0
- package/src/middleware.ts +5 -0
- package/src/virtual.d.ts +2 -0
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
|
@@ -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;
|