@lanes-sh/link 0.7.1 → 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 +1 -1
- package/src/cli/brand.ts +39 -10
- package/src/cli/callback-page.ts +16 -4
- package/src/server/oauth.ts +34 -0
package/package.json
CHANGED
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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.
|
package/src/cli/callback-page.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* else — not on emphasis, and not on the heading.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import { escapeHtml, FONTS, FOOTER,
|
|
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, '',
|
|
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
|
-
|
|
194
|
+
'content-security-policy': pageCsp({
|
|
195
|
+
...(script ? { script: true } : {}),
|
|
196
|
+
...(policy.formAction ? { formAction: policy.formAction } : {}),
|
|
197
|
+
}),
|
|
186
198
|
},
|
|
187
199
|
},
|
|
188
200
|
);
|
package/src/server/oauth.ts
CHANGED
|
@@ -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();
|