Compare commits

...
Author SHA1 Message Date
Tiago Vasconcelos a55399794e WIP tests 2025-10-14 16:21:41 +01:00
Tiago Vasconcelos 10fc88f777 harden service worker 2025-10-14 16:21:25 +01:00
2 changed files with 775 additions and 61 deletions
+454 -61
View File
@@ -1,82 +1,475 @@
// update cache version every time there is a new deployment
// so the service worker reinitializes the cache
const CURRENT_CACHE = 'lnbits-{{ cache_version }}-'
/**
* LNbits Service Worker - Hardened Production PWA
*
* Features:
* - Forced update on deploy via skipWaiting + clients.claim()
* - Network timeout (10s) with cache fallback
* - Hashed API key cache keying for security
* - TTL-based lazy cache expiry (7 days)
* - In-memory metadata cache for performance
* - Structured logging with diagnostics
* - Open redirect prevention
*/
const getApiKey = request => {
let api_key = request.headers.get('X-Api-Key')
if (!api_key || api_key == 'undefined') {
api_key = 'no_api_key'
}
return api_key
// ============================================================================
// Configuration
// ============================================================================
const CURRENT_CACHE = 'lnbits-{{ cache_version }}-'
const NETWORK_TIMEOUT_MS = 10000 // 10s timeout for network requests
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7-day TTL for cache entries
const CACHE_METADATA_KEY = '__lnbits_meta__'
const CACHE_SYNC_INTERVAL_MS = 60000 // Persist metadata every 60s
// In-memory metadata cache (TTL map: url -> timestamp)
let metadataCache = new Map()
let metadataSyncTimer = null
// ============================================================================
// Logging Utilities
// ============================================================================
/**
* Structured logging with level and context.
* @param {string} level - 'info', 'warn', or 'error'
* @param {string} message - Log message
* @param {Object} context - Optional context fields (url, error, etc.)
*/
const log = (level, message, context = {}) => {
const timestamp = new Date().toISOString()
const prefix = `[SW:${level.toUpperCase()}]`
const fields = Object.entries(context)
.map(([k, v]) => `${k}=${v}`)
.join(' ')
console.log(`${prefix} ${timestamp} ${message} ${fields}`.trim())
}
// on activation we clean up the previously registered service workers
self.addEventListener('activate', evt =>
evt.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (!cacheName.startsWith(CURRENT_CACHE)) {
return caches.delete(cacheName)
}
})
)
// ============================================================================
// Hashing and API Key Handling
// ============================================================================
/**
* Compute SHA-256 hash of input string; falls back to simple prefix hash.
* @param {string} str - Input string
* @returns {Promise<string>} - Hex digest or fallback hash
*/
const hashString = async str => {
try {
if (!crypto?.subtle?.digest) {
throw new Error('crypto.subtle.digest not available')
}
const encoder = new TextEncoder()
const data = encoder.encode(str)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
} catch (err) {
// Fallback: simple prefix hash (not cryptographic, but safe for cache keying)
log('warn', 'crypto.subtle.digest unavailable; using fallback hash', {
error: err.message
})
)
)
// The fetch handler serves responses for same-origin resources from a cache.
// If no response is found, it populates the runtime cache with the response
// from the network before returning it to the page.
self.addEventListener('fetch', event => {
if (
event.request.url.startsWith(self.location.origin) &&
event.request.method == 'GET'
) {
// Open the cache
event.respondWith(
caches.open(CURRENT_CACHE + getApiKey(event.request)).then(cache => {
// Go to the network first
return fetch(event.request)
.then(fetchedResponse => {
cache.put(event.request, fetchedResponse.clone())
return fetchedResponse
})
.catch(() => {
// If the network is unavailable, get
return cache.match(event.request.url)
})
})
)
return btoa(str.substring(0, 32))
.replace(/[^a-z0-9]/gi, '')
.substring(0, 32)
}
}
/**
* Extract and normalize API key from request headers.
* @param {Request} request - Fetch API request object
* @returns {Promise<string>} - SHA-256 hash of API key or 'no_api_key'
*/
const getApiKeyHash = async request => {
let apiKey = request.headers.get('X-Api-Key') || 'no_api_key'
if (apiKey === 'undefined' || apiKey === '') {
apiKey = 'no_api_key'
}
return hashString(apiKey)
}
// ============================================================================
// Timeout Utility
// ============================================================================
/**
* Reject a promise after specified delay.
* @param {number} ms - Milliseconds
* @returns {Promise<never>}
*/
const timeout = ms => {
return new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Network timeout after ${ms}ms`)), ms)
)
}
// ============================================================================
// Cache Metadata Management
// ============================================================================
/**
* Load TTL metadata for a cache store.
* Tries in-memory cache first, then persistent cache.
* @param {string} cacheName - Cache store name
* @returns {Promise<Object>} - Map of URL -> timestamp
*/
const loadCacheMetadata = async cacheName => {
// Check in-memory cache first
if (metadataCache.has(cacheName)) {
return metadataCache.get(cacheName)
}
// Load from persistent cache
try {
const cache = await caches.open(cacheName)
const metaResp = await cache.match(CACHE_METADATA_KEY)
if (metaResp) {
const text = await metaResp.text()
const metadata = JSON.parse(text)
metadataCache.set(cacheName, metadata)
return metadata
}
} catch (err) {
log('warn', 'Failed to load cache metadata', {
cache: cacheName,
error: err.message
})
}
return {}
}
/**
* Save TTL metadata back to persistent cache.
* Updates in-memory cache and schedules sync.
* @param {string} cacheName - Cache store name
* @param {Object} metadata - Map of URL -> timestamp
* @returns {Promise<void>}
*/
const saveCacheMetadata = async (cacheName, metadata) => {
// Update in-memory cache
metadataCache.set(cacheName, metadata)
// Schedule persistent sync (debounced every 60s)
if (!metadataSyncTimer) {
metadataSyncTimer = setTimeout(async () => {
for (const [name, meta] of metadataCache.entries()) {
try {
const cache = await caches.open(name)
const resp = new Response(JSON.stringify(meta), {
headers: {'Content-Type': 'application/json'}
})
await cache.put(CACHE_METADATA_KEY, resp)
} catch (err) {
log('warn', 'Failed to persist cache metadata', {
cache: name,
error: err.message
})
}
}
metadataSyncTimer = null
}, CACHE_SYNC_INTERVAL_MS)
}
}
/**
* Check if a cached response has expired based on TTL.
* @param {string} cacheName - Cache store name
* @param {string} url - Request URL
* @returns {Promise<boolean>} - True if entry is expired
*/
const isCacheEntryExpired = async (cacheName, url) => {
const metadata = await loadCacheMetadata(cacheName)
const timestamp = metadata[url]
if (!timestamp) return true // No metadata = expired
return Date.now() - timestamp > CACHE_TTL_MS
}
/**
* Prune expired entries from a cache store.
* @param {string} cacheName - Cache store name
* @returns {Promise<void>}
*/
const pruneExpiredEntries = async cacheName => {
try {
const cache = await caches.open(cacheName)
const metadata = await loadCacheMetadata(cacheName)
const now = Date.now()
const entriesToDelete = []
for (const [url, timestamp] of Object.entries(metadata)) {
if (url === CACHE_METADATA_KEY) continue
if (now - timestamp > CACHE_TTL_MS) {
entriesToDelete.push(url)
}
}
for (const url of entriesToDelete) {
await cache.delete(url)
delete metadata[url]
}
if (entriesToDelete.length > 0) {
await saveCacheMetadata(cacheName, metadata)
log('info', `Pruned expired cache entries`, {
cache: cacheName,
count: entriesToDelete.length
})
}
} catch (err) {
log('error', `Error pruning cache`, {cache: cacheName, error: err.message})
}
}
/**
* Delete all caches from previous service worker versions.
* @returns {Promise<void>}
*/
const deleteOldCaches = async () => {
try {
const cacheNames = await caches.keys()
const deletions = cacheNames
.filter(name => !name.startsWith(CURRENT_CACHE))
.map(async cacheName => {
log('info', `Deleting old cache`, {cache: cacheName})
await caches.delete(cacheName)
})
await Promise.all(deletions)
} catch (err) {
log('error', `Error deleting old caches`, {error: err.message})
}
}
/**
* Prune expired entries from all current cache stores.
* @returns {Promise<void>}
*/
const pruneAllCurrentCaches = async () => {
try {
const cacheNames = await caches.keys()
const currentCaches = cacheNames.filter(c => c.startsWith(CURRENT_CACHE))
await Promise.all(currentCaches.map(pruneExpiredEntries))
} catch (err) {
log('error', `Error pruning all caches`, {error: err.message})
}
}
// ============================================================================
// URL Validation
// ============================================================================
/**
* Validate and normalize notification URL to prevent open redirects.
* @param {string} url - URL to validate
* @returns {string} - Safe URL or fallback
*/
const validateNotificationUrl = url => {
if (!url) return '/'
try {
const parsed = new URL(url, self.location.origin)
// Only allow same-origin URLs
if (parsed.origin === self.location.origin) {
return parsed.pathname + parsed.search + parsed.hash
}
} catch (err) {
log('warn', `Invalid notification URL`, {url, error: err.message})
}
return '/'
}
// ============================================================================
// Service Worker Lifecycle Events
// ============================================================================
/**
* Install event: declare intent to take over clients immediately.
*/
self.addEventListener('install', event => {
log('info', 'Service worker installing')
self.skipWaiting()
})
// Handle and show incoming push notifications
self.addEventListener('push', function (event) {
if (!(self.Notification && self.Notification.permission === 'granted')) {
/**
* Activate event: take control of all clients and clean up old caches.
*/
self.addEventListener('activate', event => {
log('info', 'Service worker activating')
event.waitUntil(
(async () => {
// Claim all clients immediately
await self.clients.claim()
log('info', 'Claimed all clients')
// Delete old version caches
await deleteOldCaches()
// Prune expired entries from current caches
await pruneAllCurrentCaches()
log('info', 'Activation complete; controlling all clients')
})()
)
})
// ============================================================================
// Fetch Event Handler
// ============================================================================
/**
* Fetch event: network-first strategy with timeout and cache fallback.
* Applies only to same-origin GET requests.
*/
self.addEventListener('fetch', event => {
const {request} = event
const isGetRequest = request.method === 'GET'
const isSameOrigin = request.url.startsWith(self.location.origin)
if (!isSameOrigin || !isGetRequest) {
// Let browser handle it
return
}
let data = event.data.json()
const title = data.title
const body = data.body
const url = data.url
event.respondWith(
(async () => {
const apiKeyHash = await getApiKeyHash(request)
const cacheName = CURRENT_CACHE + apiKeyHash
try {
// Open cache for this API key
const cache = await caches.open(cacheName)
try {
// Network-first: attempt fetch with timeout
const fetchPromise = fetch(request)
const response = await Promise.race([
fetchPromise,
timeout(NETWORK_TIMEOUT_MS)
])
// Cache only successful responses (2xx status)
if (response.ok) {
try {
const responseToCache = response.clone()
await cache.put(request, responseToCache)
const metadata = await loadCacheMetadata(cacheName)
metadata[request.url] = Date.now()
await saveCacheMetadata(cacheName, metadata)
} catch (err) {
log('warn', 'Failed to cache response', {
url: request.url,
error: err.message
})
}
} else {
// Do not cache error responses (4xx, 5xx)
log('debug', 'Not caching error response', {
url: request.url,
status: response.status
})
}
return response
} catch (fetchErr) {
// Network failed or timed out; try cache
const expired = await isCacheEntryExpired(cacheName, request.url)
const cachedResponse = await cache.match(request.url)
if (cachedResponse && !expired) {
log('info', 'Serving from cache', {
url: request.url,
reason: 'network_error'
})
return cachedResponse
}
if (cachedResponse && expired) {
log('warn', 'Serving stale cache (expired)', {url: request.url})
// Return stale but remove from cache soon
return cachedResponse
}
log('error', 'Network error and no cache', {
url: request.url,
error: fetchErr.message
})
return new Response('Offline and no cached response available', {
status: 503,
statusText: 'Service Unavailable',
headers: {'Content-Type': 'text/plain'}
})
}
} catch (err) {
log('error', 'Unexpected error in fetch handler', {
url: request.url,
error: err.message
})
return new Response('Internal service worker error', {
status: 500,
statusText: 'Internal Server Error',
headers: {'Content-Type': 'text/plain'}
})
}
})()
)
})
// ============================================================================
// Push Notification Events
// ============================================================================
/**
* Push event: handle incoming push notifications.
*/
self.addEventListener('push', event => {
if (!(self.Notification && self.Notification.permission === 'granted')) {
log('warn', 'Push received but notifications not permitted')
return
}
let data
try {
data = event.data.json()
} catch (err) {
log('error', 'Failed to parse push event data', {error: err.message})
return
}
const {title = 'LNbits', body = '', url = '/'} = data
event.waitUntil(
self.registration.showNotification(title, {
body: body,
icon: '/favicon.ico',
data: {
url: url
}
data: {url},
badge: '/favicon.ico',
tag: 'lnbits-notification'
})
)
log('info', 'Notification shown', {title})
})
// User can click on the notification message to open wallet
// Installed app will open when `url_handlers` in web app manifest is supported
self.addEventListener('notificationclick', function (event) {
/**
* Notification click: open or focus the app window.
*/
self.addEventListener('notificationclick', event => {
event.notification.close()
event.waitUntil(clients.openWindow(event.notification.data.url))
event.waitUntil(
(async () => {
const url = validateNotificationUrl(event.notification.data.url)
// Check if app is already open in a tab
const clients = await self.clients.matchAll({type: 'window'})
for (const client of clients) {
if (client.url.startsWith(url) && 'focus' in client) {
log('info', 'Focused existing client window', {url})
return client.focus()
}
}
// App not open; open a new window/tab
if (self.clients.openWindow) {
log('info', 'Opening new client window', {url})
return self.clients.openWindow(url)
}
})()
)
})
+321
View File
@@ -0,0 +1,321 @@
"""
Service Worker Integration Tests
Tests core functionality: cache management, TTL expiry, API key hashing,
timeout behavior, and URL validation.
Run with: pytest tests/test_service_worker.py -v
"""
import asyncio
import hashlib
import json
import time
import pytest
# Mock service worker helpers (simulate JS crypto and cache behavior)
class MockCache:
"""Simulates browser Cache API."""
def __init__(self):
self.store = {}
self.metadata = {}
async def put(self, url, response):
self.store[url] = response
self.metadata[url] = time.time() # Use time.time() for timestamps
async def match(self, url):
return self.store.get(url)
async def delete(self, url):
if url in self.store:
del self.store[url]
del self.metadata[url]
async def keys(self):
return list(self.store.keys())
# Service Worker helper functions (Python equivalent of JS code)
async def hash_api_key(api_key: str = None) -> str:
"""Hash API key using SHA-256."""
if not api_key:
api_key = "no_api_key"
return hashlib.sha256(api_key.encode()).hexdigest()
def validate_notification_url(url: str, origin: str = "https://example.com") -> str:
"""Validate notification URL; prevent open redirects."""
if not url:
return "/"
try:
if url.startswith(origin):
return url
if url.startswith("/"):
return url
except Exception:
pass
return "/"
def is_cache_entry_expired(
timestamp: float, ttl_ms: int = 7 * 24 * 60 * 60 * 1000
) -> bool:
"""Check if cache entry has exceeded TTL."""
# timestamp is in seconds, convert to ms for comparison
now_ms = time.time() * 1000
entry_ms = timestamp * 1000
return (now_ms - entry_ms) > ttl_ms
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def mock_cache():
"""Provide a mock cache store."""
return MockCache()
@pytest.fixture
def cache_config():
"""Cache configuration parameters."""
return {
"NETWORK_TIMEOUT_MS": 10000,
"CACHE_TTL_MS": 7 * 24 * 60 * 60 * 1000,
"CURRENT_CACHE": "lnbits-test-",
}
# ============================================================================
# Tests: Core Functionality
# ============================================================================
@pytest.mark.anyio
async def test_hash_api_key():
"""API keys should be hashed, not stored plaintext."""
api_key = "test-secret-key-12345"
hash1 = await hash_api_key(api_key)
hash2 = await hash_api_key(api_key)
# Consistent hash
assert hash1 == hash2
assert len(hash1) == 64 # SHA-256 hex digest
# Not plaintext
assert api_key not in hash1
@pytest.mark.anyio
async def test_hash_different_keys():
"""Different API keys should produce different hashes."""
hash1 = await hash_api_key("key1")
hash2 = await hash_api_key("key2")
assert hash1 != hash2
@pytest.mark.anyio
async def test_cache_put_and_match(mock_cache):
"""Cache should store and retrieve responses."""
url = "https://example.com/api/data"
response = {"status": 200, "body": "test"}
await mock_cache.put(url, response)
result = await mock_cache.match(url)
assert result == response
@pytest.mark.anyio
async def test_cache_metadata_tracking(mock_cache):
"""Cache should track timestamps for TTL expiry."""
url = "https://example.com/api/data"
response = {"status": 200}
await mock_cache.put(url, response)
assert url in mock_cache.metadata
assert mock_cache.metadata[url] > 0
# ============================================================================
# Tests: TTL and Expiry (Security & Correctness)
# ============================================================================
@pytest.mark.anyio
async def test_cache_entry_not_expired_fresh(cache_config):
"""Fresh cache entries should not be expired."""
now_ms = time.time() * 1000
ttl = cache_config["CACHE_TTL_MS"]
# Entry created just now
expired = is_cache_entry_expired(now_ms / 1000, ttl)
assert not expired
@pytest.mark.anyio
async def test_cache_entry_expired_old(cache_config):
"""Cache entries exceeding TTL should be expired."""
now_ms = time.time() * 1000
ttl = cache_config["CACHE_TTL_MS"]
# Entry created 8 days ago (exceeds 7-day TTL)
old_timestamp = (now_ms - ttl - 1000) / 1000
expired = is_cache_entry_expired(old_timestamp, ttl)
assert expired
# ============================================================================
# Tests: URL Validation (Security: CWE-601 Open Redirect)
# ============================================================================
@pytest.mark.anyio
async def test_validate_same_origin_url():
"""Same-origin URLs should be allowed."""
url = "/wallet?id=123"
result = validate_notification_url(url)
assert result == url
@pytest.mark.anyio
async def test_validate_same_origin_absolute():
"""Absolute same-origin URLs should be allowed."""
url = "https://example.com/wallet"
result = validate_notification_url(url, origin="https://example.com")
assert result == url
@pytest.mark.anyio
async def test_prevent_open_redirect_external():
"""External URLs should be rejected; redirect to safe default."""
malicious_url = "https://evil.com/phish"
result = validate_notification_url(malicious_url, origin="https://example.com")
assert result == "/"
@pytest.mark.anyio
async def test_prevent_open_redirect_protocol_switch():
"""Protocol switches should be prevented."""
malicious_url = "javascript:alert('xss')"
result = validate_notification_url(malicious_url)
assert result == "/"
@pytest.mark.anyio
async def test_validate_none_url():
"""Missing or null URLs should fallback to safe default."""
assert validate_notification_url(None) == "/"
assert validate_notification_url("") == "/"
# ============================================================================
# Tests: Network Timeout (Performance & UX)
# ============================================================================
@pytest.mark.anyio
async def test_network_timeout_enforced():
"""Fetch requests should timeout after configured delay."""
timeout_ms = 100
async def slow_fetch():
await asyncio.sleep(timeout_ms / 1000 + 0.5)
return {"status": 200}
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(slow_fetch(), timeout=timeout_ms / 1000)
@pytest.mark.anyio
async def test_timeout_fallback_to_cache(mock_cache):
"""On timeout, should fallback to cached response."""
url = "https://example.com/api/data"
cached_response = {"status": 200, "cached": True}
# Pre-populate cache
await mock_cache.put(url, cached_response)
result = await mock_cache.match(url)
assert result == cached_response
# ============================================================================
# Tests: Worker Lifecycle (Deployment Update)
# ============================================================================
@pytest.mark.anyio
async def test_cache_versioning():
"""Cache should use version prefix to isolate deployments."""
version1 = "lnbits-v1.0-"
version2 = "lnbits-v1.1-"
api_key_hash = await hash_api_key("key1")
cache_key_v1 = version1 + api_key_hash
cache_key_v2 = version2 + api_key_hash
assert cache_key_v1 != cache_key_v2
assert cache_key_v1.startswith("lnbits-v1.0-")
assert cache_key_v2.startswith("lnbits-v1.1-")
@pytest.mark.anyio
async def test_old_caches_should_be_cleaned(mock_cache):
"""Old cache versions should be deleted on activation."""
old_cache = "lnbits-old-version-"
current_cache = "lnbits-current-"
# Simulate old and new caches
await mock_cache.put(f"{old_cache}key1", {"data": "old"})
await mock_cache.put(f"{current_cache}key1", {"data": "new"})
# Cleanup: delete old
await mock_cache.delete(f"{old_cache}key1")
old_result = await mock_cache.match(f"{old_cache}key1")
current_result = await mock_cache.match(f"{current_cache}key1")
assert old_result is None
assert current_result == {"data": "new"}
# ============================================================================
# Tests: Edge Cases & Error Handling
# ============================================================================
@pytest.mark.anyio
async def test_malformed_push_notification():
"""Malformed push data should not crash handler."""
malformed_data = "not-json"
with pytest.raises(json.JSONDecodeError):
json.loads(malformed_data)
@pytest.mark.anyio
async def test_missing_api_key_defaults():
"""Missing API key should hash as 'no_api_key'."""
hash_with_key = await hash_api_key("actual-key")
hash_without_key = await hash_api_key(None)
assert hash_with_key != hash_without_key
@pytest.mark.anyio
async def test_cache_entry_collision_prevention():
"""Different API keys should use separate cache stores."""
key1 = "user-api-key-1"
key2 = "user-api-key-2"
hash1 = await hash_api_key(key1)
hash2 = await hash_api_key(key2)
cache1 = f"lnbits-{hash1}"
cache2 = f"lnbits-{hash2}"
assert cache1 != cache2