@lunora/runtime 1.0.0-alpha.13 → 1.0.0-alpha.14
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/dist/index.d.mts +41 -15
- package/dist/index.d.ts +41 -15
- package/dist/index.mjs +2 -2
- package/dist/packem_shared/{composeWorker-BOB2YZ6v.mjs → composeWorker-M4mPqTJx.mjs} +66 -41
- package/dist/packem_shared/{decorateResponse-DbISh_Wi.mjs → decorateResponse-HRXo-oOD.mjs} +40 -8
- package/package.json +1 -1
package/dist/index.d.mts
CHANGED
|
@@ -1150,9 +1150,13 @@ declare const createQueryCoordinator: (options: QueryCoordinatorOptions) => Quer
|
|
|
1150
1150
|
interface SecurityHeadersOptions {
|
|
1151
1151
|
/**
|
|
1152
1152
|
* `Content-Security-Policy`. Omitted (`undefined`) applies a restrictive
|
|
1153
|
-
* default to **non-HTML** responses
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
1153
|
+
* `default-src 'none'` policy to **non-HTML** responses, and a conservative
|
|
1154
|
+
* hardening policy to **HTML** responses (`base-uri 'none'; frame-ancestors
|
|
1155
|
+
* 'self'; object-src 'none'`) — this does NOT set `default-src`/`script-src`,
|
|
1156
|
+
* so it never blocks an SSR page's own scripts/styles/images/fetches, it only
|
|
1157
|
+
* locks down the `base` element, framing (mirroring the `SAMEORIGIN`
|
|
1158
|
+
* X-Frame-Options default), and legacy plugins. Pass a string to apply that
|
|
1159
|
+
* exact policy to every response (HTML included); `false` to never send one.
|
|
1156
1160
|
*/
|
|
1157
1161
|
csp?: string | false;
|
|
1158
1162
|
/** `X-Frame-Options` (clickjacking). Defaults to `SAMEORIGIN`. `false` omits it. */
|
|
@@ -1201,7 +1205,7 @@ interface SecurityOptions {
|
|
|
1201
1205
|
interface ResolvedHeaders {
|
|
1202
1206
|
coop: string | undefined;
|
|
1203
1207
|
csp: {
|
|
1204
|
-
|
|
1208
|
+
htmlValue: string | undefined;
|
|
1205
1209
|
value: string;
|
|
1206
1210
|
} | undefined;
|
|
1207
1211
|
enabled: boolean;
|
|
@@ -1264,6 +1268,24 @@ declare const resolveSecurity: (security: SecurityOptions | undefined, env?: Rec
|
|
|
1264
1268
|
* @returns a `403` Response when the origin is untrusted, or `undefined` when the request is allowed.
|
|
1265
1269
|
*/
|
|
1266
1270
|
declare const enforceOrigin: (request: Request, resolved: ResolvedSecurity) => Response | undefined;
|
|
1271
|
+
/**
|
|
1272
|
+
* CSRF defense for the WebSocket upgrade (Cross-Site WebSocket Hijacking).
|
|
1273
|
+
*
|
|
1274
|
+
* A WS handshake is an HTTP `GET`, so {@link enforceOrigin}'s safe-method
|
|
1275
|
+
* exemption never fires for it — yet the browser auto-attaches the session
|
|
1276
|
+
* cookie to the handshake and WebSocket connections are NOT governed by
|
|
1277
|
+
* CORS/SOP. Without an explicit `Origin` check any page can open
|
|
1278
|
+
* `wss://app/_lunora/ws`, authenticate as the logged-in victim, and read the
|
|
1279
|
+
* victim's live queries + issue mutations as them. This guard closes that hole.
|
|
1280
|
+
*
|
|
1281
|
+
* Scoped to cookie-bearing upgrades — the only vector CSWSH can ride (a browser
|
|
1282
|
+
* attaches cookies but never a bearer token). Bearer/token/server-to-server
|
|
1283
|
+
* upgrades (no `Cookie`) are exempt. A browser ALWAYS sends `Origin` on a WS
|
|
1284
|
+
* handshake, so a missing/untrusted `Origin` on a cookie-bearing upgrade fails
|
|
1285
|
+
* closed (mirrors {@link enforceOrigin}).
|
|
1286
|
+
* @returns a `403` Response when the upgrade origin is untrusted, or `undefined` when it is allowed.
|
|
1287
|
+
*/
|
|
1288
|
+
|
|
1267
1289
|
/**
|
|
1268
1290
|
* Answer a CORS preflight (`OPTIONS` carrying `Access-Control-Request-Method`)
|
|
1269
1291
|
* for an allowlisted origin with a `204`. Returns `undefined` for non-preflight
|
|
@@ -1718,17 +1740,21 @@ interface WorkerOptions {
|
|
|
1718
1740
|
*/
|
|
1719
1741
|
adminToken?: string;
|
|
1720
1742
|
/**
|
|
1721
|
-
*
|
|
1722
|
-
*
|
|
1723
|
-
*
|
|
1724
|
-
*
|
|
1725
|
-
*
|
|
1726
|
-
*
|
|
1727
|
-
* `
|
|
1728
|
-
*
|
|
1729
|
-
*
|
|
1730
|
-
*
|
|
1731
|
-
* configured.
|
|
1743
|
+
* Opt into an authorization-open posture for sharded and fan-out access.
|
|
1744
|
+
*
|
|
1745
|
+
* By default (this flag unset/`false`) the runtime FAILS CLOSED: when
|
|
1746
|
+
* neither {@link WorkerOptions.authorizeShard} nor {@link WorkerOptions.authorizeFanOut}
|
|
1747
|
+
* is configured, naming a non-default shard (a potential cross-tenant hop)
|
|
1748
|
+
* or sending a fan-out envelope is rejected with a `403`
|
|
1749
|
+
* (`FORBIDDEN_SHARD`/`FORBIDDEN_FANOUT`). Set this to `true` to allow such
|
|
1750
|
+
* requests from any caller (including unauthenticated ones) — appropriate
|
|
1751
|
+
* only when every table is protected by per-row RLS. The runtime then emits
|
|
1752
|
+
* a single `console.warn` so the open posture stays visible in logs. Has no
|
|
1753
|
+
* effect once an `authorize*` callback is configured (those gate directly).
|
|
1754
|
+
*
|
|
1755
|
+
* NOTE: this is a behaviour change from earlier alphas, where the same
|
|
1756
|
+
* situation was warn-once-then-allow. Apps that relied on client-chosen
|
|
1757
|
+
* shard keys without an `authorize*` callback must set this flag explicitly.
|
|
1732
1758
|
*/
|
|
1733
1759
|
allowUnauthenticatedShardAccess?: boolean;
|
|
1734
1760
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -1150,9 +1150,13 @@ declare const createQueryCoordinator: (options: QueryCoordinatorOptions) => Quer
|
|
|
1150
1150
|
interface SecurityHeadersOptions {
|
|
1151
1151
|
/**
|
|
1152
1152
|
* `Content-Security-Policy`. Omitted (`undefined`) applies a restrictive
|
|
1153
|
-
* default to **non-HTML** responses
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
1153
|
+
* `default-src 'none'` policy to **non-HTML** responses, and a conservative
|
|
1154
|
+
* hardening policy to **HTML** responses (`base-uri 'none'; frame-ancestors
|
|
1155
|
+
* 'self'; object-src 'none'`) — this does NOT set `default-src`/`script-src`,
|
|
1156
|
+
* so it never blocks an SSR page's own scripts/styles/images/fetches, it only
|
|
1157
|
+
* locks down the `base` element, framing (mirroring the `SAMEORIGIN`
|
|
1158
|
+
* X-Frame-Options default), and legacy plugins. Pass a string to apply that
|
|
1159
|
+
* exact policy to every response (HTML included); `false` to never send one.
|
|
1156
1160
|
*/
|
|
1157
1161
|
csp?: string | false;
|
|
1158
1162
|
/** `X-Frame-Options` (clickjacking). Defaults to `SAMEORIGIN`. `false` omits it. */
|
|
@@ -1201,7 +1205,7 @@ interface SecurityOptions {
|
|
|
1201
1205
|
interface ResolvedHeaders {
|
|
1202
1206
|
coop: string | undefined;
|
|
1203
1207
|
csp: {
|
|
1204
|
-
|
|
1208
|
+
htmlValue: string | undefined;
|
|
1205
1209
|
value: string;
|
|
1206
1210
|
} | undefined;
|
|
1207
1211
|
enabled: boolean;
|
|
@@ -1264,6 +1268,24 @@ declare const resolveSecurity: (security: SecurityOptions | undefined, env?: Rec
|
|
|
1264
1268
|
* @returns a `403` Response when the origin is untrusted, or `undefined` when the request is allowed.
|
|
1265
1269
|
*/
|
|
1266
1270
|
declare const enforceOrigin: (request: Request, resolved: ResolvedSecurity) => Response | undefined;
|
|
1271
|
+
/**
|
|
1272
|
+
* CSRF defense for the WebSocket upgrade (Cross-Site WebSocket Hijacking).
|
|
1273
|
+
*
|
|
1274
|
+
* A WS handshake is an HTTP `GET`, so {@link enforceOrigin}'s safe-method
|
|
1275
|
+
* exemption never fires for it — yet the browser auto-attaches the session
|
|
1276
|
+
* cookie to the handshake and WebSocket connections are NOT governed by
|
|
1277
|
+
* CORS/SOP. Without an explicit `Origin` check any page can open
|
|
1278
|
+
* `wss://app/_lunora/ws`, authenticate as the logged-in victim, and read the
|
|
1279
|
+
* victim's live queries + issue mutations as them. This guard closes that hole.
|
|
1280
|
+
*
|
|
1281
|
+
* Scoped to cookie-bearing upgrades — the only vector CSWSH can ride (a browser
|
|
1282
|
+
* attaches cookies but never a bearer token). Bearer/token/server-to-server
|
|
1283
|
+
* upgrades (no `Cookie`) are exempt. A browser ALWAYS sends `Origin` on a WS
|
|
1284
|
+
* handshake, so a missing/untrusted `Origin` on a cookie-bearing upgrade fails
|
|
1285
|
+
* closed (mirrors {@link enforceOrigin}).
|
|
1286
|
+
* @returns a `403` Response when the upgrade origin is untrusted, or `undefined` when it is allowed.
|
|
1287
|
+
*/
|
|
1288
|
+
|
|
1267
1289
|
/**
|
|
1268
1290
|
* Answer a CORS preflight (`OPTIONS` carrying `Access-Control-Request-Method`)
|
|
1269
1291
|
* for an allowlisted origin with a `204`. Returns `undefined` for non-preflight
|
|
@@ -1718,17 +1740,21 @@ interface WorkerOptions {
|
|
|
1718
1740
|
*/
|
|
1719
1741
|
adminToken?: string;
|
|
1720
1742
|
/**
|
|
1721
|
-
*
|
|
1722
|
-
*
|
|
1723
|
-
*
|
|
1724
|
-
*
|
|
1725
|
-
*
|
|
1726
|
-
*
|
|
1727
|
-
* `
|
|
1728
|
-
*
|
|
1729
|
-
*
|
|
1730
|
-
*
|
|
1731
|
-
* configured.
|
|
1743
|
+
* Opt into an authorization-open posture for sharded and fan-out access.
|
|
1744
|
+
*
|
|
1745
|
+
* By default (this flag unset/`false`) the runtime FAILS CLOSED: when
|
|
1746
|
+
* neither {@link WorkerOptions.authorizeShard} nor {@link WorkerOptions.authorizeFanOut}
|
|
1747
|
+
* is configured, naming a non-default shard (a potential cross-tenant hop)
|
|
1748
|
+
* or sending a fan-out envelope is rejected with a `403`
|
|
1749
|
+
* (`FORBIDDEN_SHARD`/`FORBIDDEN_FANOUT`). Set this to `true` to allow such
|
|
1750
|
+
* requests from any caller (including unauthenticated ones) — appropriate
|
|
1751
|
+
* only when every table is protected by per-row RLS. The runtime then emits
|
|
1752
|
+
* a single `console.warn` so the open posture stays visible in logs. Has no
|
|
1753
|
+
* effect once an `authorize*` callback is configured (those gate directly).
|
|
1754
|
+
*
|
|
1755
|
+
* NOTE: this is a behaviour change from earlier alphas, where the same
|
|
1756
|
+
* situation was warn-once-then-allow. Apps that relied on client-chosen
|
|
1757
|
+
* shard keys without an `authorize*` callback must set this flag explicitly.
|
|
1732
1758
|
*/
|
|
1733
1759
|
allowUnauthenticatedShardAccess?: boolean;
|
|
1734
1760
|
/**
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { toAirbyteMessages, toFivetranResponse } from './packem_shared/toAirbyteMessages-DrHdplb4.mjs';
|
|
2
|
-
export { composeWorker, createLunoraHandler, createWorker, defineRpcEnvelope, resolveLunoraOptions, withFrameworkWorker } from './packem_shared/composeWorker-
|
|
2
|
+
export { composeWorker, createLunoraHandler, createWorker, defineRpcEnvelope, resolveLunoraOptions, withFrameworkWorker } from './packem_shared/composeWorker-M4mPqTJx.mjs';
|
|
3
3
|
export { createCrossShardRelationCapabilities } from './packem_shared/createCrossShardRelationCapabilities-C0KOf7er.mjs';
|
|
4
4
|
export { DEFAULT_REGISTRY_CACHE_TTL_MS, SHARD_REGISTRY_DO_NAME, createDynamicShardRegistry } from './packem_shared/DEFAULT_REGISTRY_CACHE_TTL_MS-ocax8v0n.mjs';
|
|
5
5
|
export { LunoraError, toErrorResponse } from './packem_shared/LunoraError-CL0aOtpo.mjs';
|
|
@@ -7,7 +7,7 @@ export { emitLogEvent, emitRpcEvent } from './packem_shared/emitLogEvent-pEdtqAK
|
|
|
7
7
|
export { analyticsEngineSink, combineSinks, consoleSink, sentrySink, webhookSink } from './packem_shared/analyticsEngineSink-DqEvrQs0.mjs';
|
|
8
8
|
export { createQueryCoordinator, createStaticShardRegistry, mergeStrategyForAggregate } from './packem_shared/createQueryCoordinator-ZeZYUPNu.mjs';
|
|
9
9
|
export { applyJurisdiction, resolveShard } from './packem_shared/applyJurisdiction-BkZtTkct.mjs';
|
|
10
|
-
export { decorateResponse, enforceOrigin, handleCorsPreflight, resolveSecurity } from './packem_shared/decorateResponse-
|
|
10
|
+
export { decorateResponse, enforceOrigin, handleCorsPreflight, resolveSecurity } from './packem_shared/decorateResponse-HRXo-oOD.mjs';
|
|
11
11
|
export { NOOP_EXECUTION_CONTEXT } from './packem_shared/NOOP_EXECUTION_CONTEXT-CCTu0Bf1.mjs';
|
|
12
12
|
export { composeIdentityResolvers, routeIdentityResolvers } from './packem_shared/composeIdentityResolvers-YjvUKisc.mjs';
|
|
13
13
|
|
|
@@ -4,7 +4,7 @@ import { wrapResolverWithContract } from './composeIdentityResolvers-YjvUKisc.mj
|
|
|
4
4
|
export { composeIdentityResolvers, routeIdentityResolvers } from './composeIdentityResolvers-YjvUKisc.mjs';
|
|
5
5
|
import { emitRpcEvent } from './emitLogEvent-pEdtqAK8.mjs';
|
|
6
6
|
import { resolveShard, applyJurisdiction } from './applyJurisdiction-BkZtTkct.mjs';
|
|
7
|
-
import { resolveSecurity, handleCorsPreflight, enforceOrigin, decorateResponse } from './decorateResponse-
|
|
7
|
+
import { resolveSecurity, handleCorsPreflight, enforceOrigin, decorateResponse, enforceWebSocketOrigin } from './decorateResponse-HRXo-oOD.mjs';
|
|
8
8
|
|
|
9
9
|
const RELAY_NAME_INFIX = "::relay::";
|
|
10
10
|
const relayName = (ownerKey, index) => `${ownerKey}${RELAY_NAME_INFIX}${String(index)}`;
|
|
@@ -250,15 +250,15 @@ const buildAuthAdminRoutes = (deps) => {
|
|
|
250
250
|
}
|
|
251
251
|
const candidate = error;
|
|
252
252
|
const code = typeof candidate.code === "string" ? candidate.code : "AUTH_ADMIN_ERROR";
|
|
253
|
-
|
|
254
|
-
throw new LunoraError(
|
|
253
|
+
console.error("[lunora] auth admin operation failed:", error);
|
|
254
|
+
throw new LunoraError("auth admin operation failed", { code, status: AUTH_ADMIN_ERROR_STATUS[code] ?? 400 });
|
|
255
255
|
}
|
|
256
256
|
};
|
|
257
257
|
const handle = async (request, descriptor) => {
|
|
258
|
+
deps.assertAdmin(request);
|
|
258
259
|
if (request.method !== descriptor.http) {
|
|
259
260
|
throw new LunoraError(`Auth admin endpoint requires ${descriptor.http}`, { code: "METHOD_NOT_ALLOWED", status: 405 });
|
|
260
261
|
}
|
|
261
|
-
deps.assertAdmin(request);
|
|
262
262
|
const admin = deps.getAuthAdmin();
|
|
263
263
|
if (admin === void 0) {
|
|
264
264
|
throw new LunoraError("auth endpoints require an `authAdmin` on the worker", { code: "AUTH_NOT_CONFIGURED", status: 400 });
|
|
@@ -1744,9 +1744,18 @@ const parseEnvelope = async (request) => {
|
|
|
1744
1744
|
throw new LunoraError("RPC `shardKey` must be a string", { code: "BAD_REQUEST", status: 400 });
|
|
1745
1745
|
}
|
|
1746
1746
|
const envelope = body;
|
|
1747
|
+
const fanOut = validateFanOut(envelope.fanOut);
|
|
1748
|
+
const args = envelope.args ?? {};
|
|
1749
|
+
if (fanOut && envelope.functionPath.startsWith("__lunora_relation__:")) {
|
|
1750
|
+
const requestedTable = args.table;
|
|
1751
|
+
if (typeof requestedTable === "string" && requestedTable !== fanOut.table) {
|
|
1752
|
+
throw new LunoraError("RPC `args.table` must match the authorized `fanOut.table` for a relation fan-out", { code: "BAD_REQUEST", status: 400 });
|
|
1753
|
+
}
|
|
1754
|
+
args.table = fanOut.table;
|
|
1755
|
+
}
|
|
1747
1756
|
return {
|
|
1748
|
-
args
|
|
1749
|
-
fanOut
|
|
1757
|
+
args,
|
|
1758
|
+
fanOut,
|
|
1750
1759
|
functionPath: envelope.functionPath,
|
|
1751
1760
|
shardKey: envelope.shardKey
|
|
1752
1761
|
};
|
|
@@ -1864,18 +1873,24 @@ const createWorker = (options) => {
|
|
|
1864
1873
|
}
|
|
1865
1874
|
return context;
|
|
1866
1875
|
};
|
|
1867
|
-
const hasAnyShardAuth = Boolean(options.authorizeShard) || Boolean(options.authorizeFanOut);
|
|
1868
1876
|
let warnedUnauthenticatedShardAccess = false;
|
|
1869
|
-
const
|
|
1870
|
-
if (
|
|
1877
|
+
const guardUnauthenticatedShardAccess = (kind) => {
|
|
1878
|
+
if (!options.allowUnauthenticatedShardAccess) {
|
|
1879
|
+
const callback = kind === "fan-out" ? "authorizeFanOut" : "authorizeShard";
|
|
1880
|
+
throw new LunoraError(
|
|
1881
|
+
`${kind} access is default-denied: configure \`${callback}\` on the worker, or set \`allowUnauthenticatedShardAccess: true\` to explicitly allow unauthenticated ${kind} access (relying solely on per-row RLS).`,
|
|
1882
|
+
{ code: kind === "fan-out" ? "FORBIDDEN_FANOUT" : "FORBIDDEN_SHARD", status: 403 }
|
|
1883
|
+
);
|
|
1884
|
+
}
|
|
1885
|
+
if (warnedUnauthenticatedShardAccess) {
|
|
1871
1886
|
return;
|
|
1872
1887
|
}
|
|
1873
1888
|
warnedUnauthenticatedShardAccess = true;
|
|
1874
1889
|
console.warn(
|
|
1875
1890
|
[
|
|
1876
|
-
`[lunora] SECURITY:
|
|
1891
|
+
`[lunora] SECURITY: serving ${kind} access with \`allowUnauthenticatedShardAccess: true\` and no \`authorizeShard\`/\`authorizeFanOut\` — `,
|
|
1877
1892
|
`any caller (including unauthenticated ones) can target any shard / fan out across the table. `,
|
|
1878
|
-
`
|
|
1893
|
+
`This is safe only if every table is protected by per-row RLS. Configure \`authorizeShard\`/\`authorizeFanOut\` to gate it.`
|
|
1879
1894
|
].join("")
|
|
1880
1895
|
);
|
|
1881
1896
|
};
|
|
@@ -1887,20 +1902,20 @@ const createWorker = (options) => {
|
|
|
1887
1902
|
resolveForwardContext: resolveAdminForwardContext,
|
|
1888
1903
|
shardDO
|
|
1889
1904
|
});
|
|
1890
|
-
const dispatchToShard = async (functionPath, args, shardKey) => {
|
|
1905
|
+
const dispatchToShard = async (functionPath, args, shardKey, mutationId) => {
|
|
1891
1906
|
if (options.authorizeShard) {
|
|
1892
1907
|
const allowed = await options.authorizeShard(null, shardKey);
|
|
1893
1908
|
if (!allowed) {
|
|
1894
1909
|
throw new LunoraError("Forbidden shard", { code: "FORBIDDEN_SHARD", status: 403 });
|
|
1895
1910
|
}
|
|
1896
1911
|
}
|
|
1912
|
+
const headers = { "content-type": "application/json", "x-lunora-system": "1" };
|
|
1913
|
+
if (mutationId !== void 0 && mutationId.length > 0) {
|
|
1914
|
+
headers["x-lunora-mutation-id"] = mutationId;
|
|
1915
|
+
}
|
|
1897
1916
|
const forwarded = new Request("https://shard.internal/rpc", {
|
|
1898
|
-
// `x-lunora-system` marks this as a trusted server-initiated dispatch
|
|
1899
|
-
// so the shard may run `internal` functions (scheduled/cron jobs are
|
|
1900
|
-
// typically internal). Authorization was already enforced above; this
|
|
1901
|
-
// header is set only here, never on the client RPC path.
|
|
1902
1917
|
body: JSON.stringify({ args, functionPath }),
|
|
1903
|
-
headers
|
|
1918
|
+
headers,
|
|
1904
1919
|
method: "POST"
|
|
1905
1920
|
});
|
|
1906
1921
|
return forwardToShard(shardDO, shardKey, forwarded);
|
|
@@ -2016,7 +2031,8 @@ const createWorker = (options) => {
|
|
|
2016
2031
|
}
|
|
2017
2032
|
const args = candidate.args ?? {};
|
|
2018
2033
|
const shardKey = typeof candidate.shardKey === "string" && candidate.shardKey.length > 0 ? candidate.shardKey : defaultShard;
|
|
2019
|
-
const
|
|
2034
|
+
const mutationId = typeof candidate.id === "string" && candidate.id.length > 0 ? candidate.id : void 0;
|
|
2035
|
+
const response = await dispatchToShard(candidate.functionPath, args, shardKey, mutationId);
|
|
2020
2036
|
await releasePoolSlot(candidate);
|
|
2021
2037
|
return response;
|
|
2022
2038
|
};
|
|
@@ -2159,6 +2175,10 @@ const createWorker = (options) => {
|
|
|
2159
2175
|
if (request.headers.get("Upgrade") !== "websocket") {
|
|
2160
2176
|
throw new LunoraError("WebSocket upgrade header missing", { code: "BAD_REQUEST", status: 426 });
|
|
2161
2177
|
}
|
|
2178
|
+
const blockedUpgrade = enforceWebSocketOrigin(request, resolvedSecurity);
|
|
2179
|
+
if (blockedUpgrade) {
|
|
2180
|
+
return blockedUpgrade;
|
|
2181
|
+
}
|
|
2162
2182
|
const shardKey = url.searchParams.get("shard") ?? defaultShard;
|
|
2163
2183
|
const { headers: forwardedHeaders, identity } = await resolveForwardContext(request, env, publicResolveIdentity);
|
|
2164
2184
|
if (options.authorizeShard) {
|
|
@@ -2167,7 +2187,7 @@ const createWorker = (options) => {
|
|
|
2167
2187
|
throw new LunoraError("Forbidden shard", { code: "FORBIDDEN_SHARD", status: 403 });
|
|
2168
2188
|
}
|
|
2169
2189
|
} else if (shardKey !== defaultShard) {
|
|
2170
|
-
|
|
2190
|
+
guardUnauthenticatedShardAccess("shard");
|
|
2171
2191
|
}
|
|
2172
2192
|
const upgradeHeaders = new Headers(request.headers);
|
|
2173
2193
|
upgradeHeaders.delete("x-lunora-userid");
|
|
@@ -2196,29 +2216,34 @@ const createWorker = (options) => {
|
|
|
2196
2216
|
}
|
|
2197
2217
|
return forwardToShard(shardDO, shardKey, new Request(request, { headers: upgradeHeaders }));
|
|
2198
2218
|
};
|
|
2219
|
+
const authorizeFanOutEnvelope = async (fanOut, functionPath, identity) => {
|
|
2220
|
+
if (options.authorizeFanOut) {
|
|
2221
|
+
const allowed = await options.authorizeFanOut(identity, fanOut.table, functionPath);
|
|
2222
|
+
if (!allowed) {
|
|
2223
|
+
throw new LunoraError("Forbidden fan-out", { code: "FORBIDDEN_FANOUT", status: 403 });
|
|
2224
|
+
}
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
if (functionPath.startsWith("__lunora_relation__:")) {
|
|
2228
|
+
throw new LunoraError("reverse cross-backend relation reads (`__lunora_relation__:*`) require `authorizeFanOut` to be configured on the worker", {
|
|
2229
|
+
code: "FORBIDDEN_FANOUT",
|
|
2230
|
+
status: 403
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
if (options.authorizeShard) {
|
|
2234
|
+
throw new LunoraError("Fan-out requires `authorizeFanOut` to be configured on the worker when `authorizeShard` is set", {
|
|
2235
|
+
code: "FORBIDDEN_FANOUT",
|
|
2236
|
+
status: 403
|
|
2237
|
+
});
|
|
2238
|
+
}
|
|
2239
|
+
guardUnauthenticatedShardAccess("fan-out");
|
|
2240
|
+
};
|
|
2199
2241
|
const authorizeRpcEnvelope = async (envelope, identity) => {
|
|
2242
|
+
if (!envelope.fanOut && envelope.functionPath.startsWith("__lunora_admin__:")) {
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2200
2245
|
if (envelope.fanOut) {
|
|
2201
|
-
|
|
2202
|
-
const allowed = await options.authorizeFanOut(identity, envelope.fanOut.table, envelope.functionPath);
|
|
2203
|
-
if (!allowed) {
|
|
2204
|
-
throw new LunoraError("Forbidden fan-out", { code: "FORBIDDEN_FANOUT", status: 403 });
|
|
2205
|
-
}
|
|
2206
|
-
} else if (envelope.functionPath.startsWith("__lunora_relation__:")) {
|
|
2207
|
-
throw new LunoraError(
|
|
2208
|
-
"reverse cross-backend relation reads (`__lunora_relation__:*`) require `authorizeFanOut` to be configured on the worker",
|
|
2209
|
-
{
|
|
2210
|
-
code: "FORBIDDEN_FANOUT",
|
|
2211
|
-
status: 403
|
|
2212
|
-
}
|
|
2213
|
-
);
|
|
2214
|
-
} else if (options.authorizeShard) {
|
|
2215
|
-
throw new LunoraError("Fan-out requires `authorizeFanOut` to be configured on the worker when `authorizeShard` is set", {
|
|
2216
|
-
code: "FORBIDDEN_FANOUT",
|
|
2217
|
-
status: 403
|
|
2218
|
-
});
|
|
2219
|
-
} else {
|
|
2220
|
-
warnUnauthenticatedShardAccessOnce("fan-out");
|
|
2221
|
-
}
|
|
2246
|
+
await authorizeFanOutEnvelope(envelope.fanOut, envelope.functionPath, identity);
|
|
2222
2247
|
return;
|
|
2223
2248
|
}
|
|
2224
2249
|
if (options.authorizeShard) {
|
|
@@ -2228,7 +2253,7 @@ const createWorker = (options) => {
|
|
|
2228
2253
|
throw new LunoraError("Forbidden shard", { code: "FORBIDDEN_SHARD", status: 403 });
|
|
2229
2254
|
}
|
|
2230
2255
|
} else if (envelope.shardKey !== void 0 && envelope.shardKey !== defaultShard) {
|
|
2231
|
-
|
|
2256
|
+
guardUnauthenticatedShardAccess("shard");
|
|
2232
2257
|
}
|
|
2233
2258
|
};
|
|
2234
2259
|
const dispatchSingleShard = async (functionPath, args, shardKey, forwardedHeaders, sinkContext) => {
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
const DEFAULT_CSP = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
|
|
2
|
+
const htmlCspFor = (frameOptions) => {
|
|
3
|
+
const parts = ["base-uri 'none'", "object-src 'none'"];
|
|
4
|
+
if (frameOptions === "DENY") {
|
|
5
|
+
parts.push("frame-ancestors 'none'");
|
|
6
|
+
} else if (frameOptions === "SAMEORIGIN") {
|
|
7
|
+
parts.push("frame-ancestors 'self'");
|
|
8
|
+
}
|
|
9
|
+
return parts.join("; ");
|
|
10
|
+
};
|
|
2
11
|
const DEFAULT_PERMISSIONS_POLICY = "accelerometer=(), autoplay=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()";
|
|
3
12
|
const DEFAULT_CORS_HEADERS = ["Authorization", "Content-Type", "X-D1-Bookmark", "X-Lunora-Mutation-Id"];
|
|
4
13
|
const DEFAULT_CORS_METHODS = ["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"];
|
|
@@ -13,14 +22,14 @@ const resolveHstsHeader = (hsts) => {
|
|
|
13
22
|
const includeSubDomains = config.includeSubDomains ?? true;
|
|
14
23
|
return `max-age=${String(maxAge)}${includeSubDomains ? "; includeSubDomains" : ""}${config.preload ? "; preload" : ""}`;
|
|
15
24
|
};
|
|
16
|
-
const resolveCspHeader = (csp) => {
|
|
25
|
+
const resolveCspHeader = (csp, htmlDefault) => {
|
|
17
26
|
if (csp === false) {
|
|
18
27
|
return void 0;
|
|
19
28
|
}
|
|
20
29
|
if (typeof csp === "string") {
|
|
21
|
-
return {
|
|
30
|
+
return { htmlValue: csp, value: csp };
|
|
22
31
|
}
|
|
23
|
-
return {
|
|
32
|
+
return { htmlValue: htmlDefault, value: DEFAULT_CSP };
|
|
24
33
|
};
|
|
25
34
|
const resolveHeaders = (input) => {
|
|
26
35
|
if (input === false) {
|
|
@@ -35,11 +44,12 @@ const resolveHeaders = (input) => {
|
|
|
35
44
|
};
|
|
36
45
|
}
|
|
37
46
|
const options = input === void 0 || input === true ? {} : input;
|
|
47
|
+
const frameOptions = options.frameOptions === false ? void 0 : options.frameOptions ?? "SAMEORIGIN";
|
|
38
48
|
return {
|
|
39
49
|
coop: "same-origin",
|
|
40
|
-
csp: resolveCspHeader(options.csp),
|
|
50
|
+
csp: resolveCspHeader(options.csp, htmlCspFor(frameOptions)),
|
|
41
51
|
enabled: true,
|
|
42
|
-
frameOptions
|
|
52
|
+
frameOptions,
|
|
43
53
|
hsts: resolveHstsHeader(options.hsts),
|
|
44
54
|
permissionsPolicy: options.permissionsPolicy === false ? void 0 : options.permissionsPolicy ?? DEFAULT_PERMISSIONS_POLICY,
|
|
45
55
|
referrerPolicy: options.referrerPolicy === false ? void 0 : options.referrerPolicy ?? "strict-origin-when-cross-origin"
|
|
@@ -65,6 +75,11 @@ const resolveCors = (input) => {
|
|
|
65
75
|
if (typeof origins === "function") {
|
|
66
76
|
isAllowed = origins;
|
|
67
77
|
isExplicitlyAllowed = origins;
|
|
78
|
+
if (allowCredentials) {
|
|
79
|
+
console.warn(
|
|
80
|
+
"@lunora/runtime: security.cors combines a custom `allowedOrigins` predicate with `allowCredentials: true`. Ensure the predicate matches ONLY trusted origins by exact equality — an over-broad predicate (e.g. `() => true`, or `endsWith`/`includes` checks) reflects any origin with credentials, defeating the allowlist and the CSRF guard."
|
|
81
|
+
);
|
|
82
|
+
}
|
|
68
83
|
} else {
|
|
69
84
|
const originsList = origins;
|
|
70
85
|
if (originsList.includes("*") && allowCredentials) {
|
|
@@ -152,6 +167,20 @@ const enforceOrigin = (request, resolved) => {
|
|
|
152
167
|
{ headers: { "content-type": "application/json" }, status: 403 }
|
|
153
168
|
);
|
|
154
169
|
};
|
|
170
|
+
const enforceWebSocketOrigin = (request, resolved) => {
|
|
171
|
+
if (!resolved.csrf.enabled || !request.headers.get("cookie")) {
|
|
172
|
+
return void 0;
|
|
173
|
+
}
|
|
174
|
+
const selfOrigin = new URL(request.url).origin;
|
|
175
|
+
const source = originOf(request.headers.get("origin"));
|
|
176
|
+
if (source !== void 0 && isTrustedOrigin(source, selfOrigin, resolved)) {
|
|
177
|
+
return void 0;
|
|
178
|
+
}
|
|
179
|
+
return Response.json(
|
|
180
|
+
{ error: { code: "FORBIDDEN_ORIGIN", message: "cross-origin websocket upgrade rejected" } },
|
|
181
|
+
{ headers: { "content-type": "application/json" }, status: 403 }
|
|
182
|
+
);
|
|
183
|
+
};
|
|
155
184
|
const corsResponseHeaders = (origin, cors) => {
|
|
156
185
|
const headers = new Headers();
|
|
157
186
|
headers.set("access-control-allow-origin", origin);
|
|
@@ -199,8 +228,11 @@ const applyBaselineHeaders = (headers, request, response, config) => {
|
|
|
199
228
|
if (config.coop !== void 0) {
|
|
200
229
|
setIfAbsent(headers, "cross-origin-opener-policy", config.coop);
|
|
201
230
|
}
|
|
202
|
-
if (config.csp !== void 0
|
|
203
|
-
|
|
231
|
+
if (config.csp !== void 0) {
|
|
232
|
+
const cspValue = isHtmlResponse(response) ? config.csp.htmlValue : config.csp.value;
|
|
233
|
+
if (cspValue !== void 0) {
|
|
234
|
+
setIfAbsent(headers, "content-security-policy", cspValue);
|
|
235
|
+
}
|
|
204
236
|
}
|
|
205
237
|
};
|
|
206
238
|
const applyCorsHeaders = (headers, request, cors) => {
|
|
@@ -230,4 +262,4 @@ const decorateResponse = (response, request, resolved) => {
|
|
|
230
262
|
return new Response(response.body, { headers, status: response.status, statusText: response.statusText });
|
|
231
263
|
};
|
|
232
264
|
|
|
233
|
-
export { decorateResponse, enforceOrigin, handleCorsPreflight, resolveSecurity };
|
|
265
|
+
export { decorateResponse, enforceOrigin, enforceWebSocketOrigin, handleCorsPreflight, resolveSecurity };
|