feat: sanitize file checks

This commit is contained in:
wancat committed 2023-10-29 17:41:38 +08:00
1 parent 50b1fe5124
commit 8f22babe7d
7 files changed
+115 -63

No files matched your search

+7 -7
View File
@@ -5,12 +5,12 @@ const fileTypes = {
socket: '-S', socket: '-S',
} }
function sanitizePath(path) {
return path.replace(/[^a-zA-Z0-9._/-]/g, '')
}
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
export function buildFileCheckCmd(files) { export function buildFileCheckCmd(file) {
return files const { path, type } = file
.map( return ['test', fileTypes[type] || '-e', sanitizePath(path)]
({ path, type, exists }) =>
`${exists ? '' : '! '}test ${fileTypes[type] || '-e'} ${path}`
)
.join(' && ')
} }
+20 -17
View File
@@ -4,35 +4,38 @@ describe('buildFileCheckCmd function', () => {
it('builds the file check command correctly', () => { it('builds the file check command correctly', () => {
const testcases = [ const testcases = [
{ {
input: [{ path: '/etc/passwd', type: 'file', exists: true }], input: { path: '/etc/passwd', type: 'file' },
output: 'test -f /etc/passwd', output: ['test', '-f', '/etc/passwd'],
}, },
{ {
input: [{ path: '/etc', type: 'folder', exists: false }], input: { path: '/etc', type: 'folder' },
output: '! test -d /etc', output: ['test', '-d', '/etc'],
}, },
{ {
input: [ input: { path: '/tmp/socket', type: 'socket' },
{ path: '/etc/passwd', type: 'file', exists: true }, output: ['test', '-S', '/tmp/socket'],
{ path: '/etc', type: 'folder', exists: false },
],
output: 'test -f /etc/passwd && ! test -d /etc',
}, },
{ {
input: [ input: { path: '/tmp/link', type: 'link' },
{ path: '/tmp/socket', type: 'socket', exists: true }, output: ['test', '-L', '/tmp/link'],
{ path: '/tmp/link', type: 'link', exists: false },
],
output: 'test -S /tmp/socket && ! test -L /tmp/link',
}, },
{ {
input: [{ path: '/unknown', type: 'unknown', exists: true }], input: { path: '/unknown', type: 'unknown' },
output: 'test -e /unknown', output: ['test', '-e', '/unknown'],
},
// Add some injection tests
{
input: { path: '/etc/passwd;ls', type: 'file' },
output: ['test', '-f', '/etc/passwdls'],
},
{
input: { path: '/etc/passwd && ls', type: 'file' },
output: ['test', '-f', '/etc/passwdls'],
}, },
] ]
testcases.forEach(({ input, output }) => { testcases.forEach(({ input, output }) => {
expect(buildFileCheckCmd(input)).toBe(output) expect(buildFileCheckCmd(input)).toStrictEqual(output)
}) })
}) })
}) })
+15
View File
@@ -149,3 +149,18 @@ stages:
- type: narrative - type: narrative
content: content:
- There is a jail - There is a jail
- id: test-file-not-exist
task: Remove the forgotten_scroll.txt
repeatable: true
condition:
command:
- ls -a
$not:
files:
- path: /home/commander/forgotten_scroll.txt
type: file
responses:
- type: narrative
content:
- There is no forgotten_scroll.txt
+1
View File
@@ -30,5 +30,6 @@ CMD [ "yarn", "start" ]
FROM build as dev FROM build as dev
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
COPY server/nodemon.json ./server/nodemon.json
ENV NODE_ENV=development ENV NODE_ENV=development
CMD [ "yarn", "server" ] CMD [ "yarn", "server" ]
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"watch": ["src", "swagger.yaml", "../quests"], "watch": ["src", "swagger.yaml", "../quests", "../packages"],
"ext": "js,yaml,yml" "ext": "js,yaml,yml"
} }
+26 -10
View File
@@ -1,16 +1,32 @@
import { exec } from 'child_process' import { execFile } from 'child_process'
import { buildFileCheckCmd } from '@linux-odyssey/utils' import { buildFileCheckCmd } from '@linux-odyssey/utils'
function sanitizeId(id) {
return id.replace(/[^a-zA-Z0-9]/g, '')
}
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
export function checkFiles(id, files) { export function checkFile(id, file) {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
const cmd = buildFileCheckCmd(files) const cmd = buildFileCheckCmd(file)
// WARNING: This is not secure! console.log(cmd)
exec(`docker exec ${id} ${cmd}`, (error, stdout, stderr) => { // WARNING: This is vulnerable to command injection
console.log('stdout', stdout) execFile(
console.log('stderr', stderr) 'docker',
resolve(!error) ['exec', sanitizeId(id), ...cmd],
}) { timeout: 1000 },
(error, stdout, stderr) => {
if (stderr) console.log('error', error)
if (stdout) console.log('stdout', stdout)
if (error) console.log('error', error)
const exists = !error
if (exists === file.exists) {
resolve(true)
} else {
reject(new Error('File check failed'))
}
}
)
}) })
} }
+45 -28
View File
@@ -3,7 +3,7 @@ import { FileGraph } from '@linux-odyssey/file-graph'
import { Quest } from '@linux-odyssey/models' import { Quest } from '@linux-odyssey/models'
import { pushToSession } from '../api/socket.js' import { pushToSession } from '../api/socket.js'
import SessionHandler from './sessionHandler.js' import SessionHandler from './sessionHandler.js'
import { checkFiles } from '../containers/cli.js' import { checkFile } from '../containers/cli.js'
// 檢查 pattern 是否符合 input // 檢查 pattern 是否符合 input
const checkMatch = (pattern, input) => { const checkMatch = (pattern, input) => {
@@ -55,30 +55,47 @@ export default class CommandHandler extends SessionHandler {
} }
async isMatch(condition) { async isMatch(condition) {
const keys = ['command', 'output', 'error', 'pwd'] return (
if (keys.some((k) => !checkMatch(condition[k], this.commandInput[k]))) { this.checkKeys(condition) &&
return false (await this.checkFiles(condition.files)) &&
} (await this.checkNot(condition.$not)) &&
if (condition.files.length > 0) { (await this.checkOr(condition.$or)) === true
const match = await this.checkFiles(condition.files) )
if (!match) return false
}
if (condition.$not) {
const match = await this.isMatch(condition.$not)
if (match) return false
}
if (condition.$or.length > 0) {
const matches = await Promise.all(
condition.$or.map((c) => this.isMatch(c))
)
if (matches.every((m) => m === false)) return false
}
return true
} }
checkFiles(files) { checkKeys(condition) {
const keys = ['command', 'output', 'error', 'pwd']
return keys.every((k) => checkMatch(condition[k], this.commandInput[k]))
}
async checkFiles(files) {
if (files.length === 0) {
return true
}
console.log('checkFiles', files) console.log('checkFiles', files)
return checkFiles(this.session.containerId, files) try {
const checks = await Promise.all(
files.map((f) => checkFile(this.session.containerId, f))
)
return checks.every((c) => c === true)
} catch (e) {
return false
}
}
async checkNot(condition) {
if (!condition) {
return true
}
return !(await this.isMatch(condition))
}
async checkOr(conditions) {
if (conditions.length === 0) {
return true
}
const matches = await Promise.all(conditions.map((c) => this.isMatch(c)))
return matches.some((m) => m === true)
} }
async run() { async run() {
@@ -98,12 +115,12 @@ export default class CommandHandler extends SessionHandler {
console.log('matches', matches) console.log('matches', matches)
const filteredStages = stages.filter((_, i) => matches[i]) const stage = stages.find((_, i) => matches[i])
console.log('filteredStages', filteredStages) console.log('stage', stage)
const response = filteredStages.reduce( if (!stage) {
(r, s) => ({ ...r, ...this.execute(s) }), return {}
{} }
) const response = this.execute(stage)
return { return {
end: this.session.status === 'finished', end: this.session.status === 'finished',