@@ -5,12 +5,14 @@ import { readFileSync } from 'fs';
5
5
import { resolve } from 'path' ;
6
6
import { getLocale } from "./getLocale" ;
7
7
import { filterHolidaysFromMetrics , isHoliday , parseUtcDate } from '@/utils/dateUtils' ;
8
+ import { createHash } from 'crypto' ;
8
9
9
10
const cache = new Map < string , CacheData > ( ) ;
10
11
11
12
interface CacheData {
12
13
data : CopilotMetrics [ ] ;
13
14
valid_until : number ;
15
+ auth_fingerprint : string ; // hashed representation of Authorization header used to populate cache
14
16
}
15
17
16
18
class MetricsError extends Error {
@@ -24,6 +26,50 @@ class MetricsError extends Error {
24
26
}
25
27
}
26
28
29
+ /**
30
+ * Builds a cache key for metrics data that is bound to the caller's Authorization header (hashed) + path + query.
31
+ * Exported for unit testing.
32
+ */
33
+ type QueryParamValue = string | string [ ] | undefined ;
34
+ type QueryParams = Record < string , QueryParamValue > ;
35
+
36
+ export function buildMetricsCacheKey ( path : string , query : QueryParams , authHeader : string ) : string {
37
+ // Split existing query params from provided path (if any)
38
+ const [ rawPath , existingQueryString ] = path . split ( '?' ) ;
39
+ const merged = new Map < string , string > ( ) ;
40
+
41
+ // Add existing params first
42
+ if ( existingQueryString ) {
43
+ const existingParams = new URLSearchParams ( existingQueryString ) ;
44
+ existingParams . forEach ( ( value , key ) => {
45
+ merged . set ( key , value ) ;
46
+ } ) ;
47
+ }
48
+
49
+ // Merge in provided query object (overrides existing)
50
+ Object . entries ( query ) . forEach ( ( [ k , v ] ) => {
51
+ if ( v === undefined || v === null ) return ;
52
+ if ( Array . isArray ( v ) ) {
53
+ if ( v . length === 0 ) return ;
54
+ merged . set ( k , v . join ( ',' ) ) ;
55
+ } else if ( v !== '' ) {
56
+ merged . set ( k , v ) ;
57
+ }
58
+ } ) ;
59
+
60
+ // Build stable, sorted query string
61
+ const sortedKeys = Array . from ( merged . keys ( ) ) . sort ( ) ;
62
+ const finalParams = new URLSearchParams ( ) ;
63
+ sortedKeys . forEach ( k => {
64
+ const val = merged . get ( k ) ;
65
+ if ( val !== undefined ) finalParams . set ( k , val ) ;
66
+ } ) ;
67
+ const finalQueryString = finalParams . toString ( ) ;
68
+
69
+ const authFingerprint = createHash ( 'sha256' ) . update ( authHeader ) . digest ( 'hex' ) . slice ( 0 , 16 ) ; // short fingerprint
70
+ return `${ authFingerprint } :${ rawPath } ${ finalQueryString ? `?${ finalQueryString } ` : '' } ` ;
71
+ }
72
+
27
73
export async function getMetricsData ( event : H3Event < EventHandlerRequest > ) : Promise < CopilotMetrics [ ] > {
28
74
const logger = console ;
29
75
const query = getQuery ( event ) ;
@@ -52,30 +98,33 @@ export async function getMetricsData(event: H3Event<EventHandlerRequest>): Promi
52
98
return usageData ;
53
99
}
54
100
55
- // Create cache key that includes query parameters to differentiate between different date ranges
56
- const queryString = new URLSearchParams ( query as Record < string , string > ) . toString ( ) ;
57
- const cacheKey = queryString ? `${ event . path } ?${ queryString } ` : event . path ;
101
+ // Authorization must be validated BEFORE any cache lookup to prevent leakage of cached data
102
+ const authHeader = event . context . headers . get ( 'Authorization' ) ;
103
+ if ( ! authHeader ) {
104
+ logger . error ( 'No Authentication provided' ) ;
105
+ throw new MetricsError ( 'No Authentication provided' , 401 ) ;
106
+ }
107
+
108
+ // Build auth-bound cache key
109
+ const path = event . path || '/api/metrics' ; // fallback path (should always exist in practice)
110
+ const cacheKey = buildMetricsCacheKey ( path , query as QueryParams , authHeader ) ;
58
111
59
- if ( cache . has ( cacheKey ) ) {
60
- const cachedData = cache . get ( cacheKey ) ;
61
- if ( cachedData && cachedData . valid_until > Date . now ( ) / 1000 ) {
112
+ // Attempt cache lookup with auth fingerprint validation
113
+ const cachedData = cache . get ( cacheKey ) ;
114
+ if ( cachedData ) {
115
+ if ( cachedData . valid_until > Date . now ( ) / 1000 && cachedData . auth_fingerprint === cacheKey . split ( ':' ) [ 0 ] ) {
62
116
logger . info ( `Returning cached data for ${ cacheKey } ` ) ;
63
117
return cachedData . data ;
64
118
} else {
65
- logger . info ( `Cached data for ${ cacheKey } is expired, fetching new data` ) ;
119
+ logger . info ( `Cached data for ${ cacheKey } is expired or fingerprint mismatch , fetching new data` ) ;
66
120
cache . delete ( cacheKey ) ;
67
121
}
68
122
}
69
123
70
- if ( ! event . context . headers . has ( 'Authorization' ) ) {
71
- logger . error ( 'No Authentication provided' ) ;
72
- throw new MetricsError ( 'No Authentication provided' , 401 ) ;
73
- }
74
-
75
124
logger . info ( `Fetching metrics data from ${ apiUrl } ` ) ;
76
125
77
126
try {
78
- const response = await $fetch ( apiUrl , {
127
+ const response = await $fetch ( apiUrl , {
79
128
headers : event . context . headers
80
129
} ) as unknown [ ] ;
81
130
@@ -85,7 +134,8 @@ export async function getMetricsData(event: H3Event<EventHandlerRequest>): Promi
85
134
const filteredUsageData = filterHolidaysFromMetrics ( usageData , options . excludeHolidays || false , options . locale ) ;
86
135
// metrics is the old API format
87
136
const validUntil = Math . floor ( Date . now ( ) / 1000 ) + 5 * 60 ; // Cache for 5 minutes
88
- cache . set ( cacheKey , { data : filteredUsageData , valid_until : validUntil } ) ;
137
+ const authFingerprint : string = cacheKey . split ( ':' , 1 ) [ 0 ] || '' ;
138
+ cache . set ( cacheKey , { data : filteredUsageData , valid_until : validUntil , auth_fingerprint : authFingerprint } ) ;
89
139
return filteredUsageData ;
90
140
} catch ( error : unknown ) {
91
141
logger . error ( 'Error fetching metrics data:' , error ) ;
@@ -131,17 +181,12 @@ function updateMockDataDates(originalData: CopilotMetrics[], since?: string, unt
131
181
}
132
182
133
183
// Update dates in the dataset, copying existing entries when needed
134
- const result = dateRange . map ( ( date , index ) => {
135
- // Use existing data entries, cycling through them
184
+ const result : CopilotMetrics [ ] = dateRange . map ( ( date , index ) => {
136
185
const dataIndex = index % originalData . length ;
137
- const entry = { ...originalData [ dataIndex ] } ;
138
-
139
- // Update the date
140
- entry . date = date . toISOString ( ) . split ( 'T' ) [ 0 ] ;
141
-
142
- return entry ;
186
+ const src = originalData [ dataIndex ] ;
187
+ const newDate = date . toISOString ( ) . split ( 'T' ) [ 0 ] ;
188
+ return { ...( src as CopilotMetrics ) , date : newDate } as CopilotMetrics ;
143
189
} ) ;
144
-
145
190
return result ;
146
191
}
147
192
0 commit comments