1+ import { createHash } from "node:crypto" ;
12import fs from "node:fs" ;
23import path from "node:path" ;
34import { Readable } from "node:stream" ;
45import { pipeline } from "node:stream/promises" ;
56import type { ReadableStream as NodeReadableStream } from "node:stream/web" ;
7+ import { isWindowsDrivePath } from "../infra/archive-path.js" ;
68import {
7- isWindowsDrivePath ,
8- resolveArchiveOutputPath ,
9- stripArchivePath ,
10- validateArchiveEntryPath ,
11- } from "../infra/archive-path.js" ;
12- import { extractArchive as extractArchiveSafe } from "../infra/archive.js" ;
9+ createTarEntrySafetyChecker ,
10+ extractArchive as extractArchiveSafe ,
11+ } from "../infra/archive.js" ;
1312import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js" ;
1413import { isWithinDir } from "../infra/path-safety.js" ;
1514import { runCommandWithTimeout } from "../process/exec.js" ;
@@ -63,6 +62,101 @@ function resolveArchiveType(spec: SkillInstallSpec, filename: string): string |
6362 return undefined ;
6463}
6564
65+ const TAR_VERBOSE_MONTHS = new Set ( [
66+ "Jan" ,
67+ "Feb" ,
68+ "Mar" ,
69+ "Apr" ,
70+ "May" ,
71+ "Jun" ,
72+ "Jul" ,
73+ "Aug" ,
74+ "Sep" ,
75+ "Oct" ,
76+ "Nov" ,
77+ "Dec" ,
78+ ] ) ;
79+ const ISO_DATE_PATTERN = / ^ \d { 4 } - \d { 2 } - \d { 2 } $ / ;
80+
81+ function mapTarVerboseTypeChar ( typeChar : string ) : string {
82+ switch ( typeChar ) {
83+ case "l" :
84+ return "SymbolicLink" ;
85+ case "h" :
86+ return "Link" ;
87+ case "b" :
88+ return "BlockDevice" ;
89+ case "c" :
90+ return "CharacterDevice" ;
91+ case "p" :
92+ return "FIFO" ;
93+ case "s" :
94+ return "Socket" ;
95+ case "d" :
96+ return "Directory" ;
97+ default :
98+ return "File" ;
99+ }
100+ }
101+
102+ function parseTarVerboseSize ( line : string ) : number {
103+ const tokens = line . trim ( ) . split ( / \s + / ) . filter ( Boolean ) ;
104+ if ( tokens . length < 6 ) {
105+ throw new Error ( `unable to parse tar verbose metadata: ${ line } ` ) ;
106+ }
107+
108+ let dateIndex = tokens . findIndex ( ( token ) => TAR_VERBOSE_MONTHS . has ( token ) ) ;
109+ if ( dateIndex > 0 ) {
110+ const size = Number . parseInt ( tokens [ dateIndex - 1 ] ?? "" , 10 ) ;
111+ if ( ! Number . isFinite ( size ) || size < 0 ) {
112+ throw new Error ( `unable to parse tar entry size: ${ line } ` ) ;
113+ }
114+ return size ;
115+ }
116+
117+ dateIndex = tokens . findIndex ( ( token ) => ISO_DATE_PATTERN . test ( token ) ) ;
118+ if ( dateIndex > 0 ) {
119+ const size = Number . parseInt ( tokens [ dateIndex - 1 ] ?? "" , 10 ) ;
120+ if ( ! Number . isFinite ( size ) || size < 0 ) {
121+ throw new Error ( `unable to parse tar entry size: ${ line } ` ) ;
122+ }
123+ return size ;
124+ }
125+
126+ throw new Error ( `unable to parse tar verbose metadata: ${ line } ` ) ;
127+ }
128+
129+ function parseTarVerboseMetadata ( stdout : string ) : Array < { type : string ; size : number } > {
130+ const lines = stdout
131+ . split ( "\n" )
132+ . map ( ( line ) => line . trim ( ) )
133+ . filter ( Boolean ) ;
134+ return lines . map ( ( line ) => {
135+ const typeChar = line [ 0 ] ?? "" ;
136+ if ( ! typeChar ) {
137+ throw new Error ( "unable to parse tar entry type" ) ;
138+ }
139+ return {
140+ type : mapTarVerboseTypeChar ( typeChar ) ,
141+ size : parseTarVerboseSize ( line ) ,
142+ } ;
143+ } ) ;
144+ }
145+
146+ async function hashFileSha256 ( filePath : string ) : Promise < string > {
147+ const hash = createHash ( "sha256" ) ;
148+ const stream = fs . createReadStream ( filePath ) ;
149+ return await new Promise < string > ( ( resolve , reject ) => {
150+ stream . on ( "data" , ( chunk ) => {
151+ hash . update ( chunk as Buffer ) ;
152+ } ) ;
153+ stream . on ( "error" , reject ) ;
154+ stream . on ( "end" , ( ) => {
155+ resolve ( hash . digest ( "hex" ) ) ;
156+ } ) ;
157+ } ) ;
158+ }
159+
66160async function downloadFile (
67161 url : string ,
68162 destPath : string ,
@@ -132,6 +226,8 @@ async function extractArchive(params: {
132226 return { stdout : "" , stderr : "tar not found on PATH" , code : null } ;
133227 }
134228
229+ const preflightHash = await hashFileSha256 ( archivePath ) ;
230+
135231 // Preflight list to prevent zip-slip style traversal before extraction.
136232 const listResult = await runCommandWithTimeout ( [ "tar" , "tf" , archivePath ] , { timeoutMs } ) ;
137233 if ( listResult . code !== 0 ) {
@@ -154,34 +250,43 @@ async function extractArchive(params: {
154250 code : verboseResult . code ,
155251 } ;
156252 }
157- for ( const line of verboseResult . stdout . split ( "\n" ) ) {
158- const trimmed = line . trim ( ) ;
159- if ( ! trimmed ) {
160- continue ;
161- }
162- const typeChar = trimmed [ 0 ] ;
163- if ( typeChar === "l" || typeChar === "h" || trimmed . includes ( " -> " ) ) {
253+ const metadata = parseTarVerboseMetadata ( verboseResult . stdout ) ;
254+ if ( metadata . length !== entries . length ) {
255+ return {
256+ stdout : verboseResult . stdout ,
257+ stderr : `tar verbose/list entry count mismatch (${ metadata . length } vs ${ entries . length } )` ,
258+ code : 1 ,
259+ } ;
260+ }
261+ const checkTarEntrySafety = createTarEntrySafetyChecker ( {
262+ rootDir : targetDir ,
263+ stripComponents : strip ,
264+ escapeLabel : "targetDir" ,
265+ } ) ;
266+ for ( let i = 0 ; i < entries . length ; i += 1 ) {
267+ const entryPath = entries [ i ] ;
268+ const entryMeta = metadata [ i ] ;
269+ if ( ! entryPath || ! entryMeta ) {
164270 return {
165271 stdout : verboseResult . stdout ,
166- stderr : "tar archive contains link entries; refusing to extract for safety " ,
272+ stderr : "tar metadata parse failure " ,
167273 code : 1 ,
168274 } ;
169275 }
276+ checkTarEntrySafety ( {
277+ path : entryPath ,
278+ type : entryMeta . type ,
279+ size : entryMeta . size ,
280+ } ) ;
170281 }
171282
172- for ( const entry of entries ) {
173- validateArchiveEntryPath ( entry , { escapeLabel : "targetDir" } ) ;
174- const relPath = stripArchivePath ( entry , strip ) ;
175- if ( ! relPath ) {
176- continue ;
177- }
178- validateArchiveEntryPath ( relPath , { escapeLabel : "targetDir" } ) ;
179- resolveArchiveOutputPath ( {
180- rootDir : targetDir ,
181- relPath,
182- originalPath : entry ,
183- escapeLabel : "targetDir" ,
184- } ) ;
283+ const postPreflightHash = await hashFileSha256 ( archivePath ) ;
284+ if ( postPreflightHash !== preflightHash ) {
285+ return {
286+ stdout : "" ,
287+ stderr : "tar archive changed during safety preflight; refusing to extract" ,
288+ code : 1 ,
289+ } ;
185290 }
186291
187292 const argv = [ "tar" , "xf" , archivePath , "-C" , targetDir ] ;
0 commit comments