@pikku/core 0.12.96 → 0.12.97
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 +23 -0
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/require-origin.d.ts +23 -0
- package/dist/middleware/require-origin.js +57 -0
- package/dist/services/secret-service.d.ts +5 -0
- package/dist/services/typed-variables-service.d.ts +34 -0
- package/dist/services/typed-variables-service.js +69 -3
- package/package.json +1 -1
- package/src/middleware/index.ts +1 -0
- package/src/middleware/require-origin.test.ts +115 -0
- package/src/middleware/require-origin.ts +79 -0
- package/src/public-surface.json +4 -1
- package/src/services/secret-service.ts +5 -0
- package/src/services/typed-variables-service.test.ts +93 -0
- package/src/services/typed-variables-service.ts +94 -3
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
## 0.12.97
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 8154b1c: Restore the `SecretService.getSecret` JSDoc noting its failure mode, and state the `optional` carve-out `defineSecret` already documents: a key declared `optional` resolves `undefined` when absent rather than throwing. The line was on `main` and was removed by mistake in a comment cleanup on #1411 — the PR that changes what that throw says — leaving `getSecret` the only one of the interface's methods without its documented failure mode.
|
|
6
|
+
- 6d9c09c: Resolve a variable's declared default instead of dropping it.
|
|
7
|
+
|
|
8
|
+
`defineVariable` takes a schema, and a schema can carry a default — `z.enum(['https://api.github.com']).default('https://api.github.com')` is the shape most addons declare their base URL with. Nothing read it. `variables.get('GITHUB_BASE_URL')` returned `undefined` on a host that had not set it, and the `as string` at the call site hid that until a request went to `undefined/repos/...`.
|
|
9
|
+
|
|
10
|
+
The default now resolves in `TypedVariablesService`, which is the layer that knows what was declared — `VariablesService` only knows what a host put in it. A stored value always wins; a schema with no default still resolves to `undefined`.
|
|
11
|
+
|
|
12
|
+
`VariableStatus` gains `hasDefault`, and `getMissing()` no longer lists a variable that defaults: it has a value, just not one anybody has to supply. `isConfigured` still means what it said — that a host set it.
|
|
13
|
+
|
|
14
|
+
For this to work the generated `TYPED_VARIABLES_META` now carries the schema as a value rather than only `z.infer`-ing its type, so the schema module is retained in the emit instead of being elided.
|
|
15
|
+
|
|
16
|
+
- 239332b: Move first-party product analytics out of application code and into the framework.
|
|
17
|
+
|
|
18
|
+
`createAnalytics<Event>({ endpoint })` in `@pikku/react` is the buffered beacon client: it is typed against the app's own event union, flushes on an interval, on size and on `pagehide`/`visibilitychange` (via `sendBeacon`, so the abandon-point events survive unload), never surfaces a failure to the user and never retries. It also carries the delegated `data-analytics-click` listener, registered in the capture phase so a component calling `stopPropagation()` cannot silence instrumentation, and merging `data-analytics-meta` from ancestors with nearest-wins. Put the client on the Pikku instance and `usePikkuAnalytics<Event>()` reaches it from the provider, alongside `usePikkuFetch` and `usePikkuRPC`.
|
|
19
|
+
|
|
20
|
+
`requireOrigin()` in `@pikku/core/middleware` is a server-side origin lock for any unauthed route, and is re-exported from the generated `#pikku/middleware` leaf alongside `cors`. Unlike `cors()` — which only sets response headers a non-browser client ignores — it rejects with a 403 before the function body. Comparison is exact on the parsed origin, so `https://evil-myapp.com` cannot suffix-match `myapp.com`, and a missing `Origin` is rejected because a real browser always sets one on a cross-origin-capable POST. Allowed origins default to the request's own host and can be extended with a list or a resolver over services. `isAllowedOrigin` and `toOrigin` are exported for direct unit testing.
|
|
21
|
+
|
|
22
|
+
Together these let an app keep only its event registry and its wiring, instead of a few hundred lines of copied transport.
|
|
23
|
+
|
|
1
24
|
## 0.12.96
|
|
2
25
|
|
|
3
26
|
### Patch Changes
|
|
@@ -3,6 +3,7 @@ export { authCookie } from './auth-cookie.js';
|
|
|
3
3
|
export { authBearer } from './auth-bearer.js';
|
|
4
4
|
export { pikkuRemoteAuthMiddleware } from './remote-auth.js';
|
|
5
5
|
export { cors } from './cors.js';
|
|
6
|
+
export { requireOrigin, isAllowedOrigin, toOrigin } from './require-origin.js';
|
|
6
7
|
export { telemetryOuter, telemetryInner } from './telemetry.js';
|
|
7
8
|
export { addTagMiddleware, addTagMiddleware as addMiddleware, addGlobalMiddleware, runMiddleware, } from '../middleware-runner.js';
|
|
8
9
|
export { addGlobalPermission } from '../permissions.js';
|
package/dist/middleware/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export { authCookie } from './auth-cookie.js';
|
|
|
3
3
|
export { authBearer } from './auth-bearer.js';
|
|
4
4
|
export { pikkuRemoteAuthMiddleware } from './remote-auth.js';
|
|
5
5
|
export { cors } from './cors.js';
|
|
6
|
+
export { requireOrigin, isAllowedOrigin, toOrigin } from './require-origin.js';
|
|
6
7
|
export { telemetryOuter, telemetryInner } from './telemetry.js';
|
|
7
8
|
export { addTagMiddleware, addTagMiddleware as addMiddleware, addGlobalMiddleware, runMiddleware, } from '../middleware-runner.js';
|
|
8
9
|
export { addGlobalPermission } from '../permissions.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CoreSingletonServices } from '../types/core.types.js';
|
|
2
|
+
/** Scheme + host + port, or null for anything unparseable including the literal `"null"` origin. */
|
|
3
|
+
export declare const toOrigin: (value: string | null | undefined) => string | null;
|
|
4
|
+
/**
|
|
5
|
+
* Whether a request origin may post to an origin-locked route.
|
|
6
|
+
*
|
|
7
|
+
* The comparison is exact on the parsed origin, never a suffix match:
|
|
8
|
+
* `endsWith('myapp.com')` also accepts `https://evil-myapp.com`.
|
|
9
|
+
*/
|
|
10
|
+
export declare const isAllowedOrigin: (requestOrigin: string | null, hostOrigin: string | null, configuredOrigins: string[]) => boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Rejects a request with a 403 unless its `Origin` is this app's own or explicitly allowed.
|
|
13
|
+
*
|
|
14
|
+
* This is not what `cors()` does. CORS sets response headers and is enforced by the
|
|
15
|
+
* browser, so a non-browser client ignores them and the request still runs; this rejects
|
|
16
|
+
* before the function body. It stops another site's page from posting to an unauthed
|
|
17
|
+
* route — it is not flood control, because `Origin` is trusted from nobody but a browser.
|
|
18
|
+
* A missing `Origin` is rejected too: a real browser sets one on a cross-origin-capable POST.
|
|
19
|
+
*/
|
|
20
|
+
export declare const requireOrigin: import("./middleware.types.js").CorePikkuMiddlewareFactory<{
|
|
21
|
+
/** Extra allowed origins beyond the request's own host, or a resolver for them. */
|
|
22
|
+
origins?: string[] | ((services: CoreSingletonServices) => string[] | Promise<string[]>);
|
|
23
|
+
}>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { InvalidOriginError } from '../errors/errors.js';
|
|
2
|
+
import { pikkuMiddleware, pikkuMiddlewareFactory, } from './middleware-factories.js';
|
|
3
|
+
/** Scheme + host + port, or null for anything unparseable including the literal `"null"` origin. */
|
|
4
|
+
export const toOrigin = (value) => {
|
|
5
|
+
if (!value)
|
|
6
|
+
return null;
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(value);
|
|
9
|
+
return url.protocol && url.host ? url.origin : null;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Whether a request origin may post to an origin-locked route.
|
|
17
|
+
*
|
|
18
|
+
* The comparison is exact on the parsed origin, never a suffix match:
|
|
19
|
+
* `endsWith('myapp.com')` also accepts `https://evil-myapp.com`.
|
|
20
|
+
*/
|
|
21
|
+
export const isAllowedOrigin = (requestOrigin, hostOrigin, configuredOrigins) => {
|
|
22
|
+
if (!requestOrigin)
|
|
23
|
+
return false;
|
|
24
|
+
if (hostOrigin && requestOrigin === hostOrigin)
|
|
25
|
+
return true;
|
|
26
|
+
return configuredOrigins.some((allowed) => toOrigin(allowed) === requestOrigin);
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Rejects a request with a 403 unless its `Origin` is this app's own or explicitly allowed.
|
|
30
|
+
*
|
|
31
|
+
* This is not what `cors()` does. CORS sets response headers and is enforced by the
|
|
32
|
+
* browser, so a non-browser client ignores them and the request still runs; this rejects
|
|
33
|
+
* before the function body. It stops another site's page from posting to an unauthed
|
|
34
|
+
* route — it is not flood control, because `Origin` is trusted from nobody but a browser.
|
|
35
|
+
* A missing `Origin` is rejected too: a real browser sets one on a cross-origin-capable POST.
|
|
36
|
+
*/
|
|
37
|
+
export const requireOrigin = pikkuMiddlewareFactory(({ origins = [] } = {}) => pikkuMiddleware({
|
|
38
|
+
name: 'requireOrigin',
|
|
39
|
+
description: 'Rejects requests that did not come from this app.',
|
|
40
|
+
func: async (services, { http }, next) => {
|
|
41
|
+
const request = http?.request;
|
|
42
|
+
if (!request)
|
|
43
|
+
return next();
|
|
44
|
+
const requestOrigin = toOrigin(request.header('origin')) ??
|
|
45
|
+
toOrigin(request.header('referer'));
|
|
46
|
+
const host = request.header('host');
|
|
47
|
+
const proto = request.header('x-forwarded-proto') ?? 'https';
|
|
48
|
+
const hostOrigin = host ? toOrigin(`${proto}://${host}`) : null;
|
|
49
|
+
const configured = typeof origins === 'function'
|
|
50
|
+
? await origins(services)
|
|
51
|
+
: origins;
|
|
52
|
+
if (!isAllowedOrigin(requestOrigin, hostOrigin, configured)) {
|
|
53
|
+
throw new InvalidOriginError(`Rejected origin ${requestOrigin ?? '(none)'}`);
|
|
54
|
+
}
|
|
55
|
+
return next();
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
@@ -4,6 +4,11 @@ export type SecretValues<T> = {
|
|
|
4
4
|
[K in keyof T]: SecretValue<T[K]>;
|
|
5
5
|
};
|
|
6
6
|
export interface SecretService {
|
|
7
|
+
/**
|
|
8
|
+
* Throws if the secret is not found, unless `defineSecret` declared it
|
|
9
|
+
* `optional` — then absence resolves `undefined`. Unwrap the result with
|
|
10
|
+
* `.reveal()`.
|
|
11
|
+
*/
|
|
7
12
|
getSecret<T = string>(key: string): Promise<SecretValue<T>>;
|
|
8
13
|
/** Answers for any key, including a disallowed one — it must not throw. */
|
|
9
14
|
hasSecret(key: string): Promise<boolean>;
|
|
@@ -1,14 +1,34 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
1
2
|
import type { VariablesService } from './variables-service.js';
|
|
2
3
|
export interface VariableStatus {
|
|
3
4
|
variableId: string;
|
|
4
5
|
name: string;
|
|
5
6
|
displayName: string;
|
|
6
7
|
isConfigured: boolean;
|
|
8
|
+
/** Whether the declaration answers for itself when the host sets nothing. */
|
|
9
|
+
hasDefault: boolean;
|
|
7
10
|
}
|
|
8
11
|
export type VariableMeta = {
|
|
9
12
|
name: string;
|
|
10
13
|
displayName: string;
|
|
14
|
+
/**
|
|
15
|
+
* The shape the variable was declared with. It is the schema itself rather
|
|
16
|
+
* than a description of it, because a default is only knowable by running it:
|
|
17
|
+
* `undefined` goes in and, if the declaration carries one, the default comes
|
|
18
|
+
* back out.
|
|
19
|
+
*
|
|
20
|
+
* A thunk is accepted, and is what code generation emits. The generated file
|
|
21
|
+
* and the file declaring the schema import each other, so reading the schema
|
|
22
|
+
* while the modules are still initializing throws — deferring the read until
|
|
23
|
+
* a variable is actually asked for is what keeps the cycle harmless.
|
|
24
|
+
*/
|
|
25
|
+
schema?: StandardSchemaV1 | (() => StandardSchemaV1);
|
|
11
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* A declared default is the answer to a variable nobody set, so it is resolved
|
|
29
|
+
* here rather than in `VariablesService`: the store knows what a host has put
|
|
30
|
+
* in it, and only this layer knows what was declared.
|
|
31
|
+
*/
|
|
12
32
|
export declare class TypedVariablesService<TMap = Record<string, unknown>> implements VariablesService {
|
|
13
33
|
private variables;
|
|
14
34
|
private variablesMeta;
|
|
@@ -21,5 +41,19 @@ export declare class TypedVariablesService<TMap = Record<string, unknown>> imple
|
|
|
21
41
|
has(name: string): Promise<boolean> | boolean;
|
|
22
42
|
delete(name: string): Promise<void> | void;
|
|
23
43
|
getAllStatus(): Promise<VariableStatus[]>;
|
|
44
|
+
/**
|
|
45
|
+
* What a deployment still has to be told. A variable that defaults is not on
|
|
46
|
+
* this list — it has a value, just not one anybody has to supply.
|
|
47
|
+
*/
|
|
24
48
|
getMissing(): Promise<VariableStatus[]>;
|
|
49
|
+
/**
|
|
50
|
+
* The value the declaration answers with when the host set nothing, or
|
|
51
|
+
* `undefined` when it does not answer for itself.
|
|
52
|
+
*/
|
|
53
|
+
private resolveDefault;
|
|
54
|
+
/**
|
|
55
|
+
* Kept synchronous when the defaults resolve synchronously, so a caller that
|
|
56
|
+
* did not await `getVariables` before does not have to start.
|
|
57
|
+
*/
|
|
58
|
+
private withDefaults;
|
|
25
59
|
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
const isPromise = (value) => typeof value?.then === 'function';
|
|
2
|
+
/**
|
|
3
|
+
* A declared default is the answer to a variable nobody set, so it is resolved
|
|
4
|
+
* here rather than in `VariablesService`: the store knows what a host has put
|
|
5
|
+
* in it, and only this layer knows what was declared.
|
|
6
|
+
*/
|
|
1
7
|
export class TypedVariablesService {
|
|
2
8
|
variables;
|
|
3
9
|
variablesMeta;
|
|
@@ -6,10 +12,18 @@ export class TypedVariablesService {
|
|
|
6
12
|
this.variablesMeta = variablesMeta;
|
|
7
13
|
}
|
|
8
14
|
get(name) {
|
|
9
|
-
|
|
15
|
+
const stored = this.variables.get(name);
|
|
16
|
+
if (isPromise(stored)) {
|
|
17
|
+
return stored.then((value) => value === undefined ? this.resolveDefault(name) : value);
|
|
18
|
+
}
|
|
19
|
+
return stored === undefined ? this.resolveDefault(name) : stored;
|
|
10
20
|
}
|
|
11
21
|
getVariables(names) {
|
|
12
|
-
|
|
22
|
+
const stored = this.variables.getVariables(names);
|
|
23
|
+
if (isPromise(stored)) {
|
|
24
|
+
return stored.then((values) => this.withDefaults(names, values));
|
|
25
|
+
}
|
|
26
|
+
return this.withDefaults(names, stored);
|
|
13
27
|
}
|
|
14
28
|
getAll() {
|
|
15
29
|
return this.variables.getAll();
|
|
@@ -32,12 +46,64 @@ export class TypedVariablesService {
|
|
|
32
46
|
name: meta.name,
|
|
33
47
|
displayName: meta.displayName,
|
|
34
48
|
isConfigured: all[variableId] !== undefined,
|
|
49
|
+
hasDefault: (await this.resolveDefault(variableId)) !== undefined,
|
|
35
50
|
});
|
|
36
51
|
}
|
|
37
52
|
return results;
|
|
38
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* What a deployment still has to be told. A variable that defaults is not on
|
|
56
|
+
* this list — it has a value, just not one anybody has to supply.
|
|
57
|
+
*/
|
|
39
58
|
async getMissing() {
|
|
40
59
|
const all = await this.getAllStatus();
|
|
41
|
-
return all.filter((v) => !v.isConfigured);
|
|
60
|
+
return all.filter((v) => !v.isConfigured && !v.hasDefault);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The value the declaration answers with when the host set nothing, or
|
|
64
|
+
* `undefined` when it does not answer for itself.
|
|
65
|
+
*/
|
|
66
|
+
resolveDefault(name) {
|
|
67
|
+
const declared = this.variablesMeta[name]?.schema;
|
|
68
|
+
if (!declared) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
const schema = typeof declared === 'function' ? declared() : declared;
|
|
72
|
+
const result = schema['~standard'].validate(undefined);
|
|
73
|
+
if (isPromise(result)) {
|
|
74
|
+
return result.then(unwrapDefault);
|
|
75
|
+
}
|
|
76
|
+
return unwrapDefault(result);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Kept synchronous when the defaults resolve synchronously, so a caller that
|
|
80
|
+
* did not await `getVariables` before does not have to start.
|
|
81
|
+
*/
|
|
82
|
+
withDefaults(names, values) {
|
|
83
|
+
const out = { ...values };
|
|
84
|
+
const pending = [];
|
|
85
|
+
for (const name of names) {
|
|
86
|
+
if (out[name] !== undefined)
|
|
87
|
+
continue;
|
|
88
|
+
const fallback = this.resolveDefault(name);
|
|
89
|
+
if (isPromise(fallback)) {
|
|
90
|
+
pending.push(fallback.then((value) => {
|
|
91
|
+
if (value !== undefined)
|
|
92
|
+
out[name] = value;
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
else if (fallback !== undefined) {
|
|
96
|
+
out[name] = fallback;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (pending.length > 0) {
|
|
100
|
+
return Promise.all(pending).then(() => out);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
42
103
|
}
|
|
43
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* A schema with no default rejects `undefined`, which is not a failure here —
|
|
107
|
+
* it is the answer that there is nothing to fall back to.
|
|
108
|
+
*/
|
|
109
|
+
const unwrapDefault = (result) => result.issues ? undefined : result.value;
|
package/package.json
CHANGED
package/src/middleware/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { authCookie } from './auth-cookie.js'
|
|
|
3
3
|
export { authBearer } from './auth-bearer.js'
|
|
4
4
|
export { pikkuRemoteAuthMiddleware } from './remote-auth.js'
|
|
5
5
|
export { cors } from './cors.js'
|
|
6
|
+
export { requireOrigin, isAllowedOrigin, toOrigin } from './require-origin.js'
|
|
6
7
|
export { telemetryOuter, telemetryInner } from './telemetry.js'
|
|
7
8
|
export {
|
|
8
9
|
addTagMiddleware,
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, test, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
import { requireOrigin, isAllowedOrigin, toOrigin } from './require-origin.js'
|
|
4
|
+
import { InvalidOriginError } from '../errors/errors.js'
|
|
5
|
+
import { resetPikkuState } from '../pikku-state.js'
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
resetPikkuState()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const headers = (values: Record<string, string | undefined>) => ({
|
|
12
|
+
method: () => 'post',
|
|
13
|
+
header: (name: string) => values[name],
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const run = async (
|
|
17
|
+
config: Parameters<typeof requireOrigin>[0],
|
|
18
|
+
values: Record<string, string | undefined>
|
|
19
|
+
) => {
|
|
20
|
+
let reached = false
|
|
21
|
+
const middleware = requireOrigin(config)
|
|
22
|
+
await middleware({} as any, { http: { request: headers(values) } } as any, async () => {
|
|
23
|
+
reached = true
|
|
24
|
+
})
|
|
25
|
+
return reached
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('toOrigin', () => {
|
|
29
|
+
test('keeps scheme, host and port and drops the rest', () => {
|
|
30
|
+
assert.equal(toOrigin('https://app.com:8443/a/b?c=1'), 'https://app.com:8443')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('rejects the sandboxed-iframe "null" origin and unparseable values', () => {
|
|
34
|
+
assert.equal(toOrigin('null'), null)
|
|
35
|
+
assert.equal(toOrigin(''), null)
|
|
36
|
+
assert.equal(toOrigin(undefined), null)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('isAllowedOrigin', () => {
|
|
41
|
+
test('matches the request host exactly', () => {
|
|
42
|
+
assert.equal(isAllowedOrigin('https://app.com', 'https://app.com', []), true)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('does not suffix-match a lookalike domain', () => {
|
|
46
|
+
assert.equal(isAllowedOrigin('https://evil-app.com', null, ['https://app.com']), false)
|
|
47
|
+
assert.equal(isAllowedOrigin('https://app.com.evil.net', null, ['https://app.com']), false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('normalises a configured origin before comparing', () => {
|
|
51
|
+
assert.equal(isAllowedOrigin('https://app.com', null, ['https://app.com/path']), true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('rejects a missing origin', () => {
|
|
55
|
+
assert.equal(isAllowedOrigin(null, 'https://app.com', ['https://app.com']), false)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('requireOrigin', () => {
|
|
60
|
+
test('allows a beacon from the request own host', async () => {
|
|
61
|
+
assert.equal(
|
|
62
|
+
await run({}, { origin: 'https://app.com', host: 'app.com' }),
|
|
63
|
+
true
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('falls back to referer when only it is sent', async () => {
|
|
68
|
+
assert.equal(
|
|
69
|
+
await run({}, { referer: 'https://app.com/pricing', host: 'app.com' }),
|
|
70
|
+
true
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('honours x-forwarded-proto when deriving the host origin', async () => {
|
|
75
|
+
assert.equal(
|
|
76
|
+
await run(
|
|
77
|
+
{},
|
|
78
|
+
{ origin: 'http://app.com', host: 'app.com', 'x-forwarded-proto': 'http' }
|
|
79
|
+
),
|
|
80
|
+
true
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('rejects another site with a 403', async () => {
|
|
85
|
+
await assert.rejects(
|
|
86
|
+
() => run({}, { origin: 'https://evil.com', host: 'app.com' }),
|
|
87
|
+
InvalidOriginError
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('rejects a non-browser caller that sends no origin', async () => {
|
|
92
|
+
await assert.rejects(
|
|
93
|
+
() => run({}, { host: 'app.com' }),
|
|
94
|
+
InvalidOriginError
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('resolves configured origins from services when given a function', async () => {
|
|
99
|
+
assert.equal(
|
|
100
|
+
await run(
|
|
101
|
+
{ origins: async () => ['https://other.com'] },
|
|
102
|
+
{ origin: 'https://other.com', host: 'app.com' }
|
|
103
|
+
),
|
|
104
|
+
true
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('passes through when there is no http wire at all', async () => {
|
|
109
|
+
let reached = false
|
|
110
|
+
await requireOrigin({})({} as any, {} as any, async () => {
|
|
111
|
+
reached = true
|
|
112
|
+
})
|
|
113
|
+
assert.equal(reached, true)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { CoreSingletonServices } from '../types/core.types.js'
|
|
2
|
+
import { InvalidOriginError } from '../errors/errors.js'
|
|
3
|
+
import {
|
|
4
|
+
pikkuMiddleware,
|
|
5
|
+
pikkuMiddlewareFactory,
|
|
6
|
+
} from './middleware-factories.js'
|
|
7
|
+
|
|
8
|
+
/** Scheme + host + port, or null for anything unparseable including the literal `"null"` origin. */
|
|
9
|
+
export const toOrigin = (value: string | null | undefined): string | null => {
|
|
10
|
+
if (!value) return null
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL(value)
|
|
13
|
+
return url.protocol && url.host ? url.origin : null
|
|
14
|
+
} catch {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether a request origin may post to an origin-locked route.
|
|
21
|
+
*
|
|
22
|
+
* The comparison is exact on the parsed origin, never a suffix match:
|
|
23
|
+
* `endsWith('myapp.com')` also accepts `https://evil-myapp.com`.
|
|
24
|
+
*/
|
|
25
|
+
export const isAllowedOrigin = (
|
|
26
|
+
requestOrigin: string | null,
|
|
27
|
+
hostOrigin: string | null,
|
|
28
|
+
configuredOrigins: string[]
|
|
29
|
+
): boolean => {
|
|
30
|
+
if (!requestOrigin) return false
|
|
31
|
+
if (hostOrigin && requestOrigin === hostOrigin) return true
|
|
32
|
+
return configuredOrigins.some((allowed) => toOrigin(allowed) === requestOrigin)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Rejects a request with a 403 unless its `Origin` is this app's own or explicitly allowed.
|
|
37
|
+
*
|
|
38
|
+
* This is not what `cors()` does. CORS sets response headers and is enforced by the
|
|
39
|
+
* browser, so a non-browser client ignores them and the request still runs; this rejects
|
|
40
|
+
* before the function body. It stops another site's page from posting to an unauthed
|
|
41
|
+
* route — it is not flood control, because `Origin` is trusted from nobody but a browser.
|
|
42
|
+
* A missing `Origin` is rejected too: a real browser sets one on a cross-origin-capable POST.
|
|
43
|
+
*/
|
|
44
|
+
export const requireOrigin = pikkuMiddlewareFactory<{
|
|
45
|
+
/** Extra allowed origins beyond the request's own host, or a resolver for them. */
|
|
46
|
+
origins?:
|
|
47
|
+
| string[]
|
|
48
|
+
| ((services: CoreSingletonServices) => string[] | Promise<string[]>)
|
|
49
|
+
}>(({ origins = [] } = {}) =>
|
|
50
|
+
pikkuMiddleware({
|
|
51
|
+
name: 'requireOrigin',
|
|
52
|
+
description: 'Rejects requests that did not come from this app.',
|
|
53
|
+
func: async (services, { http }, next) => {
|
|
54
|
+
const request = http?.request
|
|
55
|
+
if (!request) return next()
|
|
56
|
+
|
|
57
|
+
const requestOrigin =
|
|
58
|
+
toOrigin(request.header('origin')) ??
|
|
59
|
+
toOrigin(request.header('referer'))
|
|
60
|
+
|
|
61
|
+
const host = request.header('host')
|
|
62
|
+
const proto = request.header('x-forwarded-proto') ?? 'https'
|
|
63
|
+
const hostOrigin = host ? toOrigin(`${proto}://${host}`) : null
|
|
64
|
+
|
|
65
|
+
const configured =
|
|
66
|
+
typeof origins === 'function'
|
|
67
|
+
? await origins(services as CoreSingletonServices)
|
|
68
|
+
: origins
|
|
69
|
+
|
|
70
|
+
if (!isAllowedOrigin(requestOrigin, hostOrigin, configured)) {
|
|
71
|
+
throw new InvalidOriginError(
|
|
72
|
+
`Rejected origin ${requestOrigin ?? '(none)'}`
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return next()
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
)
|
package/src/public-surface.json
CHANGED
|
@@ -10,15 +10,18 @@
|
|
|
10
10
|
"authBearer",
|
|
11
11
|
"authCookie",
|
|
12
12
|
"cors",
|
|
13
|
+
"isAllowedOrigin",
|
|
13
14
|
"pikkuAgentMiddleware",
|
|
14
15
|
"pikkuChannelMiddleware",
|
|
15
16
|
"pikkuChannelMiddlewareFactory",
|
|
16
17
|
"pikkuMiddleware",
|
|
17
18
|
"pikkuMiddlewareFactory",
|
|
18
19
|
"pikkuRemoteAuthMiddleware",
|
|
20
|
+
"requireOrigin",
|
|
19
21
|
"runMiddleware",
|
|
20
22
|
"telemetryInner",
|
|
21
|
-
"telemetryOuter"
|
|
23
|
+
"telemetryOuter",
|
|
24
|
+
"toOrigin"
|
|
22
25
|
],
|
|
23
26
|
"./function": [
|
|
24
27
|
"AbandonedError",
|
|
@@ -4,6 +4,11 @@ import type { SecretValue } from '../classification/secret-value.js'
|
|
|
4
4
|
export type SecretValues<T> = { [K in keyof T]: SecretValue<T[K]> }
|
|
5
5
|
|
|
6
6
|
export interface SecretService {
|
|
7
|
+
/**
|
|
8
|
+
* Throws if the secret is not found, unless `defineSecret` declared it
|
|
9
|
+
* `optional` — then absence resolves `undefined`. Unwrap the result with
|
|
10
|
+
* `.reveal()`.
|
|
11
|
+
*/
|
|
7
12
|
getSecret<T = string>(key: string): Promise<SecretValue<T>>
|
|
8
13
|
/** Answers for any key, including a disallowed one — it must not throw. */
|
|
9
14
|
hasSecret(key: string): Promise<boolean>
|
|
@@ -2,6 +2,7 @@ import { describe, test } from 'node:test'
|
|
|
2
2
|
import assert from 'node:assert'
|
|
3
3
|
import { TypedVariablesService } from './typed-variables-service.js'
|
|
4
4
|
import { LocalVariablesService } from './local-variables.js'
|
|
5
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
5
6
|
|
|
6
7
|
describe('TypedVariablesService', () => {
|
|
7
8
|
const createService = (vars: Record<string, string | undefined> = {}) => {
|
|
@@ -71,3 +72,95 @@ describe('TypedVariablesService', () => {
|
|
|
71
72
|
assert.strictEqual(missing.length, 0)
|
|
72
73
|
})
|
|
73
74
|
})
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Stands in for `z.enum([...]).default(...)`: a schema that answers `undefined`
|
|
78
|
+
* with a value rather than an issue. Core declares no schema library of its
|
|
79
|
+
* own, so the contract under test is Standard Schema's, not Zod's.
|
|
80
|
+
*/
|
|
81
|
+
const withDefault = <T>(value: T): StandardSchemaV1<unknown, T> => ({
|
|
82
|
+
'~standard': {
|
|
83
|
+
version: 1,
|
|
84
|
+
vendor: 'test',
|
|
85
|
+
validate: (input: unknown) =>
|
|
86
|
+
input === undefined ? { value } : { value: input as T },
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const noDefault: StandardSchemaV1<unknown, string> = {
|
|
91
|
+
'~standard': {
|
|
92
|
+
version: 1,
|
|
93
|
+
vendor: 'test',
|
|
94
|
+
validate: (input: unknown) =>
|
|
95
|
+
typeof input === 'string'
|
|
96
|
+
? { value: input }
|
|
97
|
+
: { issues: [{ message: 'expected a string' }] },
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('TypedVariablesService schema defaults', () => {
|
|
102
|
+
const createService = (vars: Record<string, string | undefined> = {}) =>
|
|
103
|
+
new TypedVariablesService(new LocalVariablesService(vars), {
|
|
104
|
+
GITHUB_BASE_URL: {
|
|
105
|
+
name: 'GITHUB_BASE_URL',
|
|
106
|
+
displayName: 'GitHub Base URL',
|
|
107
|
+
schema: withDefault('https://api.github.com'),
|
|
108
|
+
},
|
|
109
|
+
API_KEY: {
|
|
110
|
+
name: 'API_KEY',
|
|
111
|
+
displayName: 'API Key',
|
|
112
|
+
schema: noDefault,
|
|
113
|
+
},
|
|
114
|
+
// The form code generation emits, deferred past the import cycle.
|
|
115
|
+
REGION: {
|
|
116
|
+
name: 'REGION',
|
|
117
|
+
displayName: 'Region',
|
|
118
|
+
schema: () => withDefault('eu-west-1'),
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('resolves a declared default when the host sets nothing', async () => {
|
|
123
|
+
const service = createService()
|
|
124
|
+
assert.strictEqual(
|
|
125
|
+
await service.get('GITHUB_BASE_URL'),
|
|
126
|
+
'https://api.github.com'
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('prefers the host value over the default', async () => {
|
|
131
|
+
const service = createService({ GITHUB_BASE_URL: 'https://ghe.internal' })
|
|
132
|
+
assert.strictEqual(
|
|
133
|
+
await service.get('GITHUB_BASE_URL'),
|
|
134
|
+
'https://ghe.internal'
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('stays undefined when the schema carries no default', async () => {
|
|
139
|
+
const service = createService()
|
|
140
|
+
assert.strictEqual(await service.get('API_KEY'), undefined)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('resolves a default behind a thunk', async () => {
|
|
144
|
+
const service = createService()
|
|
145
|
+
assert.strictEqual(await service.get('REGION'), 'eu-west-1')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('a defaulted variable is not missing', async () => {
|
|
149
|
+
const service = createService()
|
|
150
|
+
const missing = await service.getMissing()
|
|
151
|
+
assert.deepStrictEqual(
|
|
152
|
+
missing.map((v) => v.variableId),
|
|
153
|
+
['API_KEY']
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('status separates having a default from being configured', async () => {
|
|
158
|
+
const service = createService()
|
|
159
|
+
const status = await service.getAllStatus()
|
|
160
|
+
const github = status.find((s) => s.variableId === 'GITHUB_BASE_URL')!
|
|
161
|
+
assert.strictEqual(github.isConfigured, false)
|
|
162
|
+
assert.strictEqual(github.hasDefault, true)
|
|
163
|
+
const apiKey = status.find((s) => s.variableId === 'API_KEY')!
|
|
164
|
+
assert.strictEqual(apiKey.hasDefault, false)
|
|
165
|
+
})
|
|
166
|
+
})
|