feat: production hardening + password reset, metrics, signed webhooks
Security hardening (CRITICAL/HIGH from production-readiness audit):
- Require strong JWT_SECRET + separate INTEGRATION_ENCRYPTION_KEY at boot;
refuse placeholder defaults. Integration key now derived via HKDF.
- SSRF guard (src/lib/server/utils/safeFetch.ts): DNS-resolves and rejects
RFC1918/loopback/link-local/IPv4-mapped IPv6/decimal-IP/cloud-metadata.
Manual redirect handling re-validates each 3xx Location hop. Applied to
healthcheck, RSS, calendar, metric, system-stats, camera, notifications,
discovery, apps/preview, and all integration clients.
- API tokens, session refresh tokens, invite tokens, password-reset tokens
switched from bcrypt to sha256 with @unique indexed lookup (O(1) instead
of O(N) bcrypt-compares; eliminates a trivial DoS).
- Refresh-token reuse detection via Session.previousTokenHash.
- Permission checks on App PATCH/DELETE and Widget/Section endpoints.
- /api/integrations/alerts now requires auth.
- SVG uploads sanitized through DOMPurify (svg profile, scheme allow-list).
- Custom CSS sanitizer + selector scoping (decodes CSS unicode escapes
before pattern match, drops forbidden at-rules incl. @import without
whitespace, strips dangerous url() args). Scoped to .custom-css-scope.
- Backup restore validates SQLite magic header, takes a safety snapshot,
uses atomic rename, re-applies pragmas.
- SQLite WAL + busy_timeout + foreign_keys + synchronous=NORMAL at startup.
- Healthcheck scheduler was dead code; wired in hooks.server.ts with
HMR-safe singleton, concurrency cap, overlap prevention, retention jobs
for AppClick/Notification/AuditLog. Composite indexes added on hot paths.
- Security headers (CSP, HSTS-on-https, X-Frame-Options, Permissions-Policy)
emitted on every response.
- Account-enumeration mitigation on login (dummy bcrypt on no-user/oauth
branches) + rate limiting on login/register/onboarding/refresh/invite/
password-reset.
- OAuth callback sanitizes IdP error_description before echoing.
New features:
- Custom +error.svelte pages (root + boards + admin) via shared
ErrorState component. Inverted hierarchy (status as label, title as hero).
- /forgot-password + /reset-password + admin-mediated /admin/password-resets
page. SHA256 tokens, 24h TTL, all sessions revoked on apply.
- /invite page for manual invite-token redemption.
- /api/metrics Prometheus exposition with optional METRICS_TOKEN bearer
auth. Counters for login/healthcheck/notification/integration; gauges
for users/boards/apps + per-status app counts.
- Webhook HMAC-SHA256 signing for HTTP notification channels (optional
shared secret + configurable signature header, default X-Signature-256).
- PATCH /api/users/me/password for self-service password change.
- Persistent uploads at /app/data/uploads with served-from-volume handler
at /uploads/[...path]. SVGs served with CSP: sandbox.
- /api/health does a DB ping; returns 503 on disconnect.
- Public /status filtered to guest-accessible-board apps when unauthenticated.
- Audit log coverage: LOGIN_SUCCESS/FAILED, LOGOUT, OAUTH_LOGIN,
OAUTH_USER_PROVISIONED, SESSION_REVOKED, API_TOKEN_*, INVITE_*,
APP_UPDATED, PASSWORD_CHANGED, PASSWORD_RESET_*.
Performance:
- Board page: removed double findAll() over-fetch; include links + appTags
in board query; widgets lazy-loaded via dynamic imports (marked,
DOMPurify, hls.js, integration renderers).
- uptimeService.getAllAppsUptime: single batched query instead of N+1.
- 30s in-memory user-locals cache; invalidated on user mutation.
- pruneOldStatuses: single window-function DELETE instead of N+1.
Code quality:
- Typed error classes (NotFoundError, PermissionError, RateLimitError,
IntegrationError) with toHttpError mapper.
- Locals.user shape exposes avatarUrl and narrows role via guard.
- App input types derived from Zod schemas via z.infer.
- 274 tests passing (up from 212); 62 new tests covering SSRF guard,
CSS sanitizer, SVG sanitizer, rate limiter.
CI / Docker / config:
- Test workflow adds build, docker-build, audit jobs. Release workflow
uses buildx multi-arch (amd64+arm64) with provenance + SBOM.
- Dockerfile uses tini, multi-stage prune, persistent uploads dir, single
prisma migrate deploy (no destructive db push fallback).
- docker-compose: JWT_SECRET + INTEGRATION_ENCRYPTION_KEY required at
startup, log rotation, resource limits.
- README documents breaking-change upgrade path.
Bug fixes from UI/UX review:
- ~55 missing i18n keys added to en/ru (auth flows, error pages, admin
nav, register invite banner, settings.card_style).
- Hardcoded English on login replaced with $t('auth.remember_me').
- Admin nav uses i18n keys; mobile horizontal-scroll layout.
- Page <title> tags standardized.
- Password-resets: separated error/info/success surfaces, ConfirmDialog
replaces window.confirm.
- Auth pages have matching lucide icon badges.
- Webhook secret has eye toggle and monospace input.
- text-green-500 → text-emerald-500 to match codebase convention.
Pre-existing CI lint failures cleaned up (31 errors → 0): each-key
attributes added, unused-svelte-ignore comments removed, two any casts
typed, dead skeleton components removed, /boards/[id]/edit redirect to
inline edit mode.
Tests: 274 / 274 passing
Type check: 0 errors / 0 warnings
Build: green
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { requireAdmin } from '$lib/server/middleware/authorize.js';
|
||||
import * as passwordResetService from '$lib/server/services/passwordResetService.js';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
requireAdmin(event);
|
||||
const resets = await passwordResetService.listPendingResets();
|
||||
return {
|
||||
resets: resets.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.userId,
|
||||
userEmail: r.user.email,
|
||||
userDisplayName: r.user.displayName,
|
||||
expiresAt: r.expiresAt.toISOString(),
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
createdById: r.createdById
|
||||
}))
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { t } from 'svelte-i18n';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import type { PageData } from './$types.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let email = $state('');
|
||||
let issuing = $state(false);
|
||||
let issuedLink = $state<{ url: string; expiresAt: string } | null>(null);
|
||||
let infoMessage = $state<string | null>(null);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let copyToast = $state(false);
|
||||
let pendingRevokeId = $state<string | null>(null);
|
||||
|
||||
async function issueReset(e: Event) {
|
||||
e.preventDefault();
|
||||
if (issuing || !email.trim()) return;
|
||||
issuing = true;
|
||||
issuedLink = null;
|
||||
errorMessage = null;
|
||||
infoMessage = null;
|
||||
try {
|
||||
const res = await fetch('/api/admin/password-resets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.trim() })
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) {
|
||||
errorMessage = json.error ?? 'Failed to issue reset link.';
|
||||
return;
|
||||
}
|
||||
if (json.data?.resetUrl) {
|
||||
issuedLink = { url: json.data.resetUrl, expiresAt: json.data.expiresAt };
|
||||
email = '';
|
||||
} else {
|
||||
// Account doesn't exist or is OAuth-only — we don't tell the admin which.
|
||||
infoMessage =
|
||||
'Request processed. If a matching local account exists, a reset link is now active.';
|
||||
email = '';
|
||||
}
|
||||
await invalidateAll();
|
||||
} catch {
|
||||
errorMessage = 'Network error. Please try again.';
|
||||
} finally {
|
||||
issuing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRevoke() {
|
||||
if (!pendingRevokeId) return;
|
||||
const id = pendingRevokeId;
|
||||
pendingRevokeId = null;
|
||||
const res = await fetch(`/api/admin/password-resets/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) await invalidateAll();
|
||||
}
|
||||
|
||||
async function copyLink(url: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
copyToast = true;
|
||||
setTimeout(() => (copyToast = false), 1500);
|
||||
} catch {
|
||||
window.prompt('Copy reset link:', url);
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const ms = new Date(iso).getTime() - Date.now();
|
||||
if (ms <= 0) return 'expired';
|
||||
const hours = Math.floor(ms / (60 * 60 * 1000));
|
||||
if (hours > 0) return `expires in ${hours}h`;
|
||||
const mins = Math.max(1, Math.floor(ms / (60 * 1000)));
|
||||
return `expires in ${mins}m`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('admin.password_resets') ?? 'Password Resets'} — {$t('admin.panel') ?? 'Admin'}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header>
|
||||
<h1 class="text-xl font-semibold">{$t('admin.password_resets') ?? 'Password resets'}</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Issue a reset link for a user, then share it with them through your preferred channel.
|
||||
Links expire after 24 hours and become single-use once the user sets a new password.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Issue new reset -->
|
||||
<section class="rounded-xl border border-border bg-card p-5">
|
||||
<h2 class="mb-3 text-sm font-semibold">Issue a reset link</h2>
|
||||
|
||||
<form onsubmit={issueReset} class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<label class="flex-1">
|
||||
<span class="mb-1 block text-xs font-medium text-muted-foreground">User email</span>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
bind:value={email}
|
||||
placeholder="user@example.com"
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={issuing}
|
||||
class="w-full rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 sm:w-auto"
|
||||
>
|
||||
{issuing ? 'Issuing…' : 'Issue link'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Success: a real link to copy -->
|
||||
{#if issuedLink}
|
||||
<div class="mt-4 rounded-lg border border-primary/30 bg-primary/5 p-4 text-sm">
|
||||
<p class="mb-2 font-medium">Share this link with the user (shown once)</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
value={issuedLink.url}
|
||||
class="flex-1 rounded-md border border-input bg-background px-2 py-1.5 font-mono text-xs"
|
||||
onclick={(e) => (e.currentTarget as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => issuedLink && copyLink(issuedLink.url)}
|
||||
class="rounded-md border border-input bg-background px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{copyToast ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
Expires {new Date(issuedLink.expiresAt).toLocaleString()} ({formatRelative(
|
||||
issuedLink.expiresAt
|
||||
)}).
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Silent success (account didn't exist or is OAuth-only) — info, not error -->
|
||||
{#if infoMessage}
|
||||
<div class="mt-4 rounded-lg border border-primary/20 bg-primary/5 p-3 text-sm text-foreground">
|
||||
{infoMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Real failure -->
|
||||
{#if errorMessage}
|
||||
<div
|
||||
class="mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"
|
||||
>
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Pending resets table -->
|
||||
<section class="overflow-hidden rounded-xl border border-border bg-card">
|
||||
<header class="border-b border-border p-4">
|
||||
<h2 class="text-sm font-semibold">Pending reset links ({data.resets.length})</h2>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
The raw token is only shown once — at the moment of issue. Re-issue if you lost the link.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if data.resets.length === 0}
|
||||
<p class="p-6 text-center text-sm text-muted-foreground">No pending resets.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">User</th>
|
||||
<th class="px-4 py-3 font-medium">Issued</th>
|
||||
<th class="px-4 py-3 font-medium">Expires</th>
|
||||
<th class="px-4 py-3 text-right font-medium">
|
||||
<span class="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.resets as r (r.id)}
|
||||
<tr class="border-t border-border">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium">{r.userDisplayName}</div>
|
||||
<div class="text-xs text-muted-foreground">{r.userEmail}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-muted-foreground">
|
||||
{new Date(r.createdAt).toLocaleString()}
|
||||
{#if r.createdById}
|
||||
<div class="text-[10px] uppercase tracking-wide">by admin</div>
|
||||
{:else}
|
||||
<div class="text-[10px] uppercase tracking-wide">self-service</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-muted-foreground">
|
||||
{new Date(r.expiresAt).toLocaleString()}
|
||||
<div class="text-[10px] text-muted-foreground/80">
|
||||
{formatRelative(r.expiresAt)}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (pendingRevokeId = r.id)}
|
||||
class="rounded-md border border-destructive/30 px-3 py-1 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if pendingRevokeId}
|
||||
<ConfirmDialog
|
||||
title="Revoke reset link?"
|
||||
message="The user will no longer be able to use this link to reset their password. They can request a new one if needed."
|
||||
confirmLabel="Revoke"
|
||||
onConfirm={confirmRevoke}
|
||||
onCancel={() => (pendingRevokeId = null)}
|
||||
/>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user