@lanes-sh/link 0.6.9 → 0.6.10
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/.gcloudignore +50 -0
- package/package.json +4 -3
- package/src/auth/oidc.ts +65 -12
- package/src/cli/brand.ts +6 -0
- package/src/deployments/gcp/Dockerfile +27 -2
- package/src/deployments/gcp/bucket.ts +82 -9
- package/src/deployments/gcp/driver.ts +43 -5
- package/src/deployments/gcp/lifecycle.json +12 -0
- package/src/deployments/gcp/survey.ts +12 -1
- package/src/policy/limits.ts +76 -3
- package/src/profile/index.ts +1 -0
- package/src/profile/legacy.ts +8 -3
- package/src/profile/schema.ts +65 -0
- package/src/server/edge.ts +188 -1
- package/src/server/harness.ts +10 -0
- package/src/server/index.ts +62 -60
- package/src/server/oauth.ts +21 -2
package/.gcloudignore
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# What `gcloud builds submit` uploads, and what it must not.
|
|
2
|
+
#
|
|
3
|
+
# **This file exists because `.dockerignore` is not consulted for the upload.**
|
|
4
|
+
# The build context is packed and sent to a Cloud Build staging bucket before any
|
|
5
|
+
# Dockerfile is read, so the first block of `.dockerignore` — which its own
|
|
6
|
+
# comment calls "a security control, not an image-size optimisation" — governs
|
|
7
|
+
# what reaches the *image* and says nothing about what reaches Google.
|
|
8
|
+
#
|
|
9
|
+
# Absent this file, gcloud derives its exclusions from `.gitignore` when the
|
|
10
|
+
# context happens to be a git checkout, and from nothing at all when it is not.
|
|
11
|
+
# `lanes link deploy` sends `installRoot`, which for the documented install
|
|
12
|
+
# method is a directory under `~/.bun` with no `.git` in it — so the safe
|
|
13
|
+
# behaviour was being inherited from a coincidence.
|
|
14
|
+
#
|
|
15
|
+
# Keep the first block in step with `.dockerignore`. `data/` holds the encrypted
|
|
16
|
+
# credential store *and* the key that opens it.
|
|
17
|
+
data/
|
|
18
|
+
*.key
|
|
19
|
+
*.pem
|
|
20
|
+
*.enc
|
|
21
|
+
*.p12
|
|
22
|
+
.env
|
|
23
|
+
.env.*
|
|
24
|
+
|
|
25
|
+
# Not needed to build, and `.git` in particular carries every branch.
|
|
26
|
+
.git/
|
|
27
|
+
.gitignore
|
|
28
|
+
.worktrees/
|
|
29
|
+
node_modules/
|
|
30
|
+
**/node_modules/
|
|
31
|
+
coverage/
|
|
32
|
+
dist/
|
|
33
|
+
build/
|
|
34
|
+
*.tsbuildinfo
|
|
35
|
+
|
|
36
|
+
# Tests, docs and tooling: the image runs the endpoint and nothing else.
|
|
37
|
+
**/*.test.ts
|
|
38
|
+
**/*.test.json
|
|
39
|
+
docs/
|
|
40
|
+
instructions/
|
|
41
|
+
**/README.md
|
|
42
|
+
.lanes/
|
|
43
|
+
.claude/
|
|
44
|
+
.vscode/
|
|
45
|
+
.idea/
|
|
46
|
+
.DS_Store
|
|
47
|
+
.playwright-mcp/
|
|
48
|
+
|
|
49
|
+
# The compiled binary from `bun build --compile`, if one was made locally.
|
|
50
|
+
/lanes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lanes-sh/link",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.10",
|
|
4
4
|
"description": "A self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://lanes.sh/link",
|
|
@@ -44,13 +44,14 @@
|
|
|
44
44
|
"README.md",
|
|
45
45
|
"LICENSE",
|
|
46
46
|
"bunfig.toml",
|
|
47
|
-
".dockerignore"
|
|
47
|
+
".dockerignore",
|
|
48
|
+
".gcloudignore"
|
|
48
49
|
],
|
|
49
50
|
"scripts": {
|
|
50
51
|
"test": "bun test",
|
|
51
52
|
"typecheck": "tsc --noEmit",
|
|
52
53
|
"lanes": "bun run ./src/cli/lanes.ts",
|
|
53
|
-
"audit": "bun
|
|
54
|
+
"audit": "bun audit",
|
|
54
55
|
"vendor:bunq": "bun run ./src/providers/bunq/specs/vendor.ts",
|
|
55
56
|
"vendor:discord": "bun run ./src/providers/discord/specs/vendor.ts",
|
|
56
57
|
"vendor:google": "bun run ./src/providers/google/specs/vendor.ts"
|
package/src/auth/oidc.ts
CHANGED
|
@@ -108,8 +108,27 @@ export class OidcVerifier {
|
|
|
108
108
|
return verified;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Where to ask about a token, and whether the operator chose it.
|
|
113
|
+
*
|
|
114
|
+
* The second half decides one thing: whether the non-standard GET shape below
|
|
115
|
+
* is attempted at all. A discovered endpoint is one the issuer publishes as
|
|
116
|
+
* RFC 7662, and RFC 7662 is a form POST — so an issuer that answers discovery
|
|
117
|
+
* and then needs its token in a query string is not a case that exists. The
|
|
118
|
+
* shape is for issuers that ship an equivalent without advertising one, and
|
|
119
|
+
* those have to be named in config regardless.
|
|
120
|
+
*/
|
|
121
|
+
async #endpoint(): Promise<{ url: string; explicit: boolean } | null> {
|
|
122
|
+
const named = this.#options.introspectionEndpoint;
|
|
123
|
+
if (named) {
|
|
124
|
+
if (!isHttps(named)) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`auth.authorization.introspection_endpoint is ${named}, which is not https. ` +
|
|
127
|
+
'A token is sent to it on every call.',
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return { url: named, explicit: true };
|
|
131
|
+
}
|
|
113
132
|
|
|
114
133
|
this.#discovered ??= this.#fetch(
|
|
115
134
|
`${this.#options.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`,
|
|
@@ -120,16 +139,33 @@ export class OidcVerifier {
|
|
|
120
139
|
|
|
121
140
|
const metadata = await this.#discovered;
|
|
122
141
|
const endpoint = metadata['introspection_endpoint'];
|
|
123
|
-
|
|
142
|
+
if (typeof endpoint !== 'string') return null;
|
|
143
|
+
|
|
144
|
+
// **A discovery document decides where a credential is sent, so what it
|
|
145
|
+
// names is checked rather than followed.** The issuer is config and the
|
|
146
|
+
// operator chose it; the endpoint inside its metadata is a value fetched
|
|
147
|
+
// over the network, and until now anything there — any host, any scheme —
|
|
148
|
+
// received this endpoint's tokens. Same origin as the issuer is what a
|
|
149
|
+
// conforming document says anyway.
|
|
150
|
+
if (!isHttps(endpoint) || !sameOrigin(endpoint, this.#options.issuer)) return null;
|
|
151
|
+
|
|
152
|
+
return { url: endpoint, explicit: false };
|
|
124
153
|
}
|
|
125
154
|
|
|
126
155
|
/**
|
|
127
156
|
* Two request shapes, because two are in the wild.
|
|
128
157
|
*
|
|
129
158
|
* RFC 7662 is a form POST of `token`. The other common spelling is a GET with
|
|
130
|
-
* the token in the query string, which several issuers ship instead
|
|
131
|
-
*
|
|
132
|
-
*
|
|
159
|
+
* the token in the query string, which several issuers ship instead — Google's
|
|
160
|
+
* `tokeninfo` among them, which is why the second shape exists at all.
|
|
161
|
+
*
|
|
162
|
+
* **The GET is attempted only for an endpoint the operator named.** A token in
|
|
163
|
+
* a query string is a credential in the issuer's access logs and in every
|
|
164
|
+
* proxy between here and it, which is a cost worth paying for the issuer whose
|
|
165
|
+
* documented setup requires it and worth paying for no other. A discovered
|
|
166
|
+
* endpoint publishes itself as RFC 7662 and RFC 7662 is the POST, so trying
|
|
167
|
+
* the query-string shape against one could only ever put a credential in a URL
|
|
168
|
+
* for an issuer that did not ask for it.
|
|
133
169
|
*/
|
|
134
170
|
async #introspect(token: string): Promise<Introspection | null> {
|
|
135
171
|
const endpoint = await this.#endpoint();
|
|
@@ -138,20 +174,21 @@ export class OidcVerifier {
|
|
|
138
174
|
// audience would leave the confused-deputy hole open while looking like
|
|
139
175
|
// it verified something.
|
|
140
176
|
throw new Error(
|
|
141
|
-
`The issuer ${this.#options.issuer} publishes no introspection_endpoint
|
|
142
|
-
'
|
|
143
|
-
'
|
|
177
|
+
`The issuer ${this.#options.issuer} publishes no introspection_endpoint this endpoint ` +
|
|
178
|
+
'will use — it is absent, not https, or not on the issuer\'s own origin. Set ' +
|
|
179
|
+
'auth.authorization.introspection_endpoint to the URL that answers questions about a ' +
|
|
180
|
+
'token, or this endpoint cannot check who a token was issued to.',
|
|
144
181
|
);
|
|
145
182
|
}
|
|
146
183
|
|
|
147
|
-
const posted = await this.#ask(endpoint, {
|
|
184
|
+
const posted = await this.#ask(endpoint.url, {
|
|
148
185
|
method: 'POST',
|
|
149
186
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
150
187
|
body: new URLSearchParams({ token }).toString(),
|
|
151
188
|
});
|
|
152
|
-
if (posted) return posted;
|
|
189
|
+
if (posted || !endpoint.explicit) return posted;
|
|
153
190
|
|
|
154
|
-
const url = new URL(endpoint);
|
|
191
|
+
const url = new URL(endpoint.url);
|
|
155
192
|
url.searchParams.set('access_token', token);
|
|
156
193
|
return this.#ask(url.toString(), { method: 'GET' });
|
|
157
194
|
}
|
|
@@ -167,6 +204,22 @@ export class OidcVerifier {
|
|
|
167
204
|
}
|
|
168
205
|
}
|
|
169
206
|
|
|
207
|
+
function isHttps(candidate: string): boolean {
|
|
208
|
+
try {
|
|
209
|
+
return new URL(candidate).protocol === 'https:';
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function sameOrigin(candidate: string, issuer: string): boolean {
|
|
216
|
+
try {
|
|
217
|
+
return new URL(candidate).origin === new URL(issuer).origin;
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
170
223
|
/**
|
|
171
224
|
* One shape out of several spellings of the same three facts.
|
|
172
225
|
*
|
package/src/cli/brand.ts
CHANGED
|
@@ -160,6 +160,12 @@ a { color: inherit; }
|
|
|
160
160
|
*/
|
|
161
161
|
export const PAGE_CSP =
|
|
162
162
|
"frame-ancestors 'none'; default-src 'none'; " +
|
|
163
|
+
// `form-action` does **not** fall back to `default-src`, so `'none'` above
|
|
164
|
+
// says nothing about where a form may post. One page here posts the owner's
|
|
165
|
+
// endpoint token, and its `action` is built from the request's own `Host` —
|
|
166
|
+
// this is the second lock on that, so a form target that ever came from
|
|
167
|
+
// somewhere else is refused by the browser rather than followed.
|
|
168
|
+
"form-action 'self'; " +
|
|
163
169
|
"style-src 'unsafe-inline' https://fonts.googleapis.com; " +
|
|
164
170
|
'font-src https://fonts.gstatic.com';
|
|
165
171
|
|
|
@@ -11,7 +11,21 @@
|
|
|
11
11
|
# There is no build step: Bun runs TypeScript directly, so the image holds
|
|
12
12
|
# source, and `bun install` only fetches the third-party dependencies.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
# Pinned by digest, with the tag kept beside it so the next bump is legible.
|
|
15
|
+
#
|
|
16
|
+
# A tag is a pointer its publisher can move. `1.3.11-slim` is not a promise about
|
|
17
|
+
# bytes — it resolves to whatever was last pushed under that name — so a rebuild
|
|
18
|
+
# months from now can ship a base image nobody here reviewed, in a container that
|
|
19
|
+
# holds live OAuth refresh tokens. The digest is the bytes.
|
|
20
|
+
#
|
|
21
|
+
# `bunfig.toml` already makes this argument about npm packages, with a seven-day
|
|
22
|
+
# release-age floor; the base image is the larger half of the same supply chain
|
|
23
|
+
# and had nothing.
|
|
24
|
+
#
|
|
25
|
+
# To bump: change the tag, then resolve it —
|
|
26
|
+
# docker buildx imagetools inspect oven/bun:<tag> --format '{{.Manifest.Digest}}'
|
|
27
|
+
# The digest below is the multi-arch index, so it still resolves per architecture.
|
|
28
|
+
FROM oven/bun:1.3.11-slim@sha256:478281fdd196871c7e51ba6a820b7803a8ae97042ec86cdbc2e1c6b6626442d9
|
|
15
29
|
|
|
16
30
|
WORKDIR /app
|
|
17
31
|
|
|
@@ -42,7 +56,18 @@ COPY package.json bunfig.toml bun.lock* ./
|
|
|
42
56
|
# in `package.json` is an exact version, so the direct set is identical either
|
|
43
57
|
# way; the lockfile pins what those depend on in turn, and a published package
|
|
44
58
|
# has never been able to carry one.
|
|
45
|
-
|
|
59
|
+
#
|
|
60
|
+
# The fallback says so out loud. An unfrozen resolve is a real difference in what
|
|
61
|
+
# ships and it used to be silent — the build log looked identical either way, so
|
|
62
|
+
# "which transitive versions did this image get" was answerable only by opening
|
|
63
|
+
# the image. One line makes it answerable from the build.
|
|
64
|
+
RUN if [ -f bun.lock ]; then \
|
|
65
|
+
bun install --frozen-lockfile; \
|
|
66
|
+
else \
|
|
67
|
+
echo "no bun.lock in the build context: resolving transitive dependencies fresh." >&2; \
|
|
68
|
+
echo "Direct dependencies are exact in package.json, so the direct set is unchanged." >&2; \
|
|
69
|
+
bun install; \
|
|
70
|
+
fi
|
|
46
71
|
|
|
47
72
|
COPY src/ src/
|
|
48
73
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { installRoot, layout } from '#profile';
|
|
2
3
|
import type { DeployStep } from '../driver.ts';
|
|
3
4
|
import {
|
|
4
5
|
removalStep,
|
|
@@ -103,6 +104,68 @@ export function bucketGrants(bucket: string, profiles: readonly string[]): Condi
|
|
|
103
104
|
];
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
/**
|
|
108
|
+
* How long a deleted or overwritten object can still be recovered.
|
|
109
|
+
*
|
|
110
|
+
* The revision holds `objectAdmin` on everything under `data/`, and
|
|
111
|
+
* `objectAdmin` contains `storage.objects.delete`. That grant is correct — the
|
|
112
|
+
* endpoint writes state, memory, tasks, assets and the audit log, and rewriting
|
|
113
|
+
* an object is deleting the old one — but it means the process most exposed to
|
|
114
|
+
* the internet is also the one that can erase the record of what it did.
|
|
115
|
+
* `audit.tamper-evident` already says deleting a run whole is not *detectable*;
|
|
116
|
+
* without this it was not *recoverable* either.
|
|
117
|
+
*
|
|
118
|
+
* Thirty days rather than the platform's default seven, because the gap this
|
|
119
|
+
* closes is noticing late. A compromise found the same afternoon needs no
|
|
120
|
+
* retention policy at all.
|
|
121
|
+
*/
|
|
122
|
+
const SOFT_DELETE_DURATION = '30d';
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The three protections a deploy applies to the bucket every time it runs.
|
|
126
|
+
*
|
|
127
|
+
* `update` rather than flags on `create`, so they reach a bucket that already
|
|
128
|
+
* exists — see the call site. Idempotent: setting a policy to what it already is
|
|
129
|
+
* is a no-op that costs one API call.
|
|
130
|
+
*
|
|
131
|
+
* - **Public access prevention**, enforced. Nothing in this bucket is served to
|
|
132
|
+
* a browser and nothing in it should ever be anonymous-readable, so the useful
|
|
133
|
+
* setting is the one that makes granting that impossible rather than merely
|
|
134
|
+
* absent. Uniform bucket-level access already removes per-object ACLs; this
|
|
135
|
+
* removes the bucket-level way to do the same thing.
|
|
136
|
+
* - **Soft delete**, so a deletion is recoverable — see above.
|
|
137
|
+
* - **Object versioning**, with the lifecycle rule that bounds it. Versioning
|
|
138
|
+
* covers what soft delete does not: an object *overwritten* in place, where
|
|
139
|
+
* the previous content is the thing worth keeping. The rule is a file shipped
|
|
140
|
+
* beside this one rather than written at plan time, because `--dry-run` writes
|
|
141
|
+
* nothing and prints what it would run — a temporary file would break both.
|
|
142
|
+
*/
|
|
143
|
+
function durabilitySteps(bucket: string): DeployStep[] {
|
|
144
|
+
const lifecycle = join(installRoot(import.meta.dir), 'src/deployments/gcp/lifecycle.json');
|
|
145
|
+
|
|
146
|
+
return [
|
|
147
|
+
{
|
|
148
|
+
title: 'make the bucket unable to be shared publicly, and its deletions recoverable',
|
|
149
|
+
argv: [
|
|
150
|
+
'storage',
|
|
151
|
+
'buckets',
|
|
152
|
+
'update',
|
|
153
|
+
`gs://${bucket}`,
|
|
154
|
+
'--public-access-prevention',
|
|
155
|
+
'--soft-delete-duration',
|
|
156
|
+
SOFT_DELETE_DURATION,
|
|
157
|
+
'--versioning',
|
|
158
|
+
// Without this, versioning keeps every prior copy of every state key
|
|
159
|
+
// forever, and state is the one thing here that is rewritten rather than
|
|
160
|
+
// appended. The rule bounds it by age and by count.
|
|
161
|
+
'--lifecycle-file',
|
|
162
|
+
lifecycle,
|
|
163
|
+
],
|
|
164
|
+
tolerateFailure: true,
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
}
|
|
168
|
+
|
|
106
169
|
const TITLES: Record<string, string> = {
|
|
107
170
|
'owns-its-data': 'let the revision write its own data, but not the manifests in it',
|
|
108
171
|
'reads-its-config': 'let the revision read its config, and only read it',
|
|
@@ -133,20 +196,30 @@ export async function bucketSteps(input: {
|
|
|
133
196
|
// served publicly; uniform access removes per-object ACLs as a way to
|
|
134
197
|
// get that wrong.
|
|
135
198
|
'--uniform-bucket-level-access',
|
|
136
|
-
// Autoclass rather than a lifecycle rule, because no one
|
|
137
|
-
// right for this bucket: it holds the config the endpoint
|
|
138
|
-
// boot next to assets and audit rows nobody opens again.
|
|
139
|
-
// Autoclass bucket there are no retrieval and no
|
|
140
|
-
// which is what makes ARCHIVE safe as the floor
|
|
141
|
-
// never reading the thing again — a read pulls the
|
|
142
|
-
// Standard at no charge. Objects under 128 KiB never
|
|
143
|
-
// this costs the config and the log nothing and saves
|
|
199
|
+
// Autoclass rather than a storage-class lifecycle rule, because no one
|
|
200
|
+
// fixed class is right for this bucket: it holds the config the endpoint
|
|
201
|
+
// reads on every boot next to assets and audit rows nobody opens again.
|
|
202
|
+
// Inside an Autoclass bucket there are no retrieval and no
|
|
203
|
+
// early-deletion fees, which is what makes ARCHIVE safe as the floor
|
|
204
|
+
// rather than a bet on never reading the thing again — a read pulls the
|
|
205
|
+
// object back to Standard at no charge. Objects under 128 KiB never
|
|
206
|
+
// leave Standard, so this costs the config and the log nothing and saves
|
|
207
|
+
// on attachments.
|
|
144
208
|
'--enable-autoclass',
|
|
145
209
|
'--autoclass-terminal-storage-class',
|
|
146
210
|
'ARCHIVE',
|
|
147
211
|
],
|
|
148
212
|
tolerateFailure: true,
|
|
149
213
|
},
|
|
214
|
+
// Everything about durability, applied separately from the create.
|
|
215
|
+
//
|
|
216
|
+
// **Not folded into the flags above, and that is the whole point.** Every
|
|
217
|
+
// step here tolerates failure, so the create is refused as `ALREADY_EXISTS`
|
|
218
|
+
// on the second deploy onwards — which means a protection added to that
|
|
219
|
+
// argv reaches a bucket made after this commit and no other. Every
|
|
220
|
+
// deployment that already exists is exactly the one that has an audit log
|
|
221
|
+
// worth keeping.
|
|
222
|
+
...durabilitySteps(input.bucket),
|
|
150
223
|
];
|
|
151
224
|
|
|
152
225
|
if (!input.serviceAccount) return steps;
|
|
@@ -126,12 +126,43 @@ export function deployPlan(input: PlanInput): DeployStep[] {
|
|
|
126
126
|
// harness can mint one — so a target reached by a remote MCP client
|
|
127
127
|
// declares `public` and gates the request in the application instead.
|
|
128
128
|
cloudrun.access === 'iam' ? '--no-allow-unauthenticated' : '--allow-unauthenticated',
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
129
|
+
// What the platform will accept a connection from at all, which is a
|
|
130
|
+
// different question from who it lets through. An `iam` target is by
|
|
131
|
+
// definition not reached by an MCP client — no agent harness can mint the
|
|
132
|
+
// identity token Cloud Run wants — so it is reached by other cloud
|
|
133
|
+
// workloads or by nothing, and neither needs an internet-facing listener.
|
|
134
|
+
// `public` is the case where the listener *is* the point.
|
|
135
|
+
'--ingress',
|
|
136
|
+
cloudrun.access === 'iam' ? 'internal-and-cloud-load-balancing' : 'all',
|
|
137
|
+
// Always passed, including the zero and including every default below
|
|
138
|
+
// it. Config is the source of truth here (ADR-004), and a flag sent only
|
|
139
|
+
// when non-zero would let a value be raised and never lowered — the
|
|
140
|
+
// revision would keep whatever the last deploy that bothered to mention
|
|
141
|
+
// it had set.
|
|
133
142
|
'--min-instances',
|
|
134
143
|
String(cloudrun.min_instances),
|
|
144
|
+
// The five that used to be absent, and absent meant the platform's own
|
|
145
|
+
// defaults: a hundred instances, eighty concurrent requests each, and
|
|
146
|
+
// 512 MiB to serve a 64 MiB upload in. Each one is argued at its field in
|
|
147
|
+
// `deployTargetSchema`; what they have in common is that a public URL
|
|
148
|
+
// with no ceiling on any of them is an endpoint whose cost and whose
|
|
149
|
+
// credential-store traffic are decided by whoever is calling it.
|
|
150
|
+
'--max-instances',
|
|
151
|
+
String(cloudrun.max_instances),
|
|
152
|
+
'--concurrency',
|
|
153
|
+
String(cloudrun.concurrency),
|
|
154
|
+
'--timeout',
|
|
155
|
+
String(cloudrun.timeout_seconds),
|
|
156
|
+
'--memory',
|
|
157
|
+
cloudrun.memory,
|
|
158
|
+
'--cpu',
|
|
159
|
+
cloudrun.cpu,
|
|
160
|
+
// Named rather than inherited, like the five above. gen2 is the current
|
|
161
|
+
// default and the one this image is tested on; pinning it means a
|
|
162
|
+
// platform migration is a commit here rather than a change under a
|
|
163
|
+
// running endpoint.
|
|
164
|
+
'--execution-environment',
|
|
165
|
+
'gen2',
|
|
135
166
|
],
|
|
136
167
|
},
|
|
137
168
|
];
|
|
@@ -151,7 +182,14 @@ export function deployPlan(input: PlanInput): DeployStep[] {
|
|
|
151
182
|
*/
|
|
152
183
|
function secretMounts(secretEnv: PlanInput['secretEnv']): string[] {
|
|
153
184
|
const entries = Object.entries(secretEnv ?? {});
|
|
154
|
-
|
|
185
|
+
|
|
186
|
+
// `--clear-secrets`, not nothing. `gcloud run deploy` leaves a setting it is
|
|
187
|
+
// not told about exactly as the last revision had it, so an empty map used to
|
|
188
|
+
// mean "keep whatever is mounted" rather than "mount nothing" — and removing a
|
|
189
|
+
// vault from config left its secret still resolved into the new revision's
|
|
190
|
+
// environment, indefinitely, with nothing in the config saying so. The same
|
|
191
|
+
// argument `--min-instances` makes about always passing the zero.
|
|
192
|
+
if (entries.length === 0) return ['--clear-secrets'];
|
|
155
193
|
|
|
156
194
|
return [
|
|
157
195
|
'--set-secrets',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigError, type DeployConfig, type TargetConfig } from '#profile';
|
|
1
|
+
import { ConfigError, DEPLOY_DEFAULTS, type DeployConfig, type TargetConfig } from '#profile';
|
|
2
2
|
import { heading, print, style, waiting } from '#cli/output.ts';
|
|
3
3
|
import { ask, confirm } from '#cli/prompt.ts';
|
|
4
4
|
import type { SurveyInput, SurveyResult } from '../driver.ts';
|
|
@@ -130,6 +130,17 @@ export async function surveyCloudRun(input: SurveyInput): Promise<SurveyResult>
|
|
|
130
130
|
// Not asked about. Zero is right for almost every target and the question
|
|
131
131
|
// would cost every operator a decision to buy one of them a knob.
|
|
132
132
|
min_instances: current.min_instances ?? 0,
|
|
133
|
+
// Not asked about either, and for a stronger version of the same reason: a
|
|
134
|
+
// ceiling is only interesting to somebody who has already hit it, and the
|
|
135
|
+
// defaults are the ones a single-user endpoint wants. Carried through from
|
|
136
|
+
// what the target already says so that an operator who *has* edited them
|
|
137
|
+
// keeps their edit — pressing return through the survey changes nothing,
|
|
138
|
+
// which is the property every other field here has too.
|
|
139
|
+
max_instances: current.max_instances ?? DEPLOY_DEFAULTS.max_instances,
|
|
140
|
+
concurrency: current.concurrency ?? DEPLOY_DEFAULTS.concurrency,
|
|
141
|
+
timeout_seconds: current.timeout_seconds ?? DEPLOY_DEFAULTS.timeout_seconds,
|
|
142
|
+
memory: current.memory ?? DEPLOY_DEFAULTS.memory,
|
|
143
|
+
cpu: current.cpu ?? DEPLOY_DEFAULTS.cpu,
|
|
133
144
|
project,
|
|
134
145
|
region,
|
|
135
146
|
service,
|
package/src/policy/limits.ts
CHANGED
|
@@ -27,6 +27,29 @@ interface Bucket {
|
|
|
27
27
|
lastRefill: number;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* How many keys one limiter will hold.
|
|
32
|
+
*
|
|
33
|
+
* The bound exists because a key is not always something the endpoint chose. At
|
|
34
|
+
* the HTTP edge a caller is identified by the first `X-Forwarded-For` hop, which
|
|
35
|
+
* on a public deployment is a header a stranger writes — so an unbounded map is
|
|
36
|
+
* an unbounded allocation driven from outside, in a container that now has an
|
|
37
|
+
* explicit memory limit to exceed.
|
|
38
|
+
*
|
|
39
|
+
* Ten thousand is far above any real caller count for a single-user endpoint and
|
|
40
|
+
* far below a problem: a bucket is two numbers and a string key.
|
|
41
|
+
*/
|
|
42
|
+
const DEFAULT_MAX_KEYS = 10_000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How many go at once when the cap is reached.
|
|
46
|
+
*
|
|
47
|
+
* A batch rather than one, so the sort that finds them is amortised. Evicting a
|
|
48
|
+
* single key per overflow would run an O(n log n) pass on every request once the
|
|
49
|
+
* map is full, which turns the bound into its own denial of service.
|
|
50
|
+
*/
|
|
51
|
+
const EVICTION_FRACTION = 10;
|
|
52
|
+
|
|
30
53
|
/**
|
|
31
54
|
* Token bucket, refilled continuously rather than on a fixed window boundary.
|
|
32
55
|
*
|
|
@@ -37,9 +60,16 @@ interface Bucket {
|
|
|
37
60
|
export class RateLimiter {
|
|
38
61
|
readonly #buckets = new Map<string, Bucket>();
|
|
39
62
|
readonly #now: () => number;
|
|
63
|
+
readonly #maxKeys: number;
|
|
40
64
|
|
|
41
|
-
constructor(now: () => number = Date.now) {
|
|
65
|
+
constructor(now: () => number = Date.now, maxKeys: number = DEFAULT_MAX_KEYS) {
|
|
42
66
|
this.#now = now;
|
|
67
|
+
this.#maxKeys = maxKeys;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** How many callers are currently held. For tests, and for nothing else. */
|
|
71
|
+
get size(): number {
|
|
72
|
+
return this.#buckets.size;
|
|
43
73
|
}
|
|
44
74
|
|
|
45
75
|
/**
|
|
@@ -52,7 +82,13 @@ export class RateLimiter {
|
|
|
52
82
|
|
|
53
83
|
const now = this.#now();
|
|
54
84
|
const refillPerMs = perMinute / 60_000;
|
|
55
|
-
const
|
|
85
|
+
const existing = this.#buckets.get(key);
|
|
86
|
+
|
|
87
|
+
// Before the insert, not after: the cap is on what this map holds, and
|
|
88
|
+
// checking afterwards means it is briefly one over on every overflow.
|
|
89
|
+
if (!existing) this.#makeRoom();
|
|
90
|
+
|
|
91
|
+
const bucket = existing ?? { tokens: perMinute, lastRefill: now };
|
|
56
92
|
|
|
57
93
|
bucket.tokens = Math.min(perMinute, bucket.tokens + (now - bucket.lastRefill) * refillPerMs);
|
|
58
94
|
bucket.lastRefill = now;
|
|
@@ -67,11 +103,48 @@ export class RateLimiter {
|
|
|
67
103
|
return { allowed: true, retryAfterMs: 0 };
|
|
68
104
|
}
|
|
69
105
|
|
|
70
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Drop idle buckets so a long-lived process does not accumulate keys forever.
|
|
108
|
+
*
|
|
109
|
+
* Public because it reads as the obvious lever, and because the tests drive it
|
|
110
|
+
* directly. It is **not** what bounds the map, and nothing's correctness may
|
|
111
|
+
* depend on a caller remembering it: for most of this file's life nothing did
|
|
112
|
+
* call it, `edge.ts` claimed idle callers were dropped, and the map grew for
|
|
113
|
+
* as long as the process lived. `#makeRoom` is the bound now, and it runs on
|
|
114
|
+
* the insert path where it cannot be forgotten.
|
|
115
|
+
*/
|
|
71
116
|
prune(idleMs = 300_000): void {
|
|
72
117
|
const cutoff = this.#now() - idleMs;
|
|
73
118
|
for (const [key, bucket] of this.#buckets) {
|
|
74
119
|
if (bucket.lastRefill < cutoff) this.#buckets.delete(key);
|
|
75
120
|
}
|
|
76
121
|
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Make space for one more key, if the map is full.
|
|
125
|
+
*
|
|
126
|
+
* Idle callers first, because dropping one costs nothing — a bucket that has
|
|
127
|
+
* not been touched in five minutes has refilled to full, so re-creating it
|
|
128
|
+
* gives back exactly what was discarded.
|
|
129
|
+
*
|
|
130
|
+
* Only when that is not enough does this evict a live caller, oldest first by
|
|
131
|
+
* last use. That *does* forgive whatever the evicted caller had spent, which
|
|
132
|
+
* is the honest cost of a bounded map: an attacker who can mint ten thousand
|
|
133
|
+
* distinct keys can push their own bucket out and start again. What they
|
|
134
|
+
* cannot do is grow the map, and on the paths this limiter guards there is a
|
|
135
|
+
* second bucket keyed on nothing at all — see `edge.ts` — which is the one
|
|
136
|
+
* that holds when the per-caller key is worthless.
|
|
137
|
+
*/
|
|
138
|
+
#makeRoom(): void {
|
|
139
|
+
if (this.#buckets.size < this.#maxKeys) return;
|
|
140
|
+
|
|
141
|
+
this.prune();
|
|
142
|
+
if (this.#buckets.size < this.#maxKeys) return;
|
|
143
|
+
|
|
144
|
+
const oldest = [...this.#buckets.entries()]
|
|
145
|
+
.sort((a, b) => a[1].lastRefill - b[1].lastRefill)
|
|
146
|
+
.slice(0, Math.max(1, Math.ceil(this.#maxKeys / EVICTION_FRACTION)));
|
|
147
|
+
|
|
148
|
+
for (const [key] of oldest) this.#buckets.delete(key);
|
|
149
|
+
}
|
|
77
150
|
}
|
package/src/profile/index.ts
CHANGED
package/src/profile/legacy.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import {
|
|
3
3
|
auditTargetSchema,
|
|
4
4
|
credentialsTargetSchema,
|
|
5
|
+
DEPLOY_DEFAULTS,
|
|
5
6
|
deployTargetSchema,
|
|
6
7
|
storageTargetSchema,
|
|
7
8
|
vaultTargetSchema,
|
|
@@ -54,14 +55,18 @@ export const legacyTargetSchema = z
|
|
|
54
55
|
? target
|
|
55
56
|
: {
|
|
56
57
|
...target,
|
|
57
|
-
// The pre-`deploy` spelling predates
|
|
58
|
-
// same defaults the current one would: the closed door,
|
|
59
|
-
//
|
|
58
|
+
// The pre-`deploy` spelling predates all of these, so it gets the
|
|
59
|
+
// same defaults the current one would: the closed door, no instance
|
|
60
|
+
// kept warm, and the ceilings a public URL is deployed under. Built by
|
|
61
|
+
// hand here rather than parsed, so `DEPLOY_DEFAULTS` is spread rather
|
|
62
|
+
// than left to zod — a block that reaches `deployPlan` without them
|
|
63
|
+
// sends `undefined` to `gcloud` as the string "undefined".
|
|
60
64
|
deploy: {
|
|
61
65
|
...cloudrun,
|
|
62
66
|
platform: 'cloudrun' as const,
|
|
63
67
|
access: 'iam' as const,
|
|
64
68
|
min_instances: 0,
|
|
69
|
+
...DEPLOY_DEFAULTS,
|
|
65
70
|
},
|
|
66
71
|
},
|
|
67
72
|
);
|
package/src/profile/schema.ts
CHANGED
|
@@ -158,6 +158,25 @@ export const vaultTargetSchema = z.object({
|
|
|
158
158
|
* discriminated union per platform — buys precision this file cannot use and
|
|
159
159
|
* costs a schema edit on every field any host ever adds.
|
|
160
160
|
*/
|
|
161
|
+
/**
|
|
162
|
+
* The ceilings a deployed revision runs under, in one place.
|
|
163
|
+
*
|
|
164
|
+
* A named constant rather than five literals inside `z.default()` because two
|
|
165
|
+
* callers need the same numbers and neither can read a zod default out of a
|
|
166
|
+
* schema: the survey writes a target's block field by field, and the deploy plan
|
|
167
|
+
* sends every one of them on every rollout. Two spellings of a ceiling is a
|
|
168
|
+
* ceiling that is one value in a fresh profile and another in a surveyed one.
|
|
169
|
+
*
|
|
170
|
+
* Each is argued at its own field below.
|
|
171
|
+
*/
|
|
172
|
+
export const DEPLOY_DEFAULTS = {
|
|
173
|
+
max_instances: 4,
|
|
174
|
+
concurrency: 40,
|
|
175
|
+
timeout_seconds: 300,
|
|
176
|
+
memory: '1Gi',
|
|
177
|
+
cpu: '1',
|
|
178
|
+
} as const;
|
|
179
|
+
|
|
161
180
|
export const deployTargetSchema = z.object({
|
|
162
181
|
platform: z.enum(['cloudrun']),
|
|
163
182
|
// Non-empty here rather than in a referential check further down. Under
|
|
@@ -210,6 +229,52 @@ export const deployTargetSchema = z.object({
|
|
|
210
229
|
* Raise it if a re-authorization ever lines up with a cold `/token`.
|
|
211
230
|
*/
|
|
212
231
|
min_instances: z.number().int().min(0).max(10).default(0),
|
|
232
|
+
/**
|
|
233
|
+
* The ceiling on instances, and the only thing bounding what a public URL can
|
|
234
|
+
* spend.
|
|
235
|
+
*
|
|
236
|
+
* Four, not the platform's hundred. `access: public` is a routable address on
|
|
237
|
+
* the internet, and every instance that starts reads the credential store and
|
|
238
|
+
* lists the bucket — so scaling out multiplies cost *and* traffic against the
|
|
239
|
+
* two things this endpoint most wants kept quiet. A single-user endpoint that
|
|
240
|
+
* genuinely needs a fifth concurrent instance has an agent in a loop, which is
|
|
241
|
+
* the case the ceiling is for.
|
|
242
|
+
*
|
|
243
|
+
* It is also what makes `limits.requests_per_minute` mean something in
|
|
244
|
+
* aggregate. Those limits are per instance and always were; with no ceiling
|
|
245
|
+
* the aggregate had no value at all, and `docs/detailed/deployment-cloudrun.md`
|
|
246
|
+
* told the reader to cap this themselves because nothing here did.
|
|
247
|
+
*/
|
|
248
|
+
max_instances: z.number().int().min(1).max(100).default(DEPLOY_DEFAULTS.max_instances),
|
|
249
|
+
/**
|
|
250
|
+
* Requests one instance serves at once.
|
|
251
|
+
*
|
|
252
|
+
* Forty rather than the platform's eighty, paired with `memory` below: an
|
|
253
|
+
* attachment upload is capped at 64 MiB and is buffered before it is written,
|
|
254
|
+
* so what bounds this is memory per instance rather than CPU.
|
|
255
|
+
*/
|
|
256
|
+
concurrency: z.number().int().min(1).max(1000).default(DEPLOY_DEFAULTS.concurrency),
|
|
257
|
+
/**
|
|
258
|
+
* How long one request may run before the platform cuts it.
|
|
259
|
+
*
|
|
260
|
+
* The platform's own default, stated rather than inherited — the point is that
|
|
261
|
+
* it is written down and always sent, so a platform changing its default is
|
|
262
|
+
* not a silent change to what this serves.
|
|
263
|
+
*/
|
|
264
|
+
timeout_seconds: z.number().int().min(1).max(3600).default(DEPLOY_DEFAULTS.timeout_seconds),
|
|
265
|
+
/**
|
|
266
|
+
* Memory per instance.
|
|
267
|
+
*
|
|
268
|
+
* A gigabyte because 512 MiB is not enough for what the endpoint already
|
|
269
|
+
* accepts: `MAX_UPLOAD_BYTES` is 64 MiB, and staging one costs roughly twice
|
|
270
|
+
* that at peak — the chunks as they arrive, and the single buffer they are
|
|
271
|
+
* copied into. At the platform default that is one upload away from an
|
|
272
|
+
* out-of-memory kill, and an OOM is a 503 for every request the instance was
|
|
273
|
+
* also serving.
|
|
274
|
+
*/
|
|
275
|
+
memory: z.string().min(1).default(DEPLOY_DEFAULTS.memory),
|
|
276
|
+
/** CPU per instance. Explicit for the same reason `timeout_seconds` is. */
|
|
277
|
+
cpu: z.string().min(1).default(DEPLOY_DEFAULTS.cpu),
|
|
213
278
|
});
|
|
214
279
|
|
|
215
280
|
/**
|
package/src/server/edge.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { challenge, type AuthOutcome, type ChallengeError } from '#auth';
|
|
1
2
|
import { RateLimiter } from '#policy';
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -15,6 +16,56 @@ import { RateLimiter } from '#policy';
|
|
|
15
16
|
*/
|
|
16
17
|
export const FAILED_AUTH_PER_MINUTE = 30;
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* A ceiling on the surface that answers *before* authentication.
|
|
21
|
+
*
|
|
22
|
+
* The limit above sits behind the bearer gate, and for a long time that was the
|
|
23
|
+
* whole of it — which left the four things that answer in front of the gate with
|
|
24
|
+
* no ceiling at all. On a `public` deployment every one of them costs the owner
|
|
25
|
+
* something, and none of them needs a credential to reach:
|
|
26
|
+
*
|
|
27
|
+
* - `/health` presented with a credential re-reads the credential store, which
|
|
28
|
+
* on a deployed target is a Secret Manager call — two, when the value the
|
|
29
|
+
* process cached does not match, because a mismatch against a cached value
|
|
30
|
+
* forces the re-read that makes a rotation take effect.
|
|
31
|
+
* - `/register` writes an object to the workspace bucket, then lists two
|
|
32
|
+
* namespaces to decide whether anything needs evicting.
|
|
33
|
+
* - `/authorize` compares against the endpoint token, which is another read of
|
|
34
|
+
* the credential store.
|
|
35
|
+
* - `/token` reads and writes bucket objects.
|
|
36
|
+
*
|
|
37
|
+
* `/health` presented with *no* credential is deliberately free: it reads
|
|
38
|
+
* nothing, and it is what a platform probe and `lanes link outputs` send.
|
|
39
|
+
* Metering it would put a ceiling on the one request that costs nothing to
|
|
40
|
+
* answer.
|
|
41
|
+
*/
|
|
42
|
+
export const UNAUTHENTICATED_PER_MINUTE = 30;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The same ceiling for the endpoint as a whole, keyed on nothing.
|
|
46
|
+
*
|
|
47
|
+
* **This is the one that actually holds.** The per-caller key below is the first
|
|
48
|
+
* `X-Forwarded-For` hop, which anyone talking to the endpoint directly can write
|
|
49
|
+
* as they please — so a per-caller limit alone bounds only a caller who is not
|
|
50
|
+
* trying, and rotating the header walks straight through it.
|
|
51
|
+
*
|
|
52
|
+
* A shared bucket has the opposite problem: whoever is spending it locks
|
|
53
|
+
* everyone else out, which is why `index.ts` refuses to key the *failed-auth*
|
|
54
|
+
* limit that way. Here the trade is different, because what is behind these
|
|
55
|
+
* paths is not the owner's ability to use their endpoint — a client that has
|
|
56
|
+
* already authorised holds a token and never comes back through them — it is a
|
|
57
|
+
* discovery document, a registration, and a consent screen. Losing those for a
|
|
58
|
+
* minute is an authorization that has to be retried. Not losing them costs a
|
|
59
|
+
* stranger's arbitrary spend against the credential store.
|
|
60
|
+
*
|
|
61
|
+
* Two hundred a minute is far above what an authorization flow uses: a connector
|
|
62
|
+
* being added is a handful of requests, once.
|
|
63
|
+
*/
|
|
64
|
+
export const UNAUTHENTICATED_TOTAL_PER_MINUTE = 200;
|
|
65
|
+
|
|
66
|
+
/** The key the endpoint-wide bucket is held under. Constant on purpose. */
|
|
67
|
+
const EVERYONE = 'endpoint';
|
|
68
|
+
|
|
18
69
|
/**
|
|
19
70
|
* Who an attempt is counted against.
|
|
20
71
|
*
|
|
@@ -47,7 +98,143 @@ export function tooManyAttempts(retryAfterMs: number): Response {
|
|
|
47
98
|
);
|
|
48
99
|
}
|
|
49
100
|
|
|
50
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* One bucket set per endpoint.
|
|
103
|
+
*
|
|
104
|
+
* The map is bounded by `RateLimiter` itself rather than by a caller
|
|
105
|
+
* remembering to prune it. This comment used to say idle callers were dropped
|
|
106
|
+
* "so keys do not accumulate", and nothing anywhere called `prune` — so on a
|
|
107
|
+
* public URL the map grew one entry per distinct `X-Forwarded-For` for as long
|
|
108
|
+
* as the process lived, which is a header a stranger writes.
|
|
109
|
+
*/
|
|
51
110
|
export function failedAuthLimiter(): RateLimiter {
|
|
52
111
|
return new RateLimiter();
|
|
53
112
|
}
|
|
113
|
+
|
|
114
|
+
/** The same, for the pre-authentication surface. Separate so one cannot spend the other. */
|
|
115
|
+
export function unauthenticatedLimiter(): RateLimiter {
|
|
116
|
+
return new RateLimiter();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The refusal for a pre-authentication request over budget, or `undefined` to
|
|
121
|
+
* let it through.
|
|
122
|
+
*
|
|
123
|
+
* One function rather than a branch in the router, for the reason `./cors.ts`
|
|
124
|
+
* and `./oauth.ts` are their own files: `index.ts` gains a delegation instead of
|
|
125
|
+
* the whole of this behaviour, and it stays inside the size budget that would
|
|
126
|
+
* otherwise be the rule relaxed to fit this in.
|
|
127
|
+
*
|
|
128
|
+
* `isAuthorizationPath` is passed rather than imported so this file does not
|
|
129
|
+
* reach back into the router's path constants — `corsAware` takes the same shape
|
|
130
|
+
* for the same reason.
|
|
131
|
+
*
|
|
132
|
+
* Two buckets are taken rather than one short-circuiting the other, so a caller
|
|
133
|
+
* that has exhausted its own budget still counts against the endpoint's: the
|
|
134
|
+
* alternative lets a flood of distinct forwarded-for values leave the shared
|
|
135
|
+
* bucket untouched, which is precisely the case the shared bucket exists for.
|
|
136
|
+
*/
|
|
137
|
+
export function unauthenticatedRefusal(input: {
|
|
138
|
+
readonly request: Request;
|
|
139
|
+
readonly pathname: string;
|
|
140
|
+
readonly limiter: RateLimiter;
|
|
141
|
+
readonly healthPath: string;
|
|
142
|
+
readonly isAuthorizationPath: (pathname: string) => boolean;
|
|
143
|
+
readonly authorizationEnabled: boolean;
|
|
144
|
+
}): Response | undefined {
|
|
145
|
+
// A `/health` carrying no credential reads nothing and is deliberately free —
|
|
146
|
+
// it is what a platform probe and `lanes link outputs` send, and a ceiling on
|
|
147
|
+
// the one request that costs nothing to answer is an outage waiting for an
|
|
148
|
+
// attack that did not have to cause one.
|
|
149
|
+
const costly =
|
|
150
|
+
input.pathname === input.healthPath
|
|
151
|
+
? input.request.headers.get('authorization') !== null
|
|
152
|
+
: input.authorizationEnabled && input.isAuthorizationPath(input.pathname);
|
|
153
|
+
|
|
154
|
+
if (!costly) return undefined;
|
|
155
|
+
|
|
156
|
+
const caller = callerKey(input.request);
|
|
157
|
+
const mine = input.limiter.take(`caller:${caller}`, UNAUTHENTICATED_PER_MINUTE);
|
|
158
|
+
const everyone = input.limiter.take(EVERYONE, UNAUTHENTICATED_TOTAL_PER_MINUTE);
|
|
159
|
+
if (mine.allowed && everyone.allowed) return undefined;
|
|
160
|
+
|
|
161
|
+
return tooManyAttempts(Math.max(mine.retryAfterMs, everyone.retryAfterMs));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
type RefusalReason = Extract<AuthOutcome, { ok: false }>['reason'];
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* What a caller should do about each refusal.
|
|
168
|
+
*
|
|
169
|
+
* `invalid` is the only one a client can act on by itself: it presented a
|
|
170
|
+
* credential and this endpoint did not accept it, which is what a refresh is
|
|
171
|
+
* for. RFC 6750 §3.1 has a name for that and clients branch on it; the others
|
|
172
|
+
* mean there is nothing to refresh, and §3 says to stay quiet rather than send
|
|
173
|
+
* a client after a token it does not hold. `malformed` says nothing either —
|
|
174
|
+
* `invalid_request` carries a SHOULD of a 400 status, and changing that path's
|
|
175
|
+
* status is a larger question than this answers.
|
|
176
|
+
*/
|
|
177
|
+
const CHALLENGE: Partial<Record<RefusalReason, ChallengeError>> = {
|
|
178
|
+
invalid: {
|
|
179
|
+
code: 'invalid_token',
|
|
180
|
+
description: 'The credential is expired, revoked, or not one this endpoint issued.',
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** The same four, for whoever is reading the body rather than the header. */
|
|
185
|
+
const HINTS: Record<RefusalReason, string> = {
|
|
186
|
+
missing: 'Present the profile token as: Authorization: Bearer <token>',
|
|
187
|
+
malformed: 'Present the profile token as: Authorization: Bearer <token>',
|
|
188
|
+
invalid: 'Refresh the credential. Authorize again only if the refresh is refused too.',
|
|
189
|
+
not_configured: 'This profile has no token yet. Run: lanes link token rotate',
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The `401`, with whatever the caller can act on.
|
|
194
|
+
*
|
|
195
|
+
* Here rather than in the router because it is the same subject as the two
|
|
196
|
+
* ceilings above — what this endpoint does about a caller who has not
|
|
197
|
+
* authenticated — and because the router was over its size budget carrying both.
|
|
198
|
+
* The vocabulary is RFC 6750's on the header and plain English in the body, so
|
|
199
|
+
* whoever is reading a terminal and whoever is writing a client each get the
|
|
200
|
+
* version they can use.
|
|
201
|
+
*/
|
|
202
|
+
function unauthorized(reason: RefusalReason, metadataUrl: string | null): Response {
|
|
203
|
+
return new Response(
|
|
204
|
+
JSON.stringify({ error: 'unauthorized', reason, hint: HINTS[reason] }),
|
|
205
|
+
{
|
|
206
|
+
status: 401,
|
|
207
|
+
headers: {
|
|
208
|
+
'content-type': 'application/json',
|
|
209
|
+
'www-authenticate': challenge(metadataUrl, CHALLENGE[reason]),
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* What a caller who failed authentication gets: the `401`, or the `429` once
|
|
217
|
+
* they have failed too often.
|
|
218
|
+
*
|
|
219
|
+
* The ceiling is spent **after** the attempt rather than before it. Keyed on the
|
|
220
|
+
* caller alone, anyone able to reach the endpoint could spend the owner's budget
|
|
221
|
+
* and lock them out, which trades a cost problem for a worse availability one.
|
|
222
|
+
* Only a failure consumes a token, so a valid credential is never refused by
|
|
223
|
+
* this.
|
|
224
|
+
*
|
|
225
|
+
* `metadataUrl` is the whole handshake for a remote client: it reads the named
|
|
226
|
+
* document, finds the authorization server, and starts a flow. Without it the
|
|
227
|
+
* client has to guess the document's location, and a client that guesses wrong
|
|
228
|
+
* reports the endpoint as unreachable.
|
|
229
|
+
*/
|
|
230
|
+
export function authRefusal(input: {
|
|
231
|
+
readonly request: Request;
|
|
232
|
+
readonly reason: RefusalReason;
|
|
233
|
+
readonly limiter: RateLimiter;
|
|
234
|
+
readonly metadataUrl: string | null;
|
|
235
|
+
}): Response {
|
|
236
|
+
const budget = input.limiter.take(callerKey(input.request), FAILED_AUTH_PER_MINUTE);
|
|
237
|
+
return budget.allowed
|
|
238
|
+
? unauthorized(input.reason, input.metadataUrl)
|
|
239
|
+
: tooManyAttempts(budget.retryAfterMs);
|
|
240
|
+
}
|
package/src/server/harness.ts
CHANGED
|
@@ -116,6 +116,15 @@ export interface HarnessOptions {
|
|
|
116
116
|
* serving the old generation" case is reached; absent means nothing new.
|
|
117
117
|
*/
|
|
118
118
|
reopen?: () => Promise<ReadonlyMap<string, ProfileRuntime>>;
|
|
119
|
+
/**
|
|
120
|
+
* Meter the pre-authentication surface, as a routable deployment does.
|
|
121
|
+
*
|
|
122
|
+
* A harness binds loopback, where `serve()` leaves this off — the ceiling
|
|
123
|
+
* protects a credential-store call over the network and an object written to a
|
|
124
|
+
* bucket, and on loopback both are a local file. Set it to drive the deployed
|
|
125
|
+
* behaviour without a deployment.
|
|
126
|
+
*/
|
|
127
|
+
meterUnauthenticated?: boolean;
|
|
119
128
|
}
|
|
120
129
|
|
|
121
130
|
/**
|
|
@@ -254,6 +263,7 @@ export function startHarness(options: HarnessOptions): Harness {
|
|
|
254
263
|
primary: options.profile,
|
|
255
264
|
authenticator: gate ? new AuthenticatorChain([bearer, gate.authenticator]) : bearer,
|
|
256
265
|
...(gate ? { authorization: gate.surface } : {}),
|
|
266
|
+
...(options.meterUnauthenticated ? { meterUnauthenticated: true } : {}),
|
|
257
267
|
log,
|
|
258
268
|
});
|
|
259
269
|
|
package/src/server/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Authenticator } from '#auth';
|
|
2
2
|
import type { Logger } from '#connectivity';
|
|
3
3
|
import { capabilityIdForToolName } from '#server/mcp';
|
|
4
4
|
import { ATTACHMENTS_PATH, stageAttachment } from './attachments.ts';
|
|
@@ -6,7 +6,12 @@ import { allowedHostnamesFor, rebindingRefusal } from './rebinding.ts';
|
|
|
6
6
|
import { ANY_ORIGIN, corsAware, type CorsPolicy } from './cors.ts';
|
|
7
7
|
import type { Generation } from './generation.ts';
|
|
8
8
|
import type { Generations } from './generations.ts';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
authRefusal,
|
|
11
|
+
failedAuthLimiter,
|
|
12
|
+
unauthenticatedLimiter,
|
|
13
|
+
unauthenticatedRefusal,
|
|
14
|
+
} from './edge.ts';
|
|
10
15
|
import {
|
|
11
16
|
handleAuthorization,
|
|
12
17
|
isAuthorizationPath,
|
|
@@ -50,38 +55,29 @@ export interface ServerOptions {
|
|
|
50
55
|
readonly authorization?: AuthorizationSurface | undefined;
|
|
51
56
|
/** Hostnames this endpoint answers to. See `./rebinding.ts`. */
|
|
52
57
|
readonly allowedHostnames?: readonly string[] | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Meter the surface that answers before authentication.
|
|
60
|
+
*
|
|
61
|
+
* A property of what this is bound to, exactly as `cors` and
|
|
62
|
+
* `allowedHostnames` are, and decided in the same lines of `serve()`. Off on
|
|
63
|
+
* loopback, and that is not a gap: what the ceiling protects is a
|
|
64
|
+
* credential-store call over the network and an object written to a bucket,
|
|
65
|
+
* and on loopback both are a local file belonging to whoever is already
|
|
66
|
+
* standing at the machine. `./rebinding.ts` refuses the one caller that is not
|
|
67
|
+
* — a page the owner happens to be visiting — before this would be reached.
|
|
68
|
+
*/
|
|
69
|
+
readonly meterUnauthenticated?: boolean | undefined;
|
|
53
70
|
}
|
|
54
71
|
|
|
55
|
-
type RefusalReason = Extract<AuthOutcome, { ok: false }>['reason'];
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* What a caller should do about each refusal.
|
|
59
|
-
*
|
|
60
|
-
* `invalid` is the only one a client can act on by itself: it presented a
|
|
61
|
-
* credential and this endpoint did not accept it, which is what a refresh is
|
|
62
|
-
* for. RFC 6750 §3.1 has a name for that and clients branch on it; the others
|
|
63
|
-
* mean there is nothing to refresh, and §3 says to stay quiet rather than send
|
|
64
|
-
* a client after a token it does not hold. `malformed` says nothing either —
|
|
65
|
-
* `invalid_request` carries a SHOULD of a 400 status, and changing that path's
|
|
66
|
-
* status is a larger question than this answers.
|
|
67
|
-
*/
|
|
68
|
-
const CHALLENGE: Partial<Record<RefusalReason, ChallengeError>> = {
|
|
69
|
-
invalid: {
|
|
70
|
-
code: 'invalid_token',
|
|
71
|
-
description: 'The credential is expired, revoked, or not one this endpoint issued.',
|
|
72
|
-
},
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
/** The same four, for whoever is reading the body rather than the header. */
|
|
76
|
-
const HINTS: Record<RefusalReason, string> = {
|
|
77
|
-
missing: 'Present the profile token as: Authorization: Bearer <token>',
|
|
78
|
-
malformed: 'Present the profile token as: Authorization: Bearer <token>',
|
|
79
|
-
invalid: 'Refresh the credential. Authorize again only if the refresh is refused too.',
|
|
80
|
-
not_configured: 'This profile has no token yet. Run: lanes link token rotate',
|
|
81
|
-
};
|
|
82
|
-
|
|
83
72
|
export const MCP_PATH = '/mcp';
|
|
84
73
|
export const RELOAD_PATH = '/reload';
|
|
74
|
+
/**
|
|
75
|
+
* Named rather than inline, because two places now decide about it: the branch
|
|
76
|
+
* that answers it, and the ceiling in front of that branch which has to know
|
|
77
|
+
* that a `/health` carrying a credential is not the free request the other one
|
|
78
|
+
* is.
|
|
79
|
+
*/
|
|
80
|
+
export const HEALTH_PATH = '/health';
|
|
85
81
|
|
|
86
82
|
/**
|
|
87
83
|
* Loopback addresses. What binding to one changes is the browser threat model,
|
|
@@ -111,6 +107,7 @@ export interface RequestHandler {
|
|
|
111
107
|
export function createRequestHandler(options: ServerOptions): RequestHandler {
|
|
112
108
|
let probedAt = 0;
|
|
113
109
|
const failedAuth = failedAuthLimiter();
|
|
110
|
+
const unauthenticated = options.meterUnauthenticated ? unauthenticatedLimiter() : null;
|
|
114
111
|
|
|
115
112
|
/**
|
|
116
113
|
* Re-read the config because a call named a tool we do not serve.
|
|
@@ -144,6 +141,27 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
|
|
|
144
141
|
|
|
145
142
|
const url = new URL(request.url);
|
|
146
143
|
|
|
144
|
+
// Ahead of every path that answers without a credential, because the
|
|
145
|
+
// ceiling further down is inside the `401` branch and so has never covered
|
|
146
|
+
// any of them. Each costs a read of the credential store or a write to the
|
|
147
|
+
// workspace bucket, and none of them asks who is calling first. See
|
|
148
|
+
// `./edge.ts` for which ones, and for why `/health` is only sometimes
|
|
149
|
+
// among them.
|
|
150
|
+
if (unauthenticated) {
|
|
151
|
+
const refusal = unauthenticatedRefusal({
|
|
152
|
+
request,
|
|
153
|
+
pathname: url.pathname,
|
|
154
|
+
limiter: unauthenticated,
|
|
155
|
+
healthPath: HEALTH_PATH,
|
|
156
|
+
isAuthorizationPath,
|
|
157
|
+
authorizationEnabled: options.authorization !== undefined,
|
|
158
|
+
});
|
|
159
|
+
if (refusal) {
|
|
160
|
+
options.log.warn('rejected request', { reason: 'unauthenticated_rate' });
|
|
161
|
+
return refusal;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
147
165
|
// Before authentication, deliberately: a client's first request is the
|
|
148
166
|
// one that discovers how to authenticate, so requiring a token to read
|
|
149
167
|
// the document that says where tokens come from would close the loop it
|
|
@@ -152,7 +170,7 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
|
|
|
152
170
|
return await handleAuthorization(request, options.authorization);
|
|
153
171
|
}
|
|
154
172
|
|
|
155
|
-
if (url.pathname ===
|
|
173
|
+
if (url.pathname === HEALTH_PATH) {
|
|
156
174
|
// `status` is unauthenticated because the platform's own probe reads it
|
|
157
175
|
// and a deploy waits on it. The profile *names* are not: on a public URL
|
|
158
176
|
// that is a list of what this endpoint holds, handed to anyone who asks,
|
|
@@ -182,37 +200,17 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
|
|
|
182
200
|
request.headers.get('authorization'),
|
|
183
201
|
);
|
|
184
202
|
|
|
203
|
+
// The refusal, and the ceiling on how often one may be provoked. Both in
|
|
204
|
+
// `./edge.ts`, which is the subject: what this endpoint does about a
|
|
205
|
+
// caller who has not authenticated.
|
|
185
206
|
if (!outcome.ok) {
|
|
186
207
|
options.log.warn('rejected request', { reason: outcome.reason });
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const budget = failedAuth.take(callerKey(request), FAILED_AUTH_PER_MINUTE);
|
|
194
|
-
if (!budget.allowed) return tooManyAttempts(budget.retryAfterMs);
|
|
195
|
-
|
|
196
|
-
// The pointer is the whole handshake for a remote client: it reads the
|
|
197
|
-
// named document, finds the authorization server, and starts a flow.
|
|
198
|
-
// Without it the client has to guess the document's location, and a
|
|
199
|
-
// client that guesses wrong reports the endpoint as unreachable.
|
|
200
|
-
const metadata = options.authorization ? resourceMetadataUrl(request) : null;
|
|
201
|
-
|
|
202
|
-
return new Response(
|
|
203
|
-
JSON.stringify({
|
|
204
|
-
error: 'unauthorized',
|
|
205
|
-
reason: outcome.reason,
|
|
206
|
-
hint: HINTS[outcome.reason],
|
|
207
|
-
}),
|
|
208
|
-
{
|
|
209
|
-
status: 401,
|
|
210
|
-
headers: {
|
|
211
|
-
'content-type': 'application/json',
|
|
212
|
-
'www-authenticate': challenge(metadata, CHALLENGE[outcome.reason]),
|
|
213
|
-
},
|
|
214
|
-
},
|
|
215
|
-
);
|
|
208
|
+
return authRefusal({
|
|
209
|
+
request,
|
|
210
|
+
reason: outcome.reason,
|
|
211
|
+
limiter: failedAuth,
|
|
212
|
+
metadataUrl: options.authorization ? resourceMetadataUrl(request) : null,
|
|
213
|
+
});
|
|
216
214
|
}
|
|
217
215
|
|
|
218
216
|
// Behind the same bearer check as everything else, and deliberately not a
|
|
@@ -348,6 +346,10 @@ export function serve(options: ServeOptions): RunningServer {
|
|
|
348
346
|
: { allowedOrigins: primary.config.auth.allowed_origins ?? [ANY_ORIGIN] };
|
|
349
347
|
const handler = createRequestHandler({
|
|
350
348
|
...options,
|
|
349
|
+
// Off on loopback for the same reason `cors` is undefined there, and decided
|
|
350
|
+
// here so every property of the bind address is decided together. An
|
|
351
|
+
// explicit `true` wins, which is how a test drives the deployed behaviour.
|
|
352
|
+
meterUnauthenticated: options.meterUnauthenticated ?? !loopback,
|
|
351
353
|
...(allowedHostnames ? { allowedHostnames } : {}),
|
|
352
354
|
});
|
|
353
355
|
|
package/src/server/oauth.ts
CHANGED
|
@@ -62,11 +62,30 @@ export function isAuthorizationPath(pathname: string): boolean {
|
|
|
62
62
|
* `http` and every metadata document would name a resource no client asked for
|
|
63
63
|
* — which fails the exact-match the specification requires. Config cannot help
|
|
64
64
|
* either: the hostname carries a project hash assigned at deploy time.
|
|
65
|
+
*
|
|
66
|
+
* **The host is `Host`, and `X-Forwarded-Host` is not consulted.** It used to
|
|
67
|
+
* be, ahead of `Host`, and the justification above is entirely about the
|
|
68
|
+
* *scheme* — nothing ever needed the other header. What it cost is that four
|
|
69
|
+
* documents were steerable per request by whoever sent it: both discovery
|
|
70
|
+
* documents, the `resource_metadata` pointer on every `401`, and the `action` of
|
|
71
|
+
* the consent form that asks the owner to paste their endpoint token. Cloud Run
|
|
72
|
+
* sets `Host` and routes on it, so it is the one value a caller cannot invent
|
|
73
|
+
* without the request going somewhere else; a proxy that genuinely rewrites it
|
|
74
|
+
* rewrites `Host` too, which is what a domain mapping does.
|
|
75
|
+
*
|
|
76
|
+
* The scheme is checked rather than trusted for the same reason, and it is a
|
|
77
|
+
* smaller hole — naming `http` in a document downgrades nothing, it just makes
|
|
78
|
+
* the exact-match fail — but a header that decides part of a URL should not
|
|
79
|
+
* accept an arbitrary string.
|
|
65
80
|
*/
|
|
66
81
|
export function publicOrigin(request: Request): string {
|
|
67
82
|
const url = new URL(request.url);
|
|
68
|
-
const host = request.headers.get('
|
|
69
|
-
|
|
83
|
+
const host = request.headers.get('host') ?? url.host;
|
|
84
|
+
|
|
85
|
+
const forwarded = request.headers.get('x-forwarded-proto');
|
|
86
|
+
const proto =
|
|
87
|
+
forwarded === 'https' || forwarded === 'http' ? forwarded : url.protocol.replace(':', '');
|
|
88
|
+
|
|
70
89
|
return `${proto}://${host}`;
|
|
71
90
|
}
|
|
72
91
|
|