@glw907/cairn-cms 0.37.0 → 0.38.0
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 +30 -1
- package/README.md +69 -48
- package/dist/components/LoginPage.svelte +49 -28
- package/dist/components/LoginPage.svelte.d.ts +3 -1
- package/dist/components/cairn-admin.css +39 -14
- package/dist/diagnostics/conditions.d.ts +21 -0
- package/dist/diagnostics/conditions.js +53 -0
- package/dist/diagnostics/error.d.ts +9 -0
- package/dist/diagnostics/error.js +15 -0
- package/dist/diagnostics/index.d.ts +3 -0
- package/dist/diagnostics/index.js +3 -0
- package/dist/email.d.ts +20 -1
- package/dist/email.js +25 -0
- package/dist/github/repo.js +0 -1
- package/dist/github/types.js +0 -1
- package/dist/render/component-grammar.js +1 -1
- package/dist/sveltekit/admin-response.d.ts +7 -0
- package/dist/sveltekit/admin-response.js +21 -0
- package/dist/sveltekit/auth-routes.d.ts +16 -3
- package/dist/sveltekit/auth-routes.js +47 -28
- package/dist/sveltekit/condition-response.d.ts +11 -0
- package/dist/sveltekit/condition-response.js +34 -0
- package/dist/sveltekit/guard.js +5 -40
- package/dist/sveltekit/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/lib/components/LoginPage.svelte +49 -28
- package/src/lib/diagnostics/conditions.ts +80 -0
- package/src/lib/diagnostics/error.ts +20 -0
- package/src/lib/diagnostics/index.ts +4 -0
- package/src/lib/email.ts +31 -1
- package/src/lib/github/credentials.ts +0 -1
- package/src/lib/github/repo.ts +0 -1
- package/src/lib/github/signing.ts +0 -1
- package/src/lib/github/types.ts +0 -1
- package/src/lib/render/component-grammar.ts +1 -1
- package/src/lib/sveltekit/admin-response.ts +23 -0
- package/src/lib/sveltekit/auth-routes.ts +59 -29
- package/src/lib/sveltekit/condition-response.ts +38 -0
- package/src/lib/sveltekit/guard.ts +5 -45
- package/src/lib/sveltekit/index.ts +1 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Shared response helpers for cairn's admin pages: the baseline security headers and a branded
|
|
2
|
+
// full-document response. Extracted from guard.ts so the guard's resolve path and the condition
|
|
3
|
+
// renderer share one definition.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Attach the baseline security headers to an admin response. No full CSP; see the auth-hardening
|
|
7
|
+
* design. frame-ancestors is the modern clickjacking control and the one CSP directive included.
|
|
8
|
+
*/
|
|
9
|
+
export function applySecurityHeaders(headers: Headers): void {
|
|
10
|
+
headers.set('X-Content-Type-Options', 'nosniff');
|
|
11
|
+
headers.set('X-Frame-Options', 'DENY');
|
|
12
|
+
headers.set('Content-Security-Policy', "frame-ancestors 'none'");
|
|
13
|
+
headers.set('Referrer-Policy', 'no-referrer');
|
|
14
|
+
headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
|
|
15
|
+
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A branded full-document admin page, hardened with the baseline headers and never cached. */
|
|
19
|
+
export function brandedAdminPage(status: number, body: string): Response {
|
|
20
|
+
const headers = new Headers({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
21
|
+
applySecurityHeaders(headers);
|
|
22
|
+
return new Response(body, { status, headers });
|
|
23
|
+
}
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
sessionCookieName,
|
|
14
14
|
} from '../auth/crypto.js';
|
|
15
15
|
import { findEditor, issueToken, consumeToken, createSession, deleteSession, recentlyIssued } from '../auth/store.js';
|
|
16
|
-
import { buildMagicLinkMessage, cloudflareSend, type AuthBranding, type SendMagicLink } from '../email.js';
|
|
16
|
+
import { buildMagicLinkMessage, cloudflareSend, emailSendFailure, errorCode, type AuthBranding, type SendMagicLink } from '../email.js';
|
|
17
17
|
import { issueCsrfToken } from './csrf.js';
|
|
18
18
|
import { log } from '../log/index.js';
|
|
19
19
|
import type { RequestContext } from './types.js';
|
|
@@ -23,15 +23,37 @@ export interface AuthRoutesConfig {
|
|
|
23
23
|
send?: SendMagicLink;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The request-action result. `status` is the discriminant; `sent` is kept for a site rendering its
|
|
28
|
+
* own form against `form.sent`, so the field is additive. The neutral and send-ok paths return the
|
|
29
|
+
* identical `{ status: 'sent', sent: true }`, so the common case never leaks allowlist membership.
|
|
30
|
+
*/
|
|
31
|
+
export type RequestResult =
|
|
32
|
+
| { status: 'sent'; sent: true }
|
|
33
|
+
| { status: 'send_error'; sent: false }
|
|
34
|
+
| { status: 'throttled'; sent: false };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The loggable form of a send failure. The engine's own senders throw clean errors, but `send` is
|
|
38
|
+
* an injection seam, and a custom sender's thrown error may embed the failed message and with it
|
|
39
|
+
* the magic link. Scrub any token query value and cap the length, so the documented "records never
|
|
40
|
+
* carry a token" guarantee holds for the seam too.
|
|
41
|
+
*/
|
|
42
|
+
function scrubSendError(err: unknown): string {
|
|
43
|
+
return String(err)
|
|
44
|
+
.replace(/([?&]token=)[^&\s"'<]+/g, '$1[redacted]')
|
|
45
|
+
.slice(0, 300);
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
export function createAuthRoutes(config: AuthRoutesConfig) {
|
|
27
49
|
const send = config.send ?? cloudflareSend;
|
|
28
50
|
|
|
29
51
|
/**
|
|
30
|
-
* POST /admin/auth/request. Looks the email up in the allowlist; on a match, issues a token
|
|
31
|
-
*
|
|
32
|
-
*
|
|
52
|
+
* POST /admin/auth/request. Looks the email up in the allowlist; on a match, issues a token,
|
|
53
|
+
* emails the confirmation link, and awaits the send so the status reflects its outcome. The
|
|
54
|
+
* neutral and send-ok responses are identical, so the common case never leaks membership.
|
|
33
55
|
*/
|
|
34
|
-
async function requestAction(event: RequestContext): Promise<
|
|
56
|
+
async function requestAction(event: RequestContext): Promise<RequestResult> {
|
|
35
57
|
const env = event.platform?.env ?? {};
|
|
36
58
|
const origin = requireOrigin(env);
|
|
37
59
|
const db = requireDb(env);
|
|
@@ -43,31 +65,39 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
|
|
|
43
65
|
log.info('auth.link.requested', { email: email.slice(0, 320) });
|
|
44
66
|
|
|
45
67
|
const editor = email ? await findEditor(db, email) : null;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
// Non-editor: byte-identical to the editor send-ok path, so the response body never leaks
|
|
69
|
+
// membership. Response timing still differs (the editor path awaits the send), the side-channel
|
|
70
|
+
// the design accepts as strictly weaker than the explicit throttled signal below.
|
|
71
|
+
if (!editor) return { status: 'sent', sent: true };
|
|
72
|
+
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
// Per-email cooldown: an editor who requested within the window gets the throttled signal rather
|
|
75
|
+
// than a second email. This reveals editor membership, the deliberate relaxed-non-leak posture.
|
|
76
|
+
if (await recentlyIssued(db, email, now - SEND_COOLDOWN_MS)) {
|
|
77
|
+
return { status: 'throttled', sent: false };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const token = generateToken();
|
|
81
|
+
await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
|
|
82
|
+
log.info('auth.token.minted', { email, expiresAt: now + TOKEN_TTL_MS });
|
|
83
|
+
const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
|
|
84
|
+
// The token row is the security-critical write the email depends on, so it is awaited first.
|
|
85
|
+
// The send is now awaited too (no waitUntil backgrounding), so its outcome drives the response:
|
|
86
|
+
// confirm the link went out before telling an editor to check their inbox. The cost is one
|
|
87
|
+
// email-API round trip on the login POST, the right trade for a login flow.
|
|
88
|
+
try {
|
|
89
|
+
await send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link }));
|
|
90
|
+
} catch (err) {
|
|
91
|
+
// Map the binding failure to its registered condition (carried as a CairnError with the
|
|
92
|
+
// original as cause), and log the greppable code plus the conditionId so the next onboarding
|
|
93
|
+
// gap reads straight to its fix. The editor sees only a generic message, never this detail.
|
|
94
|
+
const failure = emailSendFailure(err);
|
|
95
|
+
log.error('auth.link.send_failed', { email, error: scrubSendError(err), code: errorCode(err), conditionId: failure.conditionId });
|
|
96
|
+
// A plain 200 with a status field, not fail(): the result stays one uniform union for the
|
|
97
|
+
// page, and the failure is already observable through the error-level log record.
|
|
98
|
+
return { status: 'send_error', sent: false };
|
|
69
99
|
}
|
|
70
|
-
return { sent: true };
|
|
100
|
+
return { status: 'sent', sent: true };
|
|
71
101
|
}
|
|
72
102
|
|
|
73
103
|
/** GET /admin/login. Public. Carries the site name, an optional `?error`, and the CSRF token. */
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The runtime renderer leg of the diagnostics model: map a condition to the Response the guard
|
|
2
|
+
// serves. Re-homes the three rejection responses guard.ts built inline, keyed by condition id, so
|
|
3
|
+
// the guard's reason, the registered condition, and the served page stay in step.
|
|
4
|
+
import { brandedAdminPage } from './admin-response.js';
|
|
5
|
+
import { httpsRequiredPage } from './https-required-page.js';
|
|
6
|
+
import { csrfRequiredPage } from './csrf-required-page.js';
|
|
7
|
+
import { condition } from '../diagnostics/index.js';
|
|
8
|
+
|
|
9
|
+
/** The guard.rejected reasons, each mapped to its registered condition id. */
|
|
10
|
+
export const REASON_CONDITION = {
|
|
11
|
+
https: 'edge.https-not-forced',
|
|
12
|
+
csrf: 'auth.csrf-token-invalid',
|
|
13
|
+
origin: 'auth.csrf-origin-mismatch',
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export type GuardReason = keyof typeof REASON_CONDITION;
|
|
17
|
+
|
|
18
|
+
/** Render the Response the guard serves for a rejection, by its condition id. */
|
|
19
|
+
export function renderConditionResponse(id: string, ctx: { url?: URL } = {}): Response {
|
|
20
|
+
// Assert the id is registered before rendering, keeping the renderer in 1:1 with the registry.
|
|
21
|
+
condition(id);
|
|
22
|
+
switch (id) {
|
|
23
|
+
case REASON_CONDITION.https: {
|
|
24
|
+
const httpsUrl = new URL(ctx.url!);
|
|
25
|
+
httpsUrl.protocol = 'https:';
|
|
26
|
+
return brandedAdminPage(400, httpsRequiredPage(httpsUrl.toString()));
|
|
27
|
+
}
|
|
28
|
+
case REASON_CONDITION.csrf:
|
|
29
|
+
return brandedAdminPage(403, csrfRequiredPage());
|
|
30
|
+
case REASON_CONDITION.origin:
|
|
31
|
+
return new Response('Cross-site POST form submissions are forbidden', {
|
|
32
|
+
status: 403,
|
|
33
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
34
|
+
});
|
|
35
|
+
default:
|
|
36
|
+
throw new Error(`no runtime renderer for condition: ${id}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
import { redirect, error } from '@sveltejs/kit';
|
|
5
5
|
import { resolveSession } from '../auth/store.js';
|
|
6
6
|
import { sessionCookieName } from '../auth/crypto.js';
|
|
7
|
-
import { httpsRequiredPage } from './https-required-page.js';
|
|
8
7
|
import { isUnsafeFormRequest, originMatches, validateCsrfToken } from './csrf.js';
|
|
9
|
-
import {
|
|
8
|
+
import { applySecurityHeaders } from './admin-response.js';
|
|
9
|
+
import { renderConditionResponse } from './condition-response.js';
|
|
10
10
|
import { log } from '../log/index.js';
|
|
11
11
|
import type { Editor } from '../auth/types.js';
|
|
12
12
|
import type { HandleInput, RequestContext } from './types.js';
|
|
@@ -36,46 +36,6 @@ function isLocalHost(hostname: string): boolean {
|
|
|
36
36
|
);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
* Attach the baseline security headers to an admin response. No full CSP; see the auth-hardening
|
|
41
|
-
* design. frame-ancestors is the modern clickjacking control and the one CSP directive included.
|
|
42
|
-
*/
|
|
43
|
-
function applySecurityHeaders(headers: Headers): void {
|
|
44
|
-
headers.set('X-Content-Type-Options', 'nosniff');
|
|
45
|
-
headers.set('X-Frame-Options', 'DENY');
|
|
46
|
-
headers.set('Content-Security-Policy', "frame-ancestors 'none'");
|
|
47
|
-
headers.set('Referrer-Policy', 'no-referrer');
|
|
48
|
-
headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
|
|
49
|
-
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** A branded full-document admin page, hardened with the baseline headers and never cached. */
|
|
53
|
-
function brandedAdminPage(status: number, body: string): Response {
|
|
54
|
-
const headers = new Headers({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
55
|
-
applySecurityHeaders(headers);
|
|
56
|
-
return new Response(body, { status, headers });
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** The hardened 400 help page for a deployed admin request that arrived over http. */
|
|
60
|
-
function httpsRequiredResponse(url: URL): Response {
|
|
61
|
-
const httpsUrl = new URL(url);
|
|
62
|
-
httpsUrl.protocol = 'https:';
|
|
63
|
-
return brandedAdminPage(400, httpsRequiredPage(httpsUrl.toString()));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** A plain 403 for a non-admin cross-origin form POST, matching the framework's wording. */
|
|
67
|
-
function csrfForbidden(): Response {
|
|
68
|
-
return new Response('Cross-site POST form submissions are forbidden', {
|
|
69
|
-
status: 403,
|
|
70
|
-
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** The branded 403 for a failed admin double-submit token check. */
|
|
75
|
-
function csrfRequiredResponse(): Response {
|
|
76
|
-
return brandedAdminPage(403, csrfRequiredPage());
|
|
77
|
-
}
|
|
78
|
-
|
|
79
39
|
/** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
|
|
80
40
|
export function createAuthGuard() {
|
|
81
41
|
return async function handle({ event, resolve }: HandleInput): Promise<Response> {
|
|
@@ -86,7 +46,7 @@ export function createAuthGuard() {
|
|
|
86
46
|
if (!isAdminPath(pathname)) {
|
|
87
47
|
if (isUnsafeFormRequest(event.request) && !originMatches(event)) {
|
|
88
48
|
log.warn('guard.rejected', { reason: 'origin', path: pathname });
|
|
89
|
-
return
|
|
49
|
+
return renderConditionResponse('auth.csrf-origin-mismatch');
|
|
90
50
|
}
|
|
91
51
|
return resolve(event);
|
|
92
52
|
}
|
|
@@ -97,14 +57,14 @@ export function createAuthGuard() {
|
|
|
97
57
|
// posts. Local http (wrangler dev) is exempt.
|
|
98
58
|
if (event.url.protocol === 'http:' && !isLocalHost(event.url.hostname)) {
|
|
99
59
|
log.warn('guard.rejected', { reason: 'https', path: pathname });
|
|
100
|
-
return
|
|
60
|
+
return renderConditionResponse('edge.https-not-forced', { url: event.url });
|
|
101
61
|
}
|
|
102
62
|
|
|
103
63
|
// Rule 1 - admin: every unsafe form POST carries a valid double-submit token, else the branded
|
|
104
64
|
// 403 before resolve() runs. This covers the public login/auth posts too.
|
|
105
65
|
if (isUnsafeFormRequest(event.request) && !(await validateCsrfToken(event))) {
|
|
106
66
|
log.warn('guard.rejected', { reason: 'csrf', path: pathname });
|
|
107
|
-
return
|
|
67
|
+
return renderConditionResponse('auth.csrf-token-invalid');
|
|
108
68
|
}
|
|
109
69
|
|
|
110
70
|
if (!isPublicAdminPath(pathname)) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SvelteKit server logic consumed by site route shims: the guard plus the auth, editor,
|
|
2
2
|
// content, and health route factories and functions.
|
|
3
3
|
export { createAuthGuard, requireSession, requireOwner } from './guard.js';
|
|
4
|
-
export { createAuthRoutes, type AuthRoutesConfig } from './auth-routes.js';
|
|
4
|
+
export { createAuthRoutes, type AuthRoutesConfig, type RequestResult } from './auth-routes.js';
|
|
5
5
|
export { createEditorRoutes } from './editors-routes.js';
|
|
6
6
|
export { createContentRoutes } from './content-routes.js';
|
|
7
7
|
export type {
|