Skip to content

Commit afd0529

Browse files
committed
fix(cache,dlx,github): fix clock skew, TOCTOU races, and parsing bugs
Quality scan findings addressed: Critical fixes: - Add try-catch to JSON.parse in github.ts to prevent crashes on malformed responses High-severity fixes: - Fix unconditional .git truncation in specs.ts (now checks if URL ends with .git) - Fix scoped package parsing in dlx/package.ts (check atIndex === 0 instead of startsWith('@')) - Add clock skew protection to cache validation in dlx/binary.ts (reject future timestamps) - Add clock skew detection to TTL cache in cache-with-ttl.ts (treat far-future expiresAt as expired) Medium-severity fixes: - Fix future timestamp handling in dlx cache cleanup (treat as expired) - Use atomic write-then-rename for metadata writes in dlx/binary.ts - Add TOCTOU race protection to version file check in releases/github.ts - Replace process.exit() with process.exitCode in scripts/build/js.mjs Test updates: - Update specs.test.mts to expect correct behavior after .git truncation fix
1 parent 62cbbc5 commit afd0529

8 files changed

Lines changed: 44 additions & 15 deletions

File tree

scripts/build/js.mjs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ async function watchJS() {
101101
logger.log('\nStopping watch mode...')
102102
}
103103
await ctx.dispose()
104-
process.exit(0)
104+
process.exitCode = 0
105+
throw new Error('Watch mode interrupted')
105106
})
106107

107108
// Wait indefinitely
@@ -119,7 +120,7 @@ async function watchJS() {
119120
if (isWatch) {
120121
watchJS().catch(error => {
121122
logger.error(error)
122-
process.exit(1)
123+
process.exitCode = 1
123124
})
124125
} else {
125126
buildJS()

src/cache-with-ttl.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,16 @@ export function createTtlCache(options?: TtlCacheOptions): TtlCache {
190190

191191
/**
192192
* Check if entry is expired.
193+
* Also detects clock skew by treating suspiciously far-future expiresAt as expired.
193194
*/
194195
function isExpired(entry: TtlCacheEntry<any>): boolean {
195-
return Date.now() > entry.expiresAt
196+
const now = Date.now()
197+
// Detect future expiresAt (clock skew or corruption).
198+
// If expiresAt is more than 2x TTL in the future, treat as expired.
199+
if (entry.expiresAt > now + ttl * 2) {
200+
return true
201+
}
202+
return now > entry.expiresAt
196203
}
197204

198205
/**

src/dlx/binary.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,8 @@ export async function cleanDlxCache(
225225
? now - timestamp
226226
: Number.POSITIVE_INFINITY
227227

228-
if (age > maxAge) {
228+
// Treat future timestamps (clock skew) as expired
229+
if (age < 0 || age > maxAge) {
229230
// Remove entire cache entry directory.
230231
// eslint-disable-next-line no-await-in-loop
231232
await safeDelete(entryPath, { force: true, recursive: true })
@@ -645,7 +646,10 @@ export async function isCacheValid(
645646
return false
646647
}
647648
const age = now - timestamp
648-
649+
// Reject future timestamps (clock skew or corruption)
650+
if (age < 0) {
651+
return false
652+
}
649653
return age < cacheTtl
650654
} catch {
651655
return false
@@ -752,5 +756,8 @@ export async function writeMetadata(
752756
},
753757
}
754758
const fs = getFs()
755-
await fs.promises.writeFile(metaPath, JSON.stringify(metadata, null, 2))
759+
// Use atomic write-then-rename pattern to prevent corruption on crash
760+
const tmpPath = `${metaPath}.tmp.${process.pid}`
761+
await fs.promises.writeFile(tmpPath, JSON.stringify(metadata, null, 2))
762+
await fs.promises.rename(tmpPath, metaPath)
756763
}

src/dlx/package.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -618,8 +618,8 @@ export function parsePackageSpec(spec: string): {
618618
} catch {
619619
// Fallback to simple parsing if npm-package-arg fails.
620620
const atIndex = spec.lastIndexOf('@')
621-
if (atIndex === -1 || spec.startsWith('@')) {
622-
// No version or scoped package without version.
621+
if (atIndex === -1 || atIndex === 0) {
622+
// No version or scoped package without version (@ only at position 0).
623623
return { name: spec, version: undefined }
624624
}
625625
return {

src/github.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,16 @@ export async function fetchGitHub<T = unknown>(
214214
)
215215
}
216216

217-
return JSON.parse(response.body.toString('utf8')) as T
217+
try {
218+
return JSON.parse(response.body.toString('utf8')) as T
219+
} catch (error) {
220+
throw new Error(
221+
`Failed to parse GitHub API response: ${error instanceof Error ? error.message : String(error)}\n` +
222+
`URL: ${url}\n` +
223+
'Response may be malformed or incomplete.',
224+
{ cause: error },
225+
)
226+
}
218227
}
219228

220229
/**

src/packages/specs.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ export function getRepoUrlDetails(repoUrl: string = ''): {
1818
const userAndRepo = repoUrl.replace(/^.+github.com\//, '').split('/')
1919
const user = userAndRepo[0] || ''
2020
const project =
21-
userAndRepo.length > 1 ? userAndRepo[1]?.slice(0, -'.git'.length) || '' : ''
21+
userAndRepo.length > 1
22+
? (userAndRepo[1]?.endsWith('.git')
23+
? userAndRepo[1].slice(0, -4)
24+
: userAndRepo[1]) || ''
25+
: ''
2226
return { user, project }
2327
}
2428

src/releases/github.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,8 @@ export async function downloadGitHubRelease(
217217
const cachedVersion = (
218218
await fs.promises.readFile(versionPath, 'utf8')
219219
).trim()
220-
if (cachedVersion === tag) {
220+
// Re-check binary exists after reading version (prevent TOCTOU race)
221+
if (cachedVersion === tag && fs.existsSync(binaryPath)) {
221222
if (!quiet) {
222223
logger.info(`Using cached ${toolName} (${platformArch}): ${binaryPath}`)
223224
}

test/unit/packages/specs.test.mts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ describe('packages/specs', () => {
3232
it('should handle URL without .git extension', () => {
3333
const result = getRepoUrlDetails('https://github.com/nodejs/node')
3434
expect(result.user).toBe('nodejs')
35-
// Note: function slices off 4 chars (.git) even when not present
36-
expect(result.project).toBe('')
35+
// Fixed: now correctly returns project name without .git extension
36+
expect(result.project).toBe('node')
3737
})
3838

3939
it('should handle git@ protocol URLs', () => {
@@ -72,8 +72,8 @@ describe('packages/specs', () => {
7272
'https://github.com/facebook/react/tree/main',
7373
)
7474
expect(result.user).toBe('facebook')
75-
// Note: function slices off 4 chars (.git) from "react/tree/main"
76-
expect(result.project).toBe('r')
75+
// Fixed: now correctly returns repo name without incorrect .git truncation
76+
expect(result.project).toBe('react')
7777
})
7878
})
7979

0 commit comments

Comments
 (0)