-
Notifications
You must be signed in to change notification settings - Fork 214
Fix: console update #2828
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Fix: console update #2828
Changes from all commits
b85c938
09ef6f3
6f403ba
ad097ec
266ee4a
100a967
a080abe
83bd6af
4148c69
cb13e06
7b0511a
520c491
e04dfa3
0cc3ebb
ff73a2c
e3fa833
878975a
73f69c5
d35a403
96bd28b
e4f32ec
52eecc9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| import { env } from '$env/dynamic/public'; | ||
|
|
||
| const SECRET = env.PUBLIC_CONSOLE_FINGERPRINT_KEY ?? ''; | ||
| const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour | ||
|
|
||
| async function sha256(message: string): Promise<string> { | ||
| const data = new TextEncoder().encode(message); | ||
| const hash = await crypto.subtle.digest('SHA-256', data); | ||
| return Array.from(new Uint8Array(hash)) | ||
| .map((b) => b.toString(16).padStart(2, '0')) | ||
| .join(''); | ||
| } | ||
|
|
||
| async function hmacSha256(message: string, secret: string): Promise<string> { | ||
| const key = await crypto.subtle.importKey( | ||
| 'raw', | ||
| new TextEncoder().encode(secret), | ||
| { name: 'HMAC', hash: 'SHA-256' }, | ||
| false, | ||
| ['sign'] | ||
| ); | ||
| const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message)); | ||
| return Array.from(new Uint8Array(sig)) | ||
| .map((b) => b.toString(16).padStart(2, '0')) | ||
| .join(''); | ||
| } | ||
|
|
||
| function getCanvasFingerprint(): string { | ||
| try { | ||
| const canvas = document.createElement('canvas'); | ||
| const ctx = canvas.getContext('2d'); | ||
| if (!ctx) return ''; | ||
|
|
||
| canvas.width = 200; | ||
| canvas.height = 50; | ||
|
|
||
| ctx.textBaseline = 'top'; | ||
| ctx.font = '14px Arial'; | ||
| ctx.fillStyle = '#f60'; | ||
| ctx.fillRect(125, 1, 62, 20); | ||
| ctx.fillStyle = '#069'; | ||
| ctx.fillText('Appwrite Console', 2, 15); | ||
| ctx.fillStyle = 'rgba(102, 204, 0, 0.7)'; | ||
| ctx.fillText('Appwrite Console', 4, 17); | ||
|
|
||
| return canvas.toDataURL(); | ||
| } catch { | ||
| return ''; | ||
| } | ||
| } | ||
|
|
||
| function getWebGLFingerprint(): string { | ||
| try { | ||
| const canvas = document.createElement('canvas'); | ||
| const gl = | ||
| canvas.getContext('webgl') || | ||
| (canvas.getContext('experimental-webgl') as WebGLRenderingContext | null); | ||
| if (!gl) return ''; | ||
|
|
||
| const debugInfo = gl.getExtension('WEBGL_debug_renderer_info'); | ||
| if (!debugInfo) return 'webgl-no-debug'; | ||
|
|
||
| const vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) || ''; | ||
| const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) || ''; | ||
|
|
||
| return `${vendor}~${renderer}`; | ||
| } catch { | ||
| return ''; | ||
| } | ||
| } | ||
|
|
||
| async function getAudioFingerprint(): Promise<string> { | ||
| try { | ||
| const OfflineCtx = | ||
| window.OfflineAudioContext || | ||
| (window as unknown as { webkitOfflineAudioContext: typeof OfflineAudioContext }) | ||
| .webkitOfflineAudioContext; | ||
| if (!OfflineCtx) return ''; | ||
|
|
||
| const sampleRate = 44100; | ||
| const length = 4096; | ||
| const context = new OfflineCtx(1, length, sampleRate); | ||
|
|
||
| const oscillator = context.createOscillator(); | ||
| oscillator.type = 'triangle'; | ||
| oscillator.frequency.value = 10000; | ||
|
|
||
| const compressor = context.createDynamicsCompressor(); | ||
| compressor.threshold.value = -50; | ||
| compressor.knee.value = 40; | ||
| compressor.ratio.value = 12; | ||
| compressor.attack.value = 0; | ||
| compressor.release.value = 0.25; | ||
|
|
||
| oscillator.connect(compressor); | ||
| compressor.connect(context.destination); | ||
|
|
||
| oscillator.start(0); | ||
|
|
||
| const buffer = await context.startRendering(); | ||
| const samples = buffer.getChannelData(0); | ||
|
|
||
| let sum = 0; | ||
| for (let i = 0; i < samples.length; i++) { | ||
| sum += Math.abs(samples[i]); | ||
| } | ||
|
|
||
| return sum.toString(); | ||
| } catch { | ||
| return ''; | ||
| } | ||
| } | ||
|
|
||
| interface StaticSignals { | ||
| userAgent: string; | ||
| language: string; | ||
| languages: string[]; | ||
| platform: string; | ||
| hardwareConcurrency: number; | ||
| deviceMemory: number | undefined; | ||
| maxTouchPoints: number; | ||
| screenWidth: number; | ||
| screenHeight: number; | ||
| screenColorDepth: number; | ||
| screenPixelDepth: number; | ||
| devicePixelRatio: number; | ||
| timezoneOffset: number; | ||
| timezone: string; | ||
| canvas: string; | ||
| webgl: string; | ||
| audio: string; | ||
| } | ||
|
|
||
| interface BrowserSignals extends StaticSignals { | ||
| timestamp: number; | ||
| } | ||
|
|
||
| interface SignalsCache { | ||
| signals: StaticSignals; | ||
| collectedAt: number; | ||
| } | ||
|
|
||
| let cache: SignalsCache | null = null; | ||
| let cachePromise: Promise<StaticSignals> | null = null; | ||
|
|
||
| async function collectStaticSignals(): Promise<StaticSignals> { | ||
| const [canvasRaw, webgl, audio] = await Promise.all([ | ||
| Promise.resolve(getCanvasFingerprint()), | ||
| Promise.resolve(getWebGLFingerprint()), | ||
| getAudioFingerprint() | ||
| ]); | ||
|
|
||
| const canvas = canvasRaw ? await sha256(canvasRaw) : ''; | ||
|
|
||
| return { | ||
| userAgent: navigator.userAgent, | ||
| language: navigator.language, | ||
| languages: [...(navigator.languages || [])], | ||
| platform: navigator.platform, | ||
| hardwareConcurrency: navigator.hardwareConcurrency || 0, | ||
| deviceMemory: (navigator as Navigator & { deviceMemory?: number }).deviceMemory, | ||
| maxTouchPoints: navigator.maxTouchPoints || 0, | ||
| screenWidth: screen.width, | ||
| screenHeight: screen.height, | ||
| screenColorDepth: screen.colorDepth, | ||
| screenPixelDepth: screen.pixelDepth, | ||
| devicePixelRatio: window.devicePixelRatio || 1, | ||
| timezoneOffset: new Date().getTimezoneOffset(), | ||
| timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, | ||
| canvas, | ||
| webgl, | ||
| audio | ||
| }; | ||
| } | ||
|
Comment on lines
+146
to
+174
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Privacy/Compliance: Browser fingerprinting collects device-identifying data. This function collects extensive device fingerprinting signals (hardware concurrency, device memory, screen properties, canvas/WebGL/audio fingerprints) that can uniquely identify users across sessions. Under GDPR/CCPA, browser fingerprinting may require:
Ensure legal/compliance review has approved this data collection, and that appropriate disclosures exist. 🤖 Prompt for AI Agents |
||
|
|
||
| async function getCachedSignals(): Promise<StaticSignals> { | ||
| const now = Date.now(); | ||
|
|
||
| if (cache && now - cache.collectedAt < CACHE_TTL_MS) { | ||
| return cache.signals; | ||
| } | ||
|
|
||
| if (cachePromise) { | ||
| return cachePromise; | ||
| } | ||
|
|
||
| cachePromise = collectStaticSignals(); | ||
|
|
||
| try { | ||
| const signals = await cachePromise; | ||
| cache = { signals, collectedAt: now }; | ||
| return signals; | ||
| } finally { | ||
| cachePromise = null; | ||
| } | ||
| } | ||
|
|
||
| export async function generateFingerprintToken(): Promise<string> { | ||
| const staticSignals = await getCachedSignals(); | ||
|
|
||
| const signals: BrowserSignals = { | ||
| ...staticSignals, | ||
| timestamp: Math.floor(Date.now() / 1000) | ||
| }; | ||
|
|
||
| const payload = JSON.stringify(signals); | ||
| const encoded = btoa(payload); | ||
|
|
||
| if (!SECRET) { | ||
| return encoded; | ||
| } | ||
|
|
||
| const signature = await hmacSha256(encoded, SECRET); | ||
|
|
||
| return `${encoded}.${signature}`; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical: HMAC signing secret is exposed to the client.
The
PUBLIC_CONSOLE_FINGERPRINT_KEYuses SvelteKit'sPUBLIC_prefix, which means this value is bundled into client-side JavaScript and visible to anyone inspecting the browser. This completely undermines the integrity guarantee of HMAC signing—users can forge valid fingerprint tokens.Additionally, the empty string fallback silently produces signatures with a null key when the env var is missing.
If server-side verification is intended, the signing must occur on the server, or use a different authentication mechanism. If this is intentional client-side obfuscation only, consider documenting that limitation.
🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@lohanidamodar this is a public key from public/private pair, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.