mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-05 19:20:12 +08:00
feat: sanitize file checks
This commit is contained in:
1 parent
50b1fe5124
commit
8f22babe7d
7 files changed
+115
-63
No files matched your search
@@ -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(' && ')
|
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -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
|
||||||
@@ -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
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"watch": ["src", "swagger.yaml", "../quests"],
|
"watch": ["src", "swagger.yaml", "../quests", "../packages"],
|
||||||
"ext": "js,yaml,yml"
|
"ext": "js,yaml,yml"
|
||||||
}
|
}
|
||||||
@@ -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'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in new issue
Block a user