@lanes-sh/link 0.7.0 → 0.7.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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",
package/src/cli/brand.ts CHANGED
@@ -150,6 +150,19 @@ a { color: inherit; }
150
150
  .footer a { text-decoration: underline; }
151
151
  `.trim();
152
152
 
153
+ /** What a page may widen its policy by, for the two pages that need to. */
154
+ export interface PagePolicy {
155
+ /** The page carries an inline script, and has to say so. */
156
+ readonly script?: boolean;
157
+ /**
158
+ * Where this page's form is allowed to end up, beyond `'self'`.
159
+ *
160
+ * A source expression, not a URL: an origin (`https://client.example`) or a
161
+ * bare scheme (`vscode:`) for a native client's redirect.
162
+ */
163
+ readonly formAction?: readonly string[];
164
+ }
165
+
153
166
  /**
154
167
  * What every page these modules serve loads, and nothing else.
155
168
  *
@@ -158,16 +171,32 @@ a { color: inherit; }
158
171
  * a page here has no script by default — the consent screen, which has one
159
172
  * listener for its submit spinner, extends this rather than replacing it.
160
173
  */
161
- export const PAGE_CSP =
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'; " +
169
- "style-src 'unsafe-inline' https://fonts.googleapis.com; " +
170
- 'font-src https://fonts.gstatic.com';
174
+ export function pageCsp(policy: PagePolicy = {}): string {
175
+ return [
176
+ "frame-ancestors 'none'",
177
+ "default-src 'none'",
178
+ // `form-action` does **not** fall back to `default-src`, so `'none'` above
179
+ // says nothing about where a form may post. One page here posts the owner's
180
+ // endpoint token, and its `action` is built from the request's own `Host`
181
+ // this is the second lock on that, so a form target that ever came from
182
+ // somewhere else is refused by the browser rather than followed.
183
+ //
184
+ // It takes sources beyond `'self'` because Chrome and Safari re-check this
185
+ // directive against the *redirect* a submission produces, not only against
186
+ // the `action` — and the consent form's whole purpose is to end in a 302 to
187
+ // the client that asked. Named nothing else, the directive accepts the
188
+ // POST, mints the code, and then blocks the browser from delivering it,
189
+ // which is indistinguishable from a hung page. Firefox does not do this, so
190
+ // it stays broken in exactly half the browsers. See ADR-054.
191
+ ["form-action 'self'", ...(policy.formAction ?? [])].join(' '),
192
+ "style-src 'unsafe-inline' https://fonts.googleapis.com",
193
+ 'font-src https://fonts.gstatic.com',
194
+ ...(policy.script ? ["script-src 'unsafe-inline'"] : []),
195
+ ].join('; ');
196
+ }
197
+
198
+ /** The policy a page with nothing to widen answers with. */
199
+ export const PAGE_CSP = pageCsp();
171
200
 
172
201
  /**
173
202
  * Headers every page here answers with.
@@ -26,7 +26,7 @@
26
26
  * else — not on emphasis, and not on the heading.
27
27
  */
28
28
 
29
- import { escapeHtml, FONTS, FOOTER, PAGE_CSP, PAGE_HEADERS, TOKENS } from './brand.ts';
29
+ import { escapeHtml, FONTS, FOOTER, pageCsp, PAGE_HEADERS, TOKENS } from './brand.ts';
30
30
 
31
31
  export interface CallbackPage {
32
32
  /** The focal line, set in Lora. A provider name, or the outcome itself. */
@@ -74,6 +74,11 @@ export interface ApprovalPage {
74
74
  readonly client: string;
75
75
  /** Where the code would be sent. The part of the request that cannot be faked. */
76
76
  readonly redirectHost: string;
77
+ /**
78
+ * The same destination as a CSP source, so the browser will follow the
79
+ * redirect this form's approval ends in rather than blocking it.
80
+ */
81
+ readonly formAction?: string;
77
82
  /** Hidden fields carrying the authorization request through the POST. */
78
83
  readonly fields: Readonly<Record<string, string>>;
79
84
  readonly action: string;
@@ -121,7 +126,10 @@ ${hidden}
121
126
  </form>
122
127
  <p class="small"><code>lanes link outputs --show --target ${escapeHtml(page.target)}</code></p>`;
123
128
 
124
- return shell(body, 'Authorise', page.retry ? 401 : 200, '', SUBMIT_SPINNER);
129
+ return shell(body, 'Authorise', page.retry ? 401 : 200, '', {
130
+ script: SUBMIT_SPINNER,
131
+ ...(page.formAction ? { formAction: [page.formAction] } : {}),
132
+ });
125
133
  }
126
134
 
127
135
  /**
@@ -152,8 +160,9 @@ function shell(
152
160
  title: string,
153
161
  status: number,
154
162
  cardClass = '',
155
- script = '',
163
+ policy: { readonly script?: string; readonly formAction?: readonly string[] } = {},
156
164
  ): Response {
165
+ const script = policy.script ?? '';
157
166
  return new Response(
158
167
  `<!doctype html>
159
168
  <html lang="en">
@@ -182,7 +191,10 @@ ${script ? `<script>\n${script}\n</script>` : ''}
182
191
  status,
183
192
  headers: {
184
193
  ...PAGE_HEADERS,
185
- ...(script ? { 'content-security-policy': `${PAGE_CSP}; script-src 'unsafe-inline'` } : {}),
194
+ 'content-security-policy': pageCsp({
195
+ ...(script ? { script: true } : {}),
196
+ ...(policy.formAction ? { formAction: policy.formAction } : {}),
197
+ }),
186
198
  },
187
199
  },
188
200
  );
@@ -156,6 +156,13 @@ export function ensureReservedConnection(
156
156
  }
157
157
 
158
158
  /** Whether a repair did anything, without a caller adding up two lists. */
159
+ /** `memory, tasks, assets, skills, vault, setup and entities`, in repair order. */
160
+ function listSurfaces(): string {
161
+ const names = [...DEFAULT_SURFACES];
162
+ const last = names.pop();
163
+ return names.length === 0 ? String(last) : `${names.join(', ')} and ${last}`;
164
+ }
165
+
159
166
  export function repaired(repair: SurfaceRepair): boolean {
160
167
  return repair.changes.length > 0 || repair.granted.length > 0;
161
168
  }
@@ -293,9 +300,11 @@ export async function repairOwnerLayer(
293
300
 
294
301
  say(ok(`gave ${style.bold(name)} its own owner layer`));
295
302
  for (const change of repairLines(repair)) say(` ${style.dim(change)}`);
296
- say(
297
- ` ${style.dim('memory, tasks, assets, skills, vault and setup your own material, no account behind any of them')}`,
298
- );
303
+ // Built from `DEFAULT_SURFACES` rather than typed out. The typed-out
304
+ // version still named six after a seventh had been added, so a person
305
+ // watching a deploy was told entities had arrived on the line above and
306
+ // that the layer was six things on the line below.
307
+ say(` ${style.dim(`${listSurfaces()} — your own material, no account behind any of them`)}`);
299
308
  } catch (error) {
300
309
  say(
301
310
  warn(
@@ -207,17 +207,21 @@ export const entitiesProvider: ProviderDefinition = defineLocalProvider({
207
207
  });
208
208
 
209
209
  if (matches.candidates.length === 0) {
210
- return {
211
- content: [
212
- {
213
- type: 'text',
214
- text:
215
- `Nothing on ${context.connection.key} matches ${describe(criteria)}. ` +
216
- 'Do not use an address that is not here — `entities.write` declares a new one, ' +
217
- 'or ask the owner.',
218
- },
219
- ],
220
- };
210
+ // An empty directory and a query that matched nothing are different
211
+ // answers and a caller does something different with each. Told
212
+ // "matches no criteria" it learns neither: that is what `describe({})`
213
+ // produces when a bare listing finds an empty store, and it reads like
214
+ // a parser error rather than an empty one.
215
+ const text =
216
+ matches.scanned === 0
217
+ ? `No entities are declared on ${context.connection.key} yet. ` +
218
+ '`entities.write` declares one. Until then there is nothing here to address ' +
219
+ 'anyone by, so ask rather than using an address from somewhere else.'
220
+ : `Nothing on ${context.connection.key} matches ${describe(criteria)}. ` +
221
+ 'Do not use an address that is not here — `entities.write` declares a new one, ' +
222
+ 'or ask the owner.';
223
+
224
+ return { content: [{ type: 'text', text }] };
221
225
  }
222
226
 
223
227
  if (matches.candidates.length > 1) {
@@ -168,6 +168,9 @@ function render(result: OAuthResult, request: Request, target: string): Response
168
168
  // anything, but it cannot change where the code is sent.
169
169
  client: result.clientName ?? result.request.clientId,
170
170
  redirectHost: hostOf(result.request.redirectUri),
171
+ // The page's policy has to admit the redirect the page's own approval
172
+ // ends in, or the browser blocks it. See `formActionFor`.
173
+ ...formActionFor(result.request.redirectUri),
171
174
  action: `${publicOrigin(request)}${AUTHORIZE_PATH}`,
172
175
  fields: formFromRequest(result.request),
173
176
  retry: result.retry,
@@ -219,6 +222,37 @@ function hostOf(uri: string): string {
219
222
  }
220
223
  }
221
224
 
225
+ /**
226
+ * The redirect target as a CSP source, for the consent page's `form-action`.
227
+ *
228
+ * Chrome and Safari check that directive against the redirect a form submission
229
+ * produces, so the page has to name where its own approval is about to send the
230
+ * browser — `'self'` alone mints the code and then blocks its delivery.
231
+ *
232
+ * Taken from the request being approved rather than from what the client
233
+ * registered, because the two legitimately differ: a native client registers
234
+ * `http://localhost/callback` and binds whatever port it got (RFC 8252), and
235
+ * the origin the browser navigates to is the one carrying that port. It is
236
+ * already checked against the registration — by `authorize` before this page is
237
+ * rendered, and again by `approve` before anything is minted — and it cannot
238
+ * move the token, because the form's `action` is built here rather than read
239
+ * from the request.
240
+ *
241
+ * An origin and nothing else, because `isSafeRedirect` registers nothing else:
242
+ * https, or http on loopback. A private-use scheme — `vscode:`, the other shape
243
+ * RFC 8252 allows — would need a scheme-source here, and is refused two steps
244
+ * earlier, so a branch for it would be a branch nothing can reach.
245
+ */
246
+ function formActionFor(uri: string): { formAction?: string } {
247
+ try {
248
+ const { protocol, origin } = new URL(uri);
249
+ if (protocol !== 'http:' && protocol !== 'https:') return {};
250
+ return { formAction: origin };
251
+ } catch {
252
+ return {};
253
+ }
254
+ }
255
+
222
256
  async function safeJson(request: Request): Promise<unknown> {
223
257
  try {
224
258
  return await request.json();