Browse Source

make logouts thorough and write-access server-cached for 24 hours

auto-logout on client after 24 hours of inactivity
make splash-page full-screen
move or repeat access-security server-side, to prevent client work-arounds
main
Silberengel 4 weeks ago
parent
commit
576882d0dc
  1. 42
      src/hooks.server.ts
  2. 25
      src/lib/components/NavBar.svelte
  3. 75
      src/lib/services/activity-tracker.ts
  4. 21
      src/lib/services/nostr/relay-write-proof.ts
  5. 132
      src/lib/services/nostr/user-level-service.ts
  6. 30
      src/lib/services/security/rate-limiter.ts
  7. 121
      src/lib/services/security/user-level-cache.ts
  8. 53
      src/lib/stores/user-store.ts
  9. 193
      src/lib/utils/input-validation.ts
  10. 171
      src/routes/+layout.svelte
  11. 77
      src/routes/+page.svelte
  12. 193
      src/routes/api/user/level/+server.ts
  13. 4
      src/routes/repos/[npub]/[repo]/+page.svelte

42
src/hooks.server.ts

@ -54,16 +54,33 @@ export const handle: Handle = async ({ event, resolve }) => {
rateLimitType = 'search'; rateLimitType = 'search';
} }
// Extract user pubkey for rate limiting (authenticated users get higher limits)
const userPubkey = event.request.headers.get('X-User-Pubkey') ||
event.request.headers.get('x-user-pubkey') ||
url.searchParams.get('userPubkey') ||
null;
// Use user pubkey as identifier if authenticated, otherwise use IP
// This allows authenticated users to have per-user limits (can't bypass by changing IP)
// and anonymous users are limited by IP (prevents abuse)
const rateLimitIdentifier = userPubkey ? `user:${userPubkey}` : `ip:${clientIp}`;
const isAnonymous = !userPubkey;
// Check rate limit (skip for Vite internal requests) // Check rate limit (skip for Vite internal requests)
const rateLimitResult = isViteInternalRequest const rateLimitResult = isViteInternalRequest
? { allowed: true, resetAt: Date.now() } ? { allowed: true, resetAt: Date.now() }
: rateLimiter.check(rateLimitType, clientIp); : rateLimiter.check(rateLimitType, rateLimitIdentifier, isAnonymous);
if (!rateLimitResult.allowed) { if (!rateLimitResult.allowed) {
auditLogger.log({ auditLogger.log({
ip: clientIp, ip: clientIp,
action: `rate_limit.${rateLimitType}`, action: `rate_limit.${rateLimitType}`,
result: 'denied', result: 'denied',
metadata: { path: url.pathname } metadata: {
path: url.pathname,
identifier: rateLimitIdentifier,
isAnonymous,
userPubkey: userPubkey || null
}
}); });
return error(429, `Rate limit exceeded. Try again after ${new Date(rateLimitResult.resetAt).toISOString()}`); return error(429, `Rate limit exceeded. Try again after ${new Date(rateLimitResult.resetAt).toISOString()}`);
} }
@ -75,6 +92,27 @@ export const handle: Handle = async ({ event, resolve }) => {
try { try {
const response = await resolve(event); const response = await resolve(event);
// Add security headers
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-XSS-Protection', '1; mode=block');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
// Add CSP header (Content Security Policy)
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // unsafe-eval needed for Svelte
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self' wss: https:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'"
].join('; ');
response.headers.set('Content-Security-Policy', csp);
// Log successful request if it's a security-sensitive operation // Log successful request if it's a security-sensitive operation
if (url.pathname.startsWith('/api/')) { if (url.pathname.startsWith('/api/')) {
const duration = Date.now() - startTime; const duration = Date.now() - startTime;

25
src/lib/components/NavBar.svelte

@ -1,16 +1,35 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getPublicKeyWithNIP07, isNIP07Available } from '../services/nostr/nip07-signer.js'; import { getPublicKeyWithNIP07, isNIP07Available } from '../services/nostr/nip07-signer.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import ThemeToggle from './ThemeToggle.svelte'; import ThemeToggle from './ThemeToggle.svelte';
import UserBadge from './UserBadge.svelte'; import UserBadge from './UserBadge.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { userStore } from '../stores/user-store.js';
import { clearActivity, updateActivity } from '../services/activity-tracker.js';
let userPubkey = $state<string | null>(null); let userPubkey = $state<string | null>(null);
let mobileMenuOpen = $state(false); let mobileMenuOpen = $state(false);
onMount(async () => { onMount(async () => {
await checkAuth(); await checkAuth();
// Update activity on mount
updateActivity();
// Set up activity tracking for user interactions
const updateActivityOnInteraction = () => updateActivity();
// Track various user interactions
document.addEventListener('click', updateActivityOnInteraction, { passive: true });
document.addEventListener('keydown', updateActivityOnInteraction, { passive: true });
document.addEventListener('scroll', updateActivityOnInteraction, { passive: true });
return () => {
document.removeEventListener('click', updateActivityOnInteraction);
document.removeEventListener('keydown', updateActivityOnInteraction);
document.removeEventListener('scroll', updateActivityOnInteraction);
};
}); });
function toggleMobileMenu() { function toggleMobileMenu() {
@ -46,6 +65,12 @@
function logout() { function logout() {
userPubkey = null; userPubkey = null;
// Reset user store
userStore.reset();
// Clear activity tracking
clearActivity();
// Navigate to home page to reset all component state to anonymous
goto('/');
} }
function isActive(path: string): boolean { function isActive(path: string): boolean {

75
src/lib/services/activity-tracker.ts

@ -0,0 +1,75 @@
/**
* Activity tracker for user sessions
* Tracks ONLY the last activity timestamp (no information about what the user did)
* Used for 24-hour auto-logout after inactivity
*
* SECURITY: Only stores a single timestamp value. No activity details are stored.
*/
const LAST_ACTIVITY_KEY = 'gitrepublic_last_activity';
const SESSION_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
/**
* Update the last activity timestamp
*
* SECURITY: Only stores the timestamp (Date.now()). No information about
* what the user did is stored. Previous timestamp is overwritten.
*/
export function updateActivity(): void {
if (typeof window === 'undefined') return;
// Only store the timestamp - no activity details
const now = Date.now();
localStorage.setItem(LAST_ACTIVITY_KEY, now.toString());
}
/**
* Get the last activity timestamp
*/
export function getLastActivity(): number | null {
if (typeof window === 'undefined') return null;
const timestamp = localStorage.getItem(LAST_ACTIVITY_KEY);
return timestamp ? parseInt(timestamp, 10) : null;
}
/**
* Check if the session has expired (24 hours since last activity)
*/
export function isSessionExpired(): boolean {
if (typeof window === 'undefined') return false;
const lastActivity = getLastActivity();
if (!lastActivity) return false;
const now = Date.now();
const timeSinceActivity = now - lastActivity;
return timeSinceActivity >= SESSION_TIMEOUT_MS;
}
/**
* Clear activity tracking (on logout)
*/
export function clearActivity(): void {
if (typeof window === 'undefined') return;
localStorage.removeItem(LAST_ACTIVITY_KEY);
}
/**
* Get time until session expires (in milliseconds)
* Returns 0 if expired or no activity
*/
export function getTimeUntilExpiry(): number {
if (typeof window === 'undefined') return 0;
const lastActivity = getLastActivity();
if (!lastActivity) return 0;
const now = Date.now();
const timeSinceActivity = now - lastActivity;
const timeUntilExpiry = SESSION_TIMEOUT_MS - timeSinceActivity;
return Math.max(0, timeUntilExpiry);
}

21
src/lib/services/nostr/relay-write-proof.ts

@ -1,6 +1,10 @@
/** /**
* Service for verifying that a user can write to at least one default relay * Service for verifying that a user can write to at least one default relay
* This replaces rate limiting by requiring proof of relay write capability * This is a trust mechanism - only users who can write to trusted default relays
* get unlimited access. This limits access to trusted npubs.
*
* The user only needs to be able to write to ONE of the default relays, not all.
* If the proof event is found on any default relay, access is granted.
* *
* Accepts NIP-98 events (kind 27235) as proof, since publishing a NIP-98 event * Accepts NIP-98 events (kind 27235) as proof, since publishing a NIP-98 event
* to a relay proves the user can write to that relay. * to a relay proves the user can write to that relay.
@ -32,7 +36,7 @@ export async function verifyRelayWriteProof(
proofEvent: NostrEvent, proofEvent: NostrEvent,
userPubkey: string, userPubkey: string,
relays: string[] = DEFAULT_NOSTR_RELAYS relays: string[] = DEFAULT_NOSTR_RELAYS
): Promise<{ valid: boolean; error?: string; relay?: string }> { ): Promise<{ valid: boolean; error?: string; relay?: string; relayDown?: boolean }> {
// Verify the event signature // Verify the event signature
if (!verifyEvent(proofEvent)) { if (!verifyEvent(proofEvent)) {
return { valid: false, error: 'Invalid event signature' }; return { valid: false, error: 'Invalid event signature' };
@ -81,6 +85,8 @@ export async function verifyRelayWriteProof(
} }
// Try to verify the event exists on at least one default relay // Try to verify the event exists on at least one default relay
// User only needs write access to ONE of the default relays, not all
// This is a trust mechanism - if they can write to any trusted relay, they're trusted
const nostrClient = new NostrClient(relays); const nostrClient = new NostrClient(relays);
try { try {
const events = await nostrClient.fetchEvents([ const events = await nostrClient.fetchEvents([
@ -92,7 +98,7 @@ export async function verifyRelayWriteProof(
]); ]);
if (events.length === 0) { if (events.length === 0) {
return { valid: false, error: 'Proof event not found on any default relay' }; return { valid: false, error: 'Proof event not found on any default relay. User must be able to write to at least one default relay.' };
} }
// Verify the fetched event matches // Verify the fetched event matches
@ -101,12 +107,17 @@ export async function verifyRelayWriteProof(
return { valid: false, error: 'Fetched event does not match proof event' }; return { valid: false, error: 'Fetched event does not match proof event' };
} }
// Determine which relay(s) have the event (we can't know for sure, but we verified it exists) // Event found on at least one default relay - user has write access
// We can't determine which specific relay(s) have it, but that's fine
// The important thing is they can write to at least one trusted relay
return { valid: true, relay: relays[0] }; // Return first relay as indication return { valid: true, relay: relays[0] }; // Return first relay as indication
} catch (error) { } catch (error) {
// Relay connection failed - this is a network/relay issue, not an auth failure
// Return a special error that indicates we should check cache
return { return {
valid: false, valid: false,
error: `Failed to verify proof on relays: ${error instanceof Error ? error.message : String(error)}` error: `Failed to verify proof on relays: ${error instanceof Error ? error.message : String(error)}`,
relayDown: true // Flag to indicate relay connectivity issue
}; };
} }
} }

132
src/lib/services/nostr/user-level-service.ts

@ -0,0 +1,132 @@
/**
* Service for determining user access level based on relay write capability
*
* SECURITY: User level verification is done server-side via API endpoint.
* Client-side checks are only for UI purposes and can be bypassed.
*
* Three tiers:
* - unlimited: Users with write access to default relays
* - rate_limited: Logged-in users without write access
* - strictly_rate_limited: Not logged-in users
*/
import { signEventWithNIP07, isNIP07Available } from './nip07-signer.js';
import { KIND } from '../../types/nostr.js';
import { createProofEvent } from './relay-write-proof.js';
import { nip19 } from 'nostr-tools';
export type UserLevel = 'unlimited' | 'rate_limited' | 'strictly_rate_limited';
export interface UserLevelResult {
level: UserLevel;
userPubkey: string | null;
userPubkeyHex: string | null;
error?: string;
}
/**
* Check if a user can write to default relays by creating and verifying a proof event
* SECURITY: This creates the proof event client-side, but verification is done server-side
*/
export async function checkRelayWriteAccess(
userPubkeyHex: string
): Promise<{ hasAccess: boolean; error?: string }> {
if (!isNIP07Available()) {
return { hasAccess: false, error: 'NIP-07 extension not available' };
}
try {
// Create a proof event (kind 1 text note)
const proofEventTemplate = createProofEvent(
userPubkeyHex,
`gitrepublic-write-proof-${Date.now()}`
);
// Sign the event with NIP-07
const signedEvent = await signEventWithNIP07(proofEventTemplate);
// Verify server-side via API endpoint (secure)
const response = await fetch('/api/user/level', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
proofEvent: signedEvent,
userPubkeyHex
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
return {
hasAccess: false,
error: errorData.error || `Server error: ${response.status}`
};
}
const result = await response.json();
return {
hasAccess: result.level === 'unlimited',
error: result.error
};
} catch (error) {
return {
hasAccess: false,
error: error instanceof Error ? error.message : 'Unknown error checking relay write access'
};
}
}
/**
* Determine user level based on authentication and relay write access
* This is the main function to call to get user level
*/
export async function determineUserLevel(
userPubkey: string | null,
userPubkeyHex: string | null
): Promise<UserLevelResult> {
// Not logged in
if (!userPubkey || !userPubkeyHex) {
return {
level: 'strictly_rate_limited',
userPubkey: null,
userPubkeyHex: null
};
}
// Check if user has write access to default relays
const writeAccess = await checkRelayWriteAccess(userPubkeyHex);
if (writeAccess.hasAccess) {
return {
level: 'unlimited',
userPubkey,
userPubkeyHex
};
}
// Logged in but no write access
return {
level: 'rate_limited',
userPubkey,
userPubkeyHex,
error: writeAccess.error
};
}
/**
* Helper to decode npub to hex if needed
*/
export function decodePubkey(pubkey: string): string | null {
try {
const decoded = nip19.decode(pubkey);
if (decoded.type === 'npub') {
return decoded.data as string;
}
return pubkey; // Assume it's already hex
} catch {
return pubkey; // Assume it's already hex
}
}

30
src/lib/services/security/rate-limiter.ts

@ -66,14 +66,22 @@ export class RateLimiter {
/** /**
* Get rate limit configuration for operation type * Get rate limit configuration for operation type
* @param type - Operation type
* @param isAnonymous - Whether the user is anonymous (stricter limits)
*/ */
private getLimitForType(type: string): number { private getLimitForType(type: string, isAnonymous: boolean = false): number {
const envKey = `RATE_LIMIT_${type.toUpperCase()}_MAX`; // Anonymous users get stricter limits (50% of authenticated limits)
const anonymousMultiplier = isAnonymous ? 0.5 : 1.0;
const envKey = isAnonymous
? `RATE_LIMIT_${type.toUpperCase()}_ANON_MAX`
: `RATE_LIMIT_${type.toUpperCase()}_MAX`;
const defaultLimits: Record<string, number> = { const defaultLimits: Record<string, number> = {
git: 60, // Git operations: 60/min git: 60, // Git operations: 60/min (authenticated), 30/min (anonymous)
api: 120, // API requests: 120/min api: 120, // API requests: 120/min (authenticated), 60/min (anonymous)
file: 30, // File operations: 30/min file: 30, // File operations: 30/min (authenticated), 15/min (anonymous)
search: 20 // Search requests: 20/min search: 20 // Search requests: 20/min (authenticated), 10/min (anonymous)
}; };
const envValue = process.env[envKey]; const envValue = process.env[envKey];
@ -81,14 +89,18 @@ export class RateLimiter {
return parseInt(envValue, 10); return parseInt(envValue, 10);
} }
return defaultLimits[type] || 60; const baseLimit = defaultLimits[type] || 60;
return Math.floor(baseLimit * anonymousMultiplier);
} }
/** /**
* Check rate limit for a specific operation type * Check rate limit for a specific operation type
* @param type - Operation type
* @param identifier - User identifier (pubkey or IP)
* @param isAnonymous - Whether the user is anonymous (applies stricter limits)
*/ */
check(type: string, identifier: string): { allowed: boolean; remaining: number; resetAt: number } { check(type: string, identifier: string, isAnonymous: boolean = false): { allowed: boolean; remaining: number; resetAt: number } {
const maxRequests = this.getLimitForType(type); const maxRequests = this.getLimitForType(type, isAnonymous);
return this.checkLimit(type, identifier, maxRequests); return this.checkLimit(type, identifier, maxRequests);
} }

121
src/lib/services/security/user-level-cache.ts

@ -0,0 +1,121 @@
/**
* Cache for user access levels
* Prevents users from losing access when relays are temporarily down
*
* SECURITY:
* - Only caches successful verifications. Failed verifications are not cached.
* - Cache is keyed by pubkey, so only the user with that pubkey can use it
* - Users must still provide a valid proof event signed with their private key
* - Cache expires after 24 hours, requiring re-verification
* - If relays are down and no cache exists, access is denied (security-first)
*/
interface CachedUserLevel {
level: 'unlimited' | 'rate_limited';
userPubkeyHex: string;
cachedAt: number;
expiresAt: number;
}
// In-memory cache (in production, consider Redis for distributed systems)
const userLevelCache = new Map<string, CachedUserLevel>();
// Cache duration: 24 hours (users keep access even if relays are down)
const CACHE_DURATION_MS = 24 * 60 * 60 * 1000;
// Cleanup interval: remove expired entries every hour
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
let cleanupInterval: NodeJS.Timeout | null = null;
/**
* Start cleanup interval if not already running
*/
function startCleanup(): void {
if (cleanupInterval) return;
cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [key, entry] of userLevelCache.entries()) {
if (entry.expiresAt < now) {
userLevelCache.delete(key);
}
}
}, CLEANUP_INTERVAL_MS);
}
/**
* Get cached user level if it exists and is still valid
*/
export function getCachedUserLevel(userPubkeyHex: string): CachedUserLevel | null {
startCleanup();
const cached = userLevelCache.get(userPubkeyHex);
if (!cached) {
return null;
}
// Check if cache is still valid
const now = Date.now();
if (cached.expiresAt < now) {
userLevelCache.delete(userPubkeyHex);
return null;
}
return cached;
}
/**
* Cache a successful user level verification
* Only caches successful verifications (unlimited or rate_limited)
* Does not cache failures (strictly_rate_limited)
*/
export function cacheUserLevel(
userPubkeyHex: string,
level: 'unlimited' | 'rate_limited'
): void {
startCleanup();
const now = Date.now();
const cached: CachedUserLevel = {
level,
userPubkeyHex,
cachedAt: now,
expiresAt: now + CACHE_DURATION_MS
};
userLevelCache.set(userPubkeyHex, cached);
}
/**
* Clear cached user level (e.g., on logout or when access is revoked)
*/
export function clearCachedUserLevel(userPubkeyHex: string): void {
userLevelCache.delete(userPubkeyHex);
}
/**
* Get cache statistics (for monitoring)
*/
export function getCacheStats(): {
size: number;
entries: Array<{ userPubkeyHex: string; level: string; expiresAt: number }>;
} {
const entries = Array.from(userLevelCache.entries()).map(([key, value]) => ({
userPubkeyHex: key,
level: value.level,
expiresAt: value.expiresAt
}));
return {
size: userLevelCache.size,
entries
};
}
/**
* Clear all cached entries (useful for testing or manual resets)
*/
export function clearAllCache(): void {
userLevelCache.clear();
}

53
src/lib/stores/user-store.ts

@ -0,0 +1,53 @@
/**
* User store for managing user state and access level across the application
*/
import { writable } from 'svelte/store';
import type { UserLevel } from '../services/nostr/user-level-service.js';
export interface UserState {
userPubkey: string | null;
userPubkeyHex: string | null;
userLevel: UserLevel;
checkingLevel: boolean;
error: string | null;
}
const initialState: UserState = {
userPubkey: null,
userPubkeyHex: null,
userLevel: 'strictly_rate_limited',
checkingLevel: false,
error: null
};
function createUserStore() {
const { subscribe, set, update } = writable<UserState>(initialState);
return {
subscribe,
set,
update,
reset: () => set(initialState),
setChecking: (checking: boolean) => {
update(state => ({ ...state, checkingLevel: checking }));
},
setUser: (
userPubkey: string | null,
userPubkeyHex: string | null,
userLevel: UserLevel,
error: string | null = null
) => {
update(state => ({
...state,
userPubkey,
userPubkeyHex,
userLevel,
checkingLevel: false,
error
}));
}
};
}
export const userStore = createUserStore();

193
src/lib/utils/input-validation.ts

@ -0,0 +1,193 @@
/**
* Input validation utilities for security
* Prevents injection attacks, path traversal, and other security issues
*/
/**
* Validate and sanitize repository name
* Repository names should be alphanumeric with hyphens and underscores
*/
export function validateRepoName(name: string): { valid: boolean; error?: string; sanitized?: string } {
if (!name || typeof name !== 'string') {
return { valid: false, error: 'Repository name is required' };
}
// Remove leading/trailing whitespace
const trimmed = name.trim();
if (trimmed.length === 0) {
return { valid: false, error: 'Repository name cannot be empty' };
}
if (trimmed.length > 100) {
return { valid: false, error: 'Repository name must be 100 characters or less' };
}
// Allow alphanumeric, hyphens, underscores, and dots
// But not starting with dot or containing consecutive dots
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$/.test(trimmed)) {
return {
valid: false,
error: 'Repository name must contain only alphanumeric characters, hyphens, underscores, and dots. Cannot start or end with a dot.'
};
}
// Prevent path traversal attempts
if (trimmed.includes('..') || trimmed.includes('/') || trimmed.includes('\\')) {
return { valid: false, error: 'Repository name cannot contain path separators' };
}
return { valid: true, sanitized: trimmed };
}
/**
* Validate and sanitize file path
* Prevents path traversal attacks
*/
export function validateFilePath(path: string): { valid: boolean; error?: string; sanitized?: string } {
if (!path || typeof path !== 'string') {
return { valid: false, error: 'File path is required' };
}
// Remove leading/trailing whitespace and slashes
const trimmed = path.trim().replace(/^\/+|\/+$/g, '');
if (trimmed.length === 0) {
return { valid: false, error: 'File path cannot be empty' };
}
// Prevent path traversal
if (trimmed.includes('..')) {
return { valid: false, error: 'Path traversal not allowed' };
}
// Prevent absolute paths
if (trimmed.startsWith('/') || trimmed.match(/^[a-zA-Z]:/)) {
return { valid: false, error: 'Absolute paths not allowed' };
}
// Prevent null bytes
if (trimmed.includes('\0')) {
return { valid: false, error: 'Null bytes not allowed in paths' };
}
// Normalize path separators
const normalized = trimmed.replace(/\\/g, '/');
return { valid: true, sanitized: normalized };
}
/**
* Validate pubkey format (hex, 64 characters)
*/
export function validatePubkey(pubkey: string): { valid: boolean; error?: string } {
if (!pubkey || typeof pubkey !== 'string') {
return { valid: false, error: 'Pubkey is required' };
}
// Hex format, 64 characters
if (!/^[0-9a-f]{64}$/i.test(pubkey)) {
return { valid: false, error: 'Invalid pubkey format. Must be 64-character hex string.' };
}
return { valid: true };
}
/**
* Validate npub format (bech32 encoded)
*/
export function validateNpub(npub: string): { valid: boolean; error?: string } {
if (!npub || typeof npub !== 'string') {
return { valid: false, error: 'Npub is required' };
}
// Basic npub format check (starts with npub, bech32 encoded)
if (!/^npub1[ac-hj-np-z02-9]{58}$/.test(npub)) {
return { valid: false, error: 'Invalid npub format' };
}
return { valid: true };
}
/**
* Sanitize string input to prevent XSS
* Removes potentially dangerous characters
*/
export function sanitizeString(input: string, maxLength: number = 10000): string {
if (typeof input !== 'string') {
return '';
}
// Truncate to max length
let sanitized = input.slice(0, maxLength);
// Remove null bytes
sanitized = sanitized.replace(/\0/g, '');
// Remove control characters except newlines and tabs
sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
return sanitized;
}
/**
* Validate commit message
*/
export function validateCommitMessage(message: string): { valid: boolean; error?: string; sanitized?: string } {
if (!message || typeof message !== 'string') {
return { valid: false, error: 'Commit message is required' };
}
const trimmed = message.trim();
if (trimmed.length === 0) {
return { valid: false, error: 'Commit message cannot be empty' };
}
if (trimmed.length > 10000) {
return { valid: false, error: 'Commit message must be 10000 characters or less' };
}
// Sanitize but allow newlines
const sanitized = sanitizeString(trimmed, 10000);
return { valid: true, sanitized };
}
/**
* Validate branch name
*/
export function validateBranchName(branch: string): { valid: boolean; error?: string; sanitized?: string } {
if (!branch || typeof branch !== 'string') {
return { valid: false, error: 'Branch name is required' };
}
const trimmed = branch.trim();
if (trimmed.length === 0) {
return { valid: false, error: 'Branch name cannot be empty' };
}
if (trimmed.length > 255) {
return { valid: false, error: 'Branch name must be 255 characters or less' };
}
// Git branch name rules
// Cannot contain .., cannot end with ., cannot contain spaces
if (trimmed.includes('..') || trimmed.endsWith('.') || /\s/.test(trimmed)) {
return { valid: false, error: 'Invalid branch name format' };
}
// Prevent path traversal
if (trimmed.includes('/') && trimmed !== 'HEAD') {
// Allow forward slashes for branch paths, but validate
const parts = trimmed.split('/');
for (const part of parts) {
if (part === '' || part === '.' || part === '..') {
return { valid: false, error: 'Invalid branch name format' };
}
}
}
return { valid: true, sanitized: trimmed };
}

171
src/routes/+layout.svelte

@ -1,12 +1,25 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import { onMount, setContext } from 'svelte'; import { onMount, setContext } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import Footer from '$lib/components/Footer.svelte'; import Footer from '$lib/components/Footer.svelte';
import NavBar from '$lib/components/NavBar.svelte'; import NavBar from '$lib/components/NavBar.svelte';
import type { Snippet } from 'svelte';
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js';
import { determineUserLevel, decodePubkey } from '$lib/services/nostr/user-level-service.js';
import { userStore } from '$lib/stores/user-store.js';
import { isSessionExpired, updateActivity, clearActivity } from '$lib/services/activity-tracker.js';
// Accept children as a snippet prop (Svelte 5)
let { children }: { children: Snippet } = $props();
// Theme management - default to dark // Theme management - default to dark
let theme: 'light' | 'dark' = 'dark'; let theme: 'light' | 'dark' = 'dark';
// User level checking state
let checkingUserLevel = $state(false);
onMount(() => { onMount(() => {
// Check for saved theme preference or default to dark // Check for saved theme preference or default to dark
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null; const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null;
@ -27,8 +40,95 @@
} }
}); });
} }
// Check for session expiry (24 hours)
if (isSessionExpired()) {
// Session expired - logout user
userStore.reset();
clearActivity();
console.log('Session expired after 24 hours of inactivity');
} else {
// Update activity on mount
updateActivity();
}
// Check user level if not on splash page
// Only check if user store is not already initialized with a logged-in user
if ($page.url.pathname !== '/') {
const currentState = $userStore;
// Only check if we don't have a user or if user level is strictly_rate_limited
if (!currentState.userPubkey || currentState.userLevel === 'strictly_rate_limited') {
checkUserLevel();
}
}
// Set up periodic session expiry check (every 5 minutes)
const expiryCheckInterval = setInterval(() => {
if (isSessionExpired()) {
userStore.reset();
clearActivity();
console.log('Session expired after 24 hours of inactivity');
// Optionally redirect to home page
if ($page.url.pathname !== '/') {
goto('/');
}
}
}, 5 * 60 * 1000); // Check every 5 minutes
return () => {
clearInterval(expiryCheckInterval);
};
}); });
async function checkUserLevel() {
// Skip if already checking or if user store is already set
const currentState = $userStore;
if (checkingUserLevel || (currentState.userPubkey && currentState.userLevel !== 'strictly_rate_limited')) {
return;
}
checkingUserLevel = true;
userStore.setChecking(true);
try {
let userPubkey: string | null = null;
let userPubkeyHex: string | null = null;
// Try to get user pubkey if NIP-07 is available
if (isNIP07Available()) {
try {
userPubkey = await getPublicKeyWithNIP07();
userPubkeyHex = decodePubkey(userPubkey);
} catch (err) {
console.warn('Failed to get user pubkey:', err);
}
}
// Determine user level
const levelResult = await determineUserLevel(userPubkey, userPubkeyHex);
// Update user store
userStore.setUser(
levelResult.userPubkey,
levelResult.userPubkeyHex,
levelResult.level,
levelResult.error || null
);
// Update activity if user is logged in
if (levelResult.userPubkey) {
updateActivity();
}
} catch (err) {
console.error('Failed to check user level:', err);
// Set to strictly rate limited on error
userStore.setUser(null, null, 'strictly_rate_limited', 'Failed to check user level');
} finally {
checkingUserLevel = false;
userStore.setChecking(false);
}
}
function applyTheme() { function applyTheme() {
if (theme === 'dark') { if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark'); document.documentElement.setAttribute('data-theme', 'dark');
@ -48,10 +148,75 @@
get theme() { return { value: theme }; }, get theme() { return { value: theme }; },
toggleTheme toggleTheme
}); });
// Hide nav bar and footer on splash page (root path)
const isSplashPage = $derived($page.url.pathname === '/');
// Subscribe to user store
const userState = $derived($userStore);
</script> </script>
<NavBar /> {#if !isSplashPage}
<NavBar />
{/if}
{#if !isSplashPage && checkingUserLevel}
<div class="user-level-check">
<div class="check-message">
<p>Checking user access level...</p>
<div class="spinner"></div>
</div>
</div>
{:else}
{@render children()}
{/if}
<slot /> {#if !isSplashPage}
<Footer />
{/if}
<Footer /> <style>
.user-level-check {
display: flex;
align-items: center;
justify-content: center;
min-height: 50vh;
padding: 2rem;
}
.check-message {
text-align: center;
}
.check-message p {
margin-bottom: 1rem;
color: var(--text-primary, #1a1a1a);
font-size: 1.1rem;
}
.spinner {
border: 3px solid var(--bg-secondary, #e8e8e8);
border-top: 3px solid var(--accent, #007bff);
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (prefers-color-scheme: dark) {
.check-message p {
color: var(--text-primary, #f5f5f5);
}
.spinner {
border-color: var(--bg-secondary, #2d2d2d);
border-top-color: var(--accent, #007bff);
}
}
</style>

77
src/routes/+page.svelte

@ -4,13 +4,28 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { getPublicKeyWithNIP07, isNIP07Available } from '../lib/services/nostr/nip07-signer.js'; import { getPublicKeyWithNIP07, isNIP07Available } from '../lib/services/nostr/nip07-signer.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { determineUserLevel, decodePubkey } from '../lib/services/nostr/user-level-service.js';
import { userStore } from '../lib/stores/user-store.js';
import { updateActivity } from '../lib/services/activity-tracker.js';
let userPubkey = $state<string | null>(null); let userPubkey = $state<string | null>(null);
let userPubkeyHex = $state<string | null>(null); let userPubkeyHex = $state<string | null>(null);
let checkingAuth = $state(true); let checkingAuth = $state(true);
let checkingLevel = $state(false);
let levelMessage = $state<string | null>(null);
onMount(async () => { onMount(() => {
await checkAuth(); // Prevent body scroll when splash page is shown
document.body.style.overflow = 'hidden';
// Check auth asynchronously
checkAuth();
// Return cleanup function
return () => {
// Re-enable scrolling when component is destroyed
document.body.style.overflow = '';
};
}); });
async function checkAuth() { async function checkAuth() {
@ -37,13 +52,48 @@
async function handleLogin() { async function handleLogin() {
if (isNIP07Available()) { if (isNIP07Available()) {
try { try {
checkingLevel = true;
levelMessage = 'Checking authentication...';
await checkAuth(); await checkAuth();
if (userPubkey) {
if (userPubkey && userPubkeyHex) {
levelMessage = 'Verifying relay write access...';
// Determine user level (checks relay write access)
const levelResult = await determineUserLevel(userPubkey, userPubkeyHex);
// Update user store
userStore.setUser(
levelResult.userPubkey,
levelResult.userPubkeyHex,
levelResult.level,
levelResult.error || null
);
// Update activity tracking on successful login
updateActivity();
checkingLevel = false;
levelMessage = null;
// Show appropriate message based on level
if (levelResult.level === 'unlimited') {
levelMessage = 'Unlimited access granted!';
} else if (levelResult.level === 'rate_limited') {
levelMessage = 'Logged in with rate-limited access.';
}
// User is logged in, go to repos page // User is logged in, go to repos page
goto('/repos'); goto('/repos');
} else {
checkingLevel = false;
levelMessage = null;
} }
} catch (err) { } catch (err) {
console.error('Login failed:', err); console.error('Login failed:', err);
checkingLevel = false;
levelMessage = null;
alert('Failed to login. Please make sure you have a Nostr extension installed (like nos2x or Alby).'); alert('Failed to login. Please make sure you have a Nostr extension installed (like nos2x or Alby).');
} }
} else { } else {
@ -101,8 +151,11 @@
</div> </div>
<div class="splash-message"> <div class="splash-message">
{#if checkingAuth} {#if checkingAuth || checkingLevel}
<p class="splash-text">Checking authentication...</p> <p class="splash-text">{levelMessage || 'Checking authentication...'}</p>
{#if checkingLevel && levelMessage}
<p class="splash-text-secondary">This may take a few seconds...</p>
{/if}
{:else if userPubkey} {:else if userPubkey}
<p class="splash-text">Welcome back! You're logged in.</p> <p class="splash-text">Welcome back! You're logged in.</p>
<p class="splash-text-secondary">You can now access all repositories you have permission to view.</p> <p class="splash-text-secondary">You can now access all repositories you have permission to view.</p>
@ -114,7 +167,7 @@
</div> </div>
<div class="splash-actions"> <div class="splash-actions">
{#if checkingAuth} {#if checkingAuth || checkingLevel}
<div class="splash-loading">Loading...</div> <div class="splash-loading">Loading...</div>
{:else if userPubkey} {:else if userPubkey}
<button class="splash-button splash-button-primary" onclick={() => goto('/repos')}> <button class="splash-button splash-button-primary" onclick={() => goto('/repos')}>
@ -135,13 +188,21 @@
<style> <style>
.splash-container { .splash-container {
min-height: 100vh; position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative; z-index: 9999;
overflow: hidden; overflow: hidden;
background: linear-gradient(135deg, var(--bg-primary, #f5f5f5) 0%, var(--bg-secondary, #e8e8e8) 100%); background: linear-gradient(135deg, var(--bg-primary, #f5f5f5) 0%, var(--bg-secondary, #e8e8e8) 100%);
/* Ensure it covers everything and blocks interaction */
pointer-events: auto;
} }
.splash-background { .splash-background {

193
src/routes/api/user/level/+server.ts

@ -0,0 +1,193 @@
/**
* API endpoint for verifying user level (relay write access)
* This must be done server-side for security - client-side checks can be bypassed
*
* User only needs to be able to write to ONE of the default relays, not all.
* This is a trust mechanism - users who can write to trusted default relays
* get unlimited access, limiting access to trusted npubs.
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { verifyRelayWriteProof } from '$lib/services/nostr/relay-write-proof.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { auditLogger } from '$lib/services/security/audit-logger.js';
import { getCachedUserLevel, cacheUserLevel } from '$lib/services/security/user-level-cache.js';
import { extractRequestContext } from '$lib/utils/api-context.js';
import { sanitizeError } from '$lib/utils/security.js';
import { verifyEvent } from 'nostr-tools';
import logger from '$lib/services/logger.js';
export const POST: RequestHandler = async (event) => {
const requestContext = extractRequestContext(event);
const clientIp = requestContext.clientIp || 'unknown';
try {
const body = await event.request.json();
const { proofEvent, userPubkeyHex } = body;
// Validate input
if (!proofEvent || !userPubkeyHex) {
auditLogger.logAuth(
null,
clientIp,
'NIP-98',
'failure',
'Missing proof event or user pubkey'
);
return error(400, 'Missing required fields: proofEvent and userPubkeyHex');
}
// Validate pubkey format (should be hex, 64 characters)
if (typeof userPubkeyHex !== 'string' || !/^[0-9a-f]{64}$/i.test(userPubkeyHex)) {
auditLogger.logAuth(
null,
clientIp,
'NIP-98',
'failure',
'Invalid pubkey format'
);
return error(400, 'Invalid pubkey format');
}
// Validate proof event structure
if (!proofEvent.kind || !proofEvent.pubkey || !proofEvent.created_at || !proofEvent.id) {
auditLogger.logAuth(
userPubkeyHex,
clientIp,
'NIP-98',
'failure',
'Invalid proof event structure'
);
return error(400, 'Invalid proof event structure');
}
// Validate proof event signature first (even if using cache, user must prove they have private key)
if (!verifyEvent(proofEvent)) {
auditLogger.logAuth(
userPubkeyHex,
clientIp,
'NIP-98',
'failure',
'Invalid proof event signature'
);
return error(400, 'Invalid proof event signature');
}
// Verify pubkey matches
if (proofEvent.pubkey !== userPubkeyHex) {
auditLogger.logAuth(
userPubkeyHex,
clientIp,
'NIP-98',
'failure',
'Proof event pubkey does not match user pubkey'
);
return error(400, 'Proof event pubkey does not match user pubkey');
}
// Check cache (if relays are down, use cached value)
// But user must still provide valid proof event signed with their private key
const cached = getCachedUserLevel(userPubkeyHex);
if (cached) {
logger.info({ userPubkeyHex, level: cached.level }, '[API] Using cached user level (proof event signature validated)');
return json({
level: cached.level,
userPubkeyHex,
verified: true,
cached: true
});
}
// Verify relay write proof server-side
const verification = await verifyRelayWriteProof(
proofEvent,
userPubkeyHex,
DEFAULT_NOSTR_RELAYS
);
// If relays are down, check cache again (might have been cached from previous request)
if (verification.relayDown) {
const cachedOnRelayDown = getCachedUserLevel(userPubkeyHex);
if (cachedOnRelayDown) {
logger.info({ userPubkeyHex, level: cachedOnRelayDown.level }, '[API] Relays down, using cached user level');
auditLogger.logAuth(
userPubkeyHex,
clientIp,
'NIP-98',
'success',
'Relays down, using cached access level'
);
return json({
level: cachedOnRelayDown.level,
userPubkeyHex,
verified: true,
cached: true,
relayDown: true
});
}
// No cache available and relays are down - return error
auditLogger.logAuth(
userPubkeyHex,
clientIp,
'NIP-98',
'failure',
'Relays unavailable and no cached access level'
);
return error(503, 'Relays are temporarily unavailable. Please try again later.');
}
if (verification.valid) {
// User has write access - unlimited level
// Cache the successful verification
cacheUserLevel(userPubkeyHex, 'unlimited');
auditLogger.logAuth(
userPubkeyHex,
clientIp,
'NIP-98',
'success',
'Relay write access verified'
);
return json({
level: 'unlimited',
userPubkeyHex,
verified: true
});
} else {
// User is logged in but no write access - rate limited
// Cache this level too (so they don't lose access if relays go down)
cacheUserLevel(userPubkeyHex, 'rate_limited');
auditLogger.logAuth(
userPubkeyHex,
clientIp,
'NIP-98',
'success',
'Authenticated but no relay write access'
);
return json({
level: 'rate_limited',
userPubkeyHex,
verified: true,
error: verification.error
});
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
logger.error({ error: err, clientIp }, '[API] Error verifying user level');
auditLogger.logAuth(
null,
clientIp,
'NIP-98',
'failure',
sanitizeError(errorMessage)
);
return error(500, 'Failed to verify user level');
}
};

4
src/routes/repos/[npub]/[repo]/+page.svelte

@ -708,10 +708,6 @@
} }
} }
function logout() {
userPubkey = null;
isMaintainer = false;
}
async function checkMaintainerStatus() { async function checkMaintainerStatus() {
if (repoNotFound || !userPubkey) { if (repoNotFound || !userPubkey) {

Loading…
Cancel
Save