diff --git a/.travis.yml b/.travis.yml index a3f46ba..cc91d78 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,4 +5,4 @@ install: - export PATH="$HOME/.deno/bin:$PATH" script: - - deno test --allow-read='.' test/* + - deno test --allow-read test/* diff --git a/dependencies.ts b/dependencies.ts index 566ea5d..b38d516 100644 --- a/dependencies.ts +++ b/dependencies.ts @@ -6,6 +6,7 @@ import * as cookie from 'https://deno.land/std@v0.56.0/http/cookie.ts'; import * as http from 'https://deno.land/std@v0.56.0/http/server.ts'; import { Status as status, STATUS_TEXT as statusText } from 'https://deno.land/std@v0.56.0/http/http_status.ts'; import * as mime from 'https://cdn.pika.dev/mime-types@2.1.27'; +import * as path from 'https://deno.land/std@v0.56.0/path/mod.ts'; export { React, @@ -13,6 +14,7 @@ export { cookie, http, mime, + path, status, statusText }; diff --git a/lib/toolkit.ts b/lib/toolkit.ts index 17c9bd4..f94ae59 100644 --- a/lib/toolkit.ts +++ b/lib/toolkit.ts @@ -1,9 +1,21 @@ import Response from './response.ts'; +import * as bang from './bang.ts'; import { ResponseBody } from './types.ts'; +import isPathInside from './util/is-path-inside.ts'; import { mime } from '../dependencies.ts'; +interface FileHandlerOptions { + confine: boolean | string +} + export default class Toolkit { - async file(path: string): Promise { + async file(path: string, options?: FileHandlerOptions): Promise { + if (options?.confine !== false) { + const confine = typeof options?.confine === 'string' ? options.confine : Deno.cwd(); + if (!(await isPathInside.fs(path, confine))) { + throw bang.forbidden(); + } + } const file = await Deno.readFile(path); const mediaType = mime.lookup(path); const contentType = mime.contentType(mediaType || ''); diff --git a/lib/util/is-path-inside.ts b/lib/util/is-path-inside.ts new file mode 100644 index 0000000..7174381 --- /dev/null +++ b/lib/util/is-path-inside.ts @@ -0,0 +1,33 @@ +import { path } from '../../dependencies.ts'; + +const realPathSilent = async (filePath: string): Promise => { + try { + return await Deno.realPath(filePath); + } + catch (error) { + if (error instanceof Deno.errors.NotFound) { + return filePath; + } + throw error; + } +}; + +const isPathInside = (childPath: string, parentPath: string): boolean => { + const relation = path.relative(parentPath, childPath); + return Boolean( + relation && + relation !== '..' && + !relation.startsWith('..' + path.SEP) && + relation !== path.resolve(childPath) + ); +}; + +isPathInside.fs = async (childPath: string, parentPath: string): Promise => { + const [realChildPath, realParentPath] = await Promise.all([ + realPathSilent(childPath), + realPathSilent(parentPath) + ]); + return isPathInside(realChildPath, realParentPath); +}; + +export default isPathInside; diff --git a/test/toolkit.js b/test/toolkit.js index 0a7e1d0..04efc41 100644 --- a/test/toolkit.js +++ b/test/toolkit.js @@ -102,3 +102,25 @@ test('h.file()', async () => { assertStrictEq(response.headers.get('content-type'), 'application/json; charset=utf-8'); assertEquals(response.body, new TextEncoder().encode('[\n "Alice",\n "Bob",\n "Cara"\n]\n')); }); + +test('h.file() outside default confine', async () => { + const server = pogo.server(); + server.route({ + method : 'GET', + path : '/forbid', + handler(request, h) { + return h.file('/etc/hosts'); + } + }); + const response = await server.inject({ + method : 'GET', + url : '/forbid' + }); + assertStrictEq(response.status, 403); + assertStrictEq(response.headers.get('content-type'), 'application/json; charset=utf-8'); + assertEquals(response.body, JSON.stringify({ + error : 'Forbidden', + message : 'Forbidden', + status : 403 + })); +});