-
Notifications
You must be signed in to change notification settings - Fork 139
SNOW-1825789 Secure token cache #1012
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: master
Are you sure you want to change the base?
Changes from 45 commits
d72bac3
89e3b4b
ada2872
f638e60
c9c6bd6
a431cb8
770e3b1
7e3dc4f
dcc6d2e
d51750d
b0c0752
1c3dc35
f813df5
9cb1c16
03e376f
800a58a
47a684a
7f4afd4
ef6e428
d279ca6
52a088c
43e1040
6d49ccf
1778e9f
7a1b31d
c99010b
cdc5484
3ba209a
948cd27
8e9e85d
13e04df
90f758d
f1e8ebf
74c6d1c
5f2cf3c
d5898c9
2d65bbf
47e6449
3f66eb2
dc36c39
3097c53
c036882
f2ea3b3
a24a749
50e7e39
25f6ccb
86fd21b
3514cd7
81a56df
24995a4
3048378
0140865
76462a5
1d15e2e
380c2f0
cf9b613
55a6b4b
aed35d0
29c74f2
669e214
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,82 +1,233 @@ | ||
const path = require('path'); | ||
const Logger = require('../../logger'); | ||
const fs = require('node:fs/promises'); | ||
const os = require('os'); | ||
const Util = require('../../util'); | ||
const { validateOnlyUserReadWritePermissionAndOwner } = require('../../file_util'); | ||
const os = require('os'); | ||
const crypto = require('crypto'); | ||
const { getSecureHandle } = require('../../file_util'); | ||
|
||
function JsonCredentialManager(credentialCacheDir, timeoutMs = 60000) { | ||
const topLevelKey = 'tokens'; | ||
|
||
this.hashKey = function (key) { | ||
return crypto.createHash('sha256').update(key).digest('hex'); | ||
}; | ||
|
||
this.getTokenDirCandidates = function () { | ||
const candidates = []; | ||
if (Util.exists(credentialCacheDir)) { | ||
candidates.push({ folder: credentialCacheDir, subfolders: [] }); | ||
} | ||
const sfTemp = process.env.SF_TEMPORARY_CREDENTIAL_CACHE_DIR; | ||
if (Util.exists(sfTemp)) { | ||
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. Checking if this 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. Done |
||
candidates.push({ folder: sfTemp, subfolders: [] }); | ||
} | ||
const xdgCache = process.env.XDG_CACHE_HOME; | ||
if (Util.exists(xdgCache) && process.platform === 'linux') { | ||
candidates.push({ folder: xdgCache, subfolders: ['snowflake'] }); | ||
} | ||
const home = process.env.HOME; | ||
switch (process.platform) { | ||
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. Nit: it's hard to see what's returned for each system. Can you do it like this
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. Didn't do exactly that, but without the null/undefined checks the function is much more readable now 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. Looks good |
||
case 'win32': | ||
candidates.push({ folder: os.homedir(), subfolders: ['AppData', 'Local', 'Snowflake', 'Caches'] }); | ||
break; | ||
case 'linux': | ||
if (Util.exists(home)) { | ||
candidates.push({ folder: home, subfolders: ['.cache', 'snowflake'] }); | ||
} | ||
break; | ||
case 'darwin': | ||
if (Util.exists(home)) { | ||
candidates.push({ folder: home, subfolders: ['Library', 'Caches', 'Snowflake'] }); | ||
} | ||
} | ||
return candidates; | ||
}; | ||
|
||
this.tryTokenDir = async function (dir, subDirs) { | ||
const cacheDir = path.join(dir, ...subDirs); | ||
try { | ||
const stat = await fs.stat(dir); | ||
if (!stat.isDirectory()) { | ||
Logger.getInstance().info(`Path ${dir} is not a directory`); | ||
return false; | ||
} | ||
const cacheStat = await fs.lstat(cacheDir).catch(() => {}); | ||
if (!Util.exists(cacheStat)) { | ||
sfc-gh-pmotacki marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const options = { recursive: true }; | ||
sfc-gh-pmotacki marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if (process.platform !== 'win32') { | ||
options.mode = 0o700; | ||
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. We are using here 0o700 permissions and recursive. This is problematic since for example:
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. Fair, changed to creating recursively with |
||
} | ||
await fs.mkdir(cacheDir, options); | ||
sfc-gh-pmotacki marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return true; | ||
} else { | ||
if (process.platform === 'win32') { | ||
return true; | ||
} | ||
if ((cacheStat.mode & 0o777) === 0o700) { | ||
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. We should also check owner 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. Added |
||
return true; | ||
} | ||
await fs.chmod(cacheDir, 0o700); | ||
sfc-gh-pmotacki marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return true; | ||
} | ||
} catch (err) { | ||
Logger.getInstance().warn(`The path location ${cacheDir} is invalid. Please check this location is accessible or existing`); | ||
return false; | ||
} | ||
}; | ||
|
||
function JsonCredentialManager(credentialCacheDir) { | ||
|
||
this.getTokenDir = async function () { | ||
let tokenDir = credentialCacheDir; | ||
if (!Util.exists(tokenDir)) { | ||
tokenDir = os.homedir(); | ||
} else { | ||
Logger.getInstance().info(`The credential cache directory is configured by the user. The token will be saved at ${tokenDir}`); | ||
const candidates = this.getTokenDirCandidates(); | ||
for (const candidate of candidates) { | ||
const { folder: dir, subfolders: subDirs } = candidate; | ||
if (await this.tryTokenDir(dir, subDirs)) { | ||
return path.join(dir, ...subDirs); | ||
} else { | ||
Logger.getInstance().info(`${path.join(dir, ...subDirs)} is not a valid cache directory`); | ||
} | ||
} | ||
return null; | ||
}; | ||
|
||
this.getTokenFile = async function () { | ||
const tokenDir = await this.getTokenDir(); | ||
|
||
if (!Util.exists(tokenDir)) { | ||
throw new Error(`Temporary credential cache directory is invalid, and the driver is unable to use the default location(home). | ||
throw new Error(`Temporary credential cache directory is invalid, and the driver is unable to use the default location. | ||
Please set 'credentialCacheDir' connection configuration option to enable the default credential manager.`); | ||
} | ||
|
||
const tokenCacheFile = path.join(tokenDir, 'temporary_credential.json'); | ||
await validateOnlyUserReadWritePermissionAndOwner(tokenCacheFile); | ||
return tokenCacheFile; | ||
const tokenCacheFile = path.join(tokenDir, 'credential_cache_v1.json'); | ||
return [await getSecureHandle(tokenCacheFile, fs.constants.O_RDWR | fs.constants.O_CREAT, fs), tokenCacheFile]; | ||
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. I have an issue with how this function is used and O_CREAT flag. It seems to me that it will create cache file (empty one) during read-only operations (retrieving key), and creating file lock 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. Good point, changed |
||
}; | ||
|
||
this.readJsonCredentialFile = async function () { | ||
this.readJsonCredentialFile = async function (fileHandle) { | ||
try { | ||
const cred = await fs.readFile(await this.getTokenDir(), 'utf8'); | ||
const cred = await fileHandle.readFile('utf8'); | ||
return JSON.parse(cred); | ||
} catch (err) { | ||
Logger.getInstance().warn('Failed to read token data from the file. Err: %s', err.message); | ||
return null; | ||
} | ||
}; | ||
|
||
this.removeStale = async function (file) { | ||
const stat = await fs.stat(file).catch(() => { | ||
return undefined; | ||
}); | ||
if (!Util.exists(stat)) { | ||
return; | ||
} | ||
if (new Date().getTime() - stat.birthtimeMs > timeoutMs) { | ||
try { | ||
await fs.rmdir(file); | ||
} catch (err) { | ||
Logger.getInstance().warn('Failed to remove stale file. Error: %s', err.message); | ||
} | ||
} | ||
|
||
}; | ||
|
||
|
||
this.withFileLocked = async function (fun) { | ||
sfc-gh-pmotacki marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const [fileHandle, file] = await this.getTokenFile(); | ||
const lckFile = file + '.lck'; | ||
await this.removeStale(lckFile); | ||
let attempts = 1; | ||
let locked = false; | ||
const options = {}; | ||
if (process.platform !== 'win32') { | ||
options.mode = 0o600; | ||
} | ||
while (attempts <= 10) { | ||
Logger.getInstance().debug('Attempting to get a lock on file %s, attempt: %d', file, attempts); | ||
attempts++; | ||
await fs.mkdir(lckFile, options).then(() => { | ||
sfc-gh-pmotacki marked this conversation as resolved.
Show resolved
Hide resolved
|
||
locked = true; | ||
}, () => {}); | ||
if (locked) { | ||
break; | ||
} | ||
await new Promise(resolve => setTimeout(resolve, 100)); | ||
sfc-gh-astachowski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
if (!locked) { | ||
if (Util.exists(fileHandle)) { | ||
await fileHandle.close(); | ||
} | ||
Logger.getInstance().warn('Could not acquire lock on cache file %s', file); | ||
return null; | ||
} | ||
const res = await fun(fileHandle, file); | ||
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. Nit: Can you more the file locking functionality to a seperate object/functions? I would like this function to look similar to this:
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. Changed it a bit, is this better? |
||
if (Util.exists(fileHandle)) { | ||
await fileHandle.close(); | ||
} | ||
await fs.rmdir(lckFile); | ||
return res; | ||
}; | ||
|
||
this.write = async function (key, token) { | ||
sfc-gh-jszczerbinski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if (!validateTokenCacheOption(key)) { | ||
return null; | ||
} | ||
|
||
const jsonCredential = await this.readJsonCredentialFile() || {}; | ||
jsonCredential[key] = token; | ||
|
||
try { | ||
await fs.writeFile(await this.getTokenDir(), JSON.stringify(jsonCredential), { mode: 0o600 }); | ||
} catch (err) { | ||
throw new Error(`Failed to write token data. Please check the permission or the file format of the token. ${err.message}`); | ||
} | ||
const keyHash = this.hashKey(key); | ||
|
||
await this.withFileLocked(async (fileHandle, filename) => { | ||
const jsonCredential = await this.readJsonCredentialFile(fileHandle) || {}; | ||
if (!Util.exists(jsonCredential[topLevelKey])) { | ||
jsonCredential[topLevelKey] = {}; | ||
} | ||
jsonCredential[topLevelKey][keyHash] = token; | ||
|
||
try { | ||
const flag = Util.exists(fileHandle) ? fs.constants.O_RDWR | fs.constants.O_CREAT : fs.constants.O_WRONLY; | ||
const writeFileHandle = await getSecureHandle(filename, flag, fs); | ||
await writeFileHandle.writeFile(JSON.stringify(jsonCredential), { mode: 0o600 }); | ||
await writeFileHandle.close(); | ||
} catch (err) { | ||
Logger.getInstance().warn(`Failed to write token data in ${filename}. Please check the permission or the file format of the token. ${err.message}`); | ||
} | ||
}); | ||
}; | ||
|
||
this.read = async function (key) { | ||
if (!validateTokenCacheOption(key)) { | ||
return null; | ||
} | ||
|
||
const jsonCredential = await this.readJsonCredentialFile(); | ||
if (!!jsonCredential && jsonCredential[key]){ | ||
return jsonCredential[key]; | ||
} else { | ||
return null; | ||
} | ||
const keyHash = this.hashKey(key); | ||
|
||
return await this.withFileLocked(async (fileHandle) => { | ||
const jsonCredential = await this.readJsonCredentialFile(fileHandle); | ||
if (!!jsonCredential && jsonCredential[topLevelKey] && jsonCredential[topLevelKey][keyHash]) { | ||
return jsonCredential[topLevelKey][keyHash]; | ||
} else { | ||
return null; | ||
} | ||
}); | ||
}; | ||
|
||
this.remove = async function (key) { | ||
if (!validateTokenCacheOption(key)) { | ||
return null; | ||
} | ||
const jsonCredential = await this.readJsonCredentialFile(); | ||
|
||
if (jsonCredential && jsonCredential[key]) { | ||
try { | ||
jsonCredential[key] = null; | ||
await fs.writeFile(await this.getTokenDir(), JSON.stringify(jsonCredential), { mode: 0o600 }); | ||
} catch (err) { | ||
throw new Error(`Failed to write token data from the file in ${await this.getTokenDir()}. Please check the permission or the file format of the token. ${err.message}`); | ||
} | ||
} | ||
|
||
const keyHash = this.hashKey(key); | ||
|
||
await this.withFileLocked(async (fileHandle, filename) => { | ||
const jsonCredential = await this.readJsonCredentialFile(fileHandle); | ||
|
||
if (jsonCredential && jsonCredential[topLevelKey] && jsonCredential[topLevelKey][keyHash]) { | ||
try { | ||
jsonCredential[topLevelKey][keyHash] = null; | ||
const flag = Util.exists(fileHandle) ? fs.constants.O_RDWR | fs.constants.O_CREAT : fs.constants.O_WRONLY; | ||
const writeFileHandle = await getSecureHandle(filename, flag, fs); | ||
await writeFileHandle.writeFile(JSON.stringify(jsonCredential), { mode: 0o600 }); | ||
await writeFileHandle.close(); | ||
} catch (err) { | ||
Logger.getInstance().warn(`Failed to remove token data from the file in ${filename}. Please check the permission or the file format of the token. ${err.message}`); | ||
} | ||
} | ||
}); | ||
}; | ||
|
||
function validateTokenCacheOption(key) { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -164,7 +164,9 @@ exports.validateOnlyUserReadWritePermissionAndOwner = async function (filePath, | |
if (octalPermissions === '600') { | ||
Logger.getInstance().debug(`Validated that the user has only read and write permission for file: ${filePath}, Permission: ${permission}`); | ||
} else { | ||
throw new Error(`Invalid file permissions (${octalPermissions} for file ${filePath}). Make sure you have read and write permissions and other users do not have access to it. Please remove the file and re-run the driver.`); | ||
await fsp.chmod(filePath, 0o600).catch(() => { | ||
sfc-gh-pmotacki marked this conversation as resolved.
Show resolved
Hide resolved
|
||
throw new Error(`Invalid file permissions (${octalPermissions} for file ${filePath}). Make sure you have read and write permissions and other users do not have access to it. Please remove the file and re-run the driver.`); | ||
}); | ||
} | ||
|
||
const userInfo = os.userInfo(); | ||
|
@@ -183,6 +185,51 @@ exports.validateOnlyUserReadWritePermissionAndOwner = async function (filePath, | |
} | ||
}; | ||
|
||
/** | ||
* Checks if the provided file is writable only by the user and os tha file owner is the same as os user. FsPromises can be provided. | ||
* @param filePath | ||
* @param expectedMode | ||
* @param fsPromises | ||
* @returns {Promise<FileHandle>} | ||
*/ | ||
exports.getSecureHandle = async function (filePath, flags, fsPromises) { | ||
const fsp = fsPromises ? fsPromises : require('fs/promises'); | ||
try { | ||
const fileHandle = await fsp.open(filePath, flags | fsp.constants.O_NOFOLLOW, 0o600); | ||
if (os.platform() === 'win32') { | ||
return fileHandle; | ||
} | ||
const stats = await fileHandle.stat(); | ||
const mode = stats.mode; | ||
const permission = mode & 0o777; | ||
|
||
//This should be 600 permission, which means the file permission has not been changed by others. | ||
const octalPermissions = permission.toString(8); | ||
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. Isn't it duplication of validateOnlyUserReadWritePermissionAndOwner method? 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. It is very similar, but operates on file handles in order to mitigate any manipulations between our checks and operations. 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. The issue you attached doesn't address the migration of control for configuration files. Do we need to create a new issue? 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. Why convert it to string? just compare with octal value? 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. This was copied from an analogous function, fixed |
||
if (octalPermissions === '600') { | ||
Logger.getInstance().debug(`Validated that the user has only read and write permission for file: ${filePath}, Permission: ${permission}`); | ||
} else { | ||
await fileHandle.chmod(0o600).catch(() => { | ||
throw new Error(`Invalid file permissions (${octalPermissions} for file ${filePath}). Make sure you have read and write permissions and other users do not have access to it. Please remove the file and re-run the driver.`); | ||
}); | ||
} | ||
|
||
const userInfo = os.userInfo(); | ||
if (stats.uid === userInfo.uid) { | ||
Logger.getInstance().debug('Validated file owner'); | ||
} else { | ||
throw new Error(`Invalid file owner for file ${filePath}). Make sure the system user are the owner of the file otherwise please remove the file and re-run the driver.`); | ||
} | ||
return fileHandle; | ||
} catch (err) { | ||
//When file doesn't exist - return | ||
if (err.code === 'ENOENT') { | ||
return null; | ||
} else { | ||
throw err; | ||
} | ||
} | ||
}; | ||
|
||
/** | ||
* Checks if the provided file or directory permissions are correct. | ||
* @param filePath | ||
|
Uh oh!
There was an error while loading. Please reload this page.