@tokenoftrust/storefront-runner 2.2.37 → 2.2.38
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/apps/storefront/astro.config.mjs +5 -0
- package/apps/storefront/src/lib/cdnForward.ts +85 -0
- package/apps/storefront/src/lib/tenantRouting.ts +17 -0
- package/apps/storefront/src/middleware/index.ts +20 -10
- package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +2 -2
- package/package.json +1 -1
|
@@ -86,6 +86,11 @@ export default defineConfig({
|
|
|
86
86
|
// inline-style allowance for elements — every stylesheet is a 'self' <link>.
|
|
87
87
|
// Small first-paint tradeoff, offset by cross-page caching (see LIMITATIONS).
|
|
88
88
|
build: { inlineStylesheets: "never" },
|
|
89
|
+
// The cross-site form check runs in the front door (src/middleware/index.ts,
|
|
90
|
+
// lib/cdnForward.ts `crossSiteFormRefusal`) with the same rule, against the
|
|
91
|
+
// origin the viewer used. Astro's own check compares with the request URL, which
|
|
92
|
+
// for a CloudFront-forwarded Tier 3 request is this Worker's host, not the viewer's.
|
|
93
|
+
security: { checkOrigin: false },
|
|
89
94
|
adapter: cloudflare({
|
|
90
95
|
// Local image/asset placeholders are served from /public; real product
|
|
91
96
|
// images get rehosted to R2 in Phase 1 ETL (see DECISIONS.md).
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Requests a tenant's CloudFront distribution forwards to this Worker on its
|
|
3
|
+
* path-scoped Tier 3 behaviors (ADR 0013: sign-in, the commerce and compliance
|
|
4
|
+
* APIs, search, the dashboard). The public static path never reaches here.
|
|
5
|
+
*
|
|
6
|
+
* CloudFront supplies three headers a viewer cannot set: the tenant id and an
|
|
7
|
+
* HMAC over it are origin custom headers (CloudFront replaces any viewer-sent
|
|
8
|
+
* header of the same name), and the viewer's Host is copied by the behavior's
|
|
9
|
+
* viewer-request function, which overwrites any viewer-sent value. The HMAC is
|
|
10
|
+
* the only trust anchor: without a valid one none of the three is read and the
|
|
11
|
+
* request is routed by its own Host like any other.
|
|
12
|
+
*
|
|
13
|
+
* The node-side mirror that computes the header value for a distribution is
|
|
14
|
+
* scripts/publish/lib/worker-tier3.mjs `cdnOriginAuthValue`; both are pinned to
|
|
15
|
+
* one test vector.
|
|
16
|
+
*/
|
|
17
|
+
import { timingSafeEqual } from "@/lib/activity/ingestAuth";
|
|
18
|
+
|
|
19
|
+
export const CDN_TENANT_HEADER = "x-tot-cdn-tenant";
|
|
20
|
+
export const CDN_AUTH_HEADER = "x-tot-cdn-origin-auth";
|
|
21
|
+
export const CDN_VIEWER_HOST_HEADER = "x-tot-viewer-host";
|
|
22
|
+
const AUTH_VERSION = "v1";
|
|
23
|
+
|
|
24
|
+
export interface CdnForward {
|
|
25
|
+
tenantId: string;
|
|
26
|
+
/** The Host the viewer addressed — the public origin the request belongs to. */
|
|
27
|
+
viewerHost: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const HOST_SHAPE = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\d{1,5})?$/i;
|
|
31
|
+
const TENANT_SHAPE = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i;
|
|
32
|
+
|
|
33
|
+
/** `v1.<hex HMAC-SHA256(secret, "cdn-origin:v1:" + tenantId)>`. */
|
|
34
|
+
export async function cdnOriginAuthValue(secret: string, tenantId: string): Promise<string> {
|
|
35
|
+
const encoder = new TextEncoder();
|
|
36
|
+
const key = await crypto.subtle.importKey(
|
|
37
|
+
"raw",
|
|
38
|
+
encoder.encode(secret),
|
|
39
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
40
|
+
false,
|
|
41
|
+
["sign"],
|
|
42
|
+
);
|
|
43
|
+
const mac = await crypto.subtle.sign("HMAC", key, encoder.encode(`cdn-origin:${AUTH_VERSION}:${tenantId}`));
|
|
44
|
+
const hex = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
45
|
+
return `${AUTH_VERSION}.${hex}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The authenticated CDN forward this request carries, or null. Never throws. */
|
|
49
|
+
export async function verifyCdnForward(
|
|
50
|
+
headers: Headers,
|
|
51
|
+
secret: string | undefined,
|
|
52
|
+
): Promise<CdnForward | null> {
|
|
53
|
+
if (!secret) return null;
|
|
54
|
+
const tenantId = headers.get(CDN_TENANT_HEADER)?.trim().toLowerCase() ?? "";
|
|
55
|
+
const presented = headers.get(CDN_AUTH_HEADER)?.trim() ?? "";
|
|
56
|
+
const viewerHost = headers.get(CDN_VIEWER_HOST_HEADER)?.trim().toLowerCase() ?? "";
|
|
57
|
+
if (!tenantId || !presented || !TENANT_SHAPE.test(tenantId) || !HOST_SHAPE.test(viewerHost)) return null;
|
|
58
|
+
try {
|
|
59
|
+
if (!timingSafeEqual(presented, await cdnOriginAuthValue(secret, tenantId))) return null;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return { tenantId, viewerHost };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const FORM_CONTENT_TYPES = ["application/x-www-form-urlencoded", "multipart/form-data", "text/plain"];
|
|
67
|
+
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Astro's cross-site form check, against the origin the viewer actually used.
|
|
71
|
+
* Astro compares `Origin` with the request URL, which for a CDN-forwarded request
|
|
72
|
+
* is this Worker's own host, so the front door runs this instead with the viewer's
|
|
73
|
+
* origin (astro.config.mjs turns the built-in check off). Same rule otherwise: an
|
|
74
|
+
* unsafe method with a form-like or absent content type must come from `expectedOrigin`.
|
|
75
|
+
*/
|
|
76
|
+
export function crossSiteFormRefusal(request: Request, expectedOrigin: string): Response | null {
|
|
77
|
+
if (SAFE_METHODS.has(request.method)) return null;
|
|
78
|
+
if (request.headers.get("origin") === expectedOrigin) return null;
|
|
79
|
+
const contentType = request.headers.get("content-type");
|
|
80
|
+
if (contentType !== null) {
|
|
81
|
+
const lower = contentType.toLowerCase();
|
|
82
|
+
if (!FORM_CONTENT_TYPES.some((type) => lower.includes(type))) return null;
|
|
83
|
+
}
|
|
84
|
+
return new Response(`Cross-site ${request.method} form submissions are forbidden`, { status: 403 });
|
|
85
|
+
}
|
|
@@ -54,6 +54,23 @@ function normalizePath(path: string): string {
|
|
|
54
54
|
return trimmed === "" ? "/" : trimmed;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Routing for an authenticated CDN forward (lib/cdnForward.ts). The forward names
|
|
59
|
+
* its tenant outright and renders at the apex, as the tenant's own host would.
|
|
60
|
+
* Only a tenant served from CloudFront static storage (`serving.provider:
|
|
61
|
+
* "s3-overwrite"`) has a distribution that forwards here; any other resolves to
|
|
62
|
+
* no tenant, which refuses the request.
|
|
63
|
+
*/
|
|
64
|
+
export async function resolveCdnForwardRouting(
|
|
65
|
+
forward: { tenantId: string },
|
|
66
|
+
pathname: string,
|
|
67
|
+
resolver: TenantResolver,
|
|
68
|
+
): Promise<TenantRouting> {
|
|
69
|
+
const tenant = await resolver.resolveStrict(forward.tenantId);
|
|
70
|
+
const served = tenant && tenant.tenant_id === forward.tenantId && tenant.serving?.provider === "s3-overwrite";
|
|
71
|
+
return { tenant: served ? tenant : null, basePath: "", renderPath: normalizePath(pathname) };
|
|
72
|
+
}
|
|
73
|
+
|
|
57
74
|
export async function resolveTenantRouting(
|
|
58
75
|
host: string,
|
|
59
76
|
pathname: string,
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
import { defineMiddleware } from "astro:middleware";
|
|
23
23
|
import { getTenantResolver } from "@/config/resolver";
|
|
24
24
|
import { readEnv, readKv, readR2 } from "@/lib/runtimeEnv";
|
|
25
|
-
import { resolveTenantRouting } from "@/lib/tenantRouting";
|
|
25
|
+
import { resolveCdnForwardRouting, resolveTenantRouting } from "@/lib/tenantRouting";
|
|
26
|
+
import { crossSiteFormRefusal, verifyCdnForward } from "@/lib/cdnForward";
|
|
26
27
|
import { compileRouteTierIndexV1, type RouteTierIndexV1 } from "@tot/public-runtime";
|
|
27
28
|
import type { PublicationArtifactBucketV1, TenantConfig } from "@tot/public-runtime";
|
|
28
29
|
import { DEV_DRAFT_COOKIE } from "@/lib/dev/devDraft";
|
|
@@ -67,7 +68,7 @@ function routeTierIndexFor(tenant: TenantConfig): RouteTierIndexV1 {
|
|
|
67
68
|
export const onRequest = defineMiddleware(async (context, next) => {
|
|
68
69
|
const { request, locals } = context;
|
|
69
70
|
const url = new URL(request.url);
|
|
70
|
-
|
|
71
|
+
let host =
|
|
71
72
|
request.headers.get("host") ?? url.host ?? "localhost";
|
|
72
73
|
|
|
73
74
|
// ---- Activity telemetry kill switch -----------------------------
|
|
@@ -102,6 +103,17 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
102
103
|
return maintenanceResponse();
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
// ---- CDN-forwarded Tier 3 request (ADR 0013) ------------------------------
|
|
107
|
+
// A static tenant's CloudFront distribution routes its Tier 3 prefixes here.
|
|
108
|
+
// The authenticated forward names the tenant and the viewer's Host; every
|
|
109
|
+
// host-keyed decision below (gates, canonical base, the origin check) then sees
|
|
110
|
+
// the host the viewer addressed, never this Worker's own.
|
|
111
|
+
const cdn = await verifyCdnForward(request.headers, await readEnv("CDN_ORIGIN_SECRET"));
|
|
112
|
+
if (cdn) host = cdn.viewerHost;
|
|
113
|
+
const origin = cdn ? `https://${cdn.viewerHost}` : url.origin;
|
|
114
|
+
const crossSite = crossSiteFormRefusal(request, origin);
|
|
115
|
+
if (crossSite) return crossSite;
|
|
116
|
+
|
|
105
117
|
// Demo access gate for named hosts (DEMO_GATE_HOSTS). Two modes, switched by
|
|
106
118
|
// DEMO_GATE_MODE:
|
|
107
119
|
// "password" (default) — interim Basic-Auth gate, enforced HERE (host-level,
|
|
@@ -124,11 +136,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
124
136
|
// selects one (DNS-free tenant selection on localhost/preview). The resolver
|
|
125
137
|
// is KV-backed in prod (many-tenant) and static in plain dev.
|
|
126
138
|
const resolver = await getTenantResolver();
|
|
127
|
-
const { tenant, basePath, renderPath } =
|
|
128
|
-
|
|
129
|
-
url.pathname,
|
|
130
|
-
resolver,
|
|
131
|
-
);
|
|
139
|
+
const { tenant, basePath, renderPath } = cdn
|
|
140
|
+
? await resolveCdnForwardRouting(cdn, url.pathname, resolver)
|
|
141
|
+
: await resolveTenantRouting(host, url.pathname, resolver);
|
|
132
142
|
if (!tenant) {
|
|
133
143
|
return new Response("Unknown host", { status: 404 });
|
|
134
144
|
}
|
|
@@ -153,7 +163,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
153
163
|
status: 308,
|
|
154
164
|
headers: {
|
|
155
165
|
location: publicationRedirectLocationV1({
|
|
156
|
-
origin
|
|
166
|
+
origin,
|
|
157
167
|
basePath,
|
|
158
168
|
plan: publication,
|
|
159
169
|
search: url.search,
|
|
@@ -193,7 +203,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
193
203
|
status: 308,
|
|
194
204
|
headers: {
|
|
195
205
|
location: publicationRedirectLocationV1({
|
|
196
|
-
origin
|
|
206
|
+
origin,
|
|
197
207
|
basePath,
|
|
198
208
|
plan: publication,
|
|
199
209
|
search: url.search,
|
|
@@ -268,7 +278,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
268
278
|
});
|
|
269
279
|
if (bundled) return bundled;
|
|
270
280
|
const rootLocation = platformStaticRootLocationV1({
|
|
271
|
-
origin
|
|
281
|
+
origin,
|
|
272
282
|
basePath,
|
|
273
283
|
renderPath,
|
|
274
284
|
search: url.search,
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* (re-unsubscribing is a no-op success) and NON-LEAKING: an unknown token gets the exact
|
|
15
15
|
* same response as a real one, so an observer can't probe which tokens exist.
|
|
16
16
|
*
|
|
17
|
-
* NOTE (integration):
|
|
18
|
-
*
|
|
17
|
+
* NOTE (integration): The front door.s cross-site form check (lib/cdnForward.ts `crossSiteFormRefusal`,
|
|
18
|
+
* the rule Astro.s `security.checkOrigin` applies) 403s a cross-origin form-encoded POST before it reaches this handler, which
|
|
19
19
|
* would break the RFC 8058 one-click POST from a mailbox provider. See the unit report.
|
|
20
20
|
*/
|
|
21
21
|
import type { APIContext } from "astro";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.38",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|