-
Notifications
You must be signed in to change notification settings - Fork 252
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
Fix: console update #2828
Changes from 12 commits
b85c938
09ef6f3
6f403ba
ad097ec
266ee4a
100a967
a080abe
83bd6af
4148c69
cb13e06
7b0511a
520c491
e04dfa3
0cc3ebb
ff73a2c
e3fa833
878975a
73f69c5
d35a403
96bd28b
e4f32ec
52eecc9
55a8cf6
1f3a6be
fadeb6f
657366d
736e667
831eb50
48172a3
4d35cc6
042490a
809d683
3c7f679
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,217 @@ | ||
| 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(''); | ||
| } | ||
|
lohanidamodar marked this conversation as resolved.
|
||
|
|
||
| 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 ''; | ||
| } | ||
| } | ||
|
|
||
| function getAudioFingerprint(): Promise<string> { | ||
| return new Promise((resolve) => { | ||
| try { | ||
| const AudioContext = | ||
| window.AudioContext || | ||
| (window as unknown as { webkitAudioContext: typeof window.AudioContext }) | ||
| .webkitAudioContext; | ||
| if (!AudioContext) { | ||
| resolve(''); | ||
| return; | ||
| } | ||
|
|
||
| const context = new AudioContext(); | ||
| const oscillator = context.createOscillator(); | ||
| const analyser = context.createAnalyser(); | ||
| const gain = context.createGain(); | ||
| const processor = context.createScriptProcessor(4096, 1, 1); | ||
|
|
||
| gain.gain.value = 0; | ||
| oscillator.type = 'triangle'; | ||
| oscillator.frequency.value = 10000; | ||
|
|
||
| oscillator.connect(analyser); | ||
| analyser.connect(processor); | ||
| processor.connect(gain); | ||
| gain.connect(context.destination); | ||
|
|
||
| oscillator.start(0); | ||
|
|
||
| const dataArray = new Float32Array(analyser.frequencyBinCount); | ||
| analyser.getFloatFrequencyData(dataArray); | ||
|
|
||
| let sum = 0; | ||
| for (let i = 0; i < dataArray.length; i++) { | ||
| sum += Math.abs(dataArray[i]); | ||
| } | ||
|
|
||
| oscillator.stop(); | ||
| processor.disconnect(); | ||
| context.close(); | ||
|
|
||
| resolve(sum.toString()); | ||
| } catch { | ||
| resolve(''); | ||
| } | ||
| }); | ||
| } | ||
|
lohanidamodar marked this conversation as resolved.
Outdated
|
||
|
|
||
| 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
+150
to
+178
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); | ||
|
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. Large payload sent with every request: the fingerprint token includes full browser signals (user agent, screen resolution, etc.) in base64-encoded JSON. Consider hashing the signals and only sending the hash + signature to reduce payload size and improve performance. Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| const signature = await hmacSha256(encoded, SECRET); | ||
|
|
||
| return `${encoded}.${signature}`; | ||
| } | ||
|
lohanidamodar marked this conversation as resolved.
|
||
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.