Skip to content

Commit

Permalink
feat(file): 重构LSF 文件管理方式;新增文件访问令牌云函数;修复文件API安全漏洞;
Browse files Browse the repository at this point in the history
  • Loading branch information
maslow committed Jun 24, 2021
1 parent 9834018 commit 164ab59
Show file tree
Hide file tree
Showing 7 changed files with 139 additions and 65 deletions.
29 changes: 6 additions & 23 deletions http/file.http
Original file line number Diff line number Diff line change
Expand Up @@ -10,45 +10,28 @@ POST {{base_url}}/admin/login HTTP/1.1
Content-Type: application/json

{
"username": "less-admin",
"password": "less-framework"
"username": "less",
"password": "less123"
}

### 文件上传 json

POST {{base_url}}/file/upload/public HTTP/1.1
Authorization: Bearer {{token}}
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="tsconfig.json"

< ./../tsconfig.json
------WebKitFormBoundary7MA4YWxkTrZu0gW--

### 文件上传 png

POST {{base_url}}/file/upload/public HTTP/1.1
POST {{base_url}}/file/upload/public?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJucyI6InB1YmxpYyIsIm9wIjoiY3JlYXRlIiwiZXhwIjoxNjI0NTIyOTIyLCJpYXQiOjE2MjQ1MTkzMjJ9.PPmnDlAOcpqvWmyzgWWrgC-KI7SnWQ2IsypLjSnWL6M HTTP/1.1
Authorization: Bearer {{token}}
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="upload-test.png"
Content-Type: image/png

< ./../upload-test.png
< ./upload.png
------WebKitFormBoundary7MA4YWxkTrZu0gW--


### public 文件访问

GET {{base_url}}/file/public/bbd2ecda-3dd0-4a09-a76d-705923ecc88e.png HTTP/1.1
GET {{base_url}}/file/public/03be77fd-a7b6-4233-b196-79101f2923c1.png HTTP/1.1

### 文件下载

GET {{base_url}}/file/download/public/bbd2ecda-3dd0-4a09-a76d-705923ecc88e.png HTTP/1.1

### 文件信息

GET {{base_url}}/file/info/public/bbd2ecda-3dd0-4a09-a76d-705923ecc88e.png HTTP/1.1

GET {{base_url}}/file/download/public/03be77fd-a7b6-4233-b196-79101f2923c1.png HTTP/1.1
Binary file added http/upload.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 29 additions & 0 deletions init/functions/file-token/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@

/**
* 本函数可发放文件访问令牌,用于下载或上传文件
* @TODO 你可以修改本函数,以实现符合业务的文件访问授权逻辑
*
* @body {string} namespace 文件存储的名字空间
* @body {string} filename 文件名
* @body {string} type 授权的操作,取值为: "read" | "create" | "all"
* @body {number} expire 令牌有效期,单位为秒,默认为一小时,即 3600
*/

exports.main = async function (ctx) {
const uid = ctx.auth?.uid
if (!uid) return 'error: unauthorized'

const ns = ctx.body?.namespace ?? undefined
const fn = ctx.body?.filename ?? undefined
const op = ctx.body?.type ?? 'read'
const expire = ctx.body?.expire ?? 3600

if (!ns) {
return 'error: invalid namespace'
}

const exp = Math.floor(Date.now()/1000) + expire
const payload = { ns, op, exp, fn }

return less.getToken(payload)
}
6 changes: 6 additions & 0 deletions init/functions/file-token/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"label": "文件访问令牌",
"name": "file-token",
"description": "文件访问令牌",
"enableHTTP": true
}
14 changes: 13 additions & 1 deletion src/lib/storage/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,19 @@ export interface FileStorageInterface {

/**
* 读取文件
* @param filename 文件名
* @param {string} filename 文件名
*/
readFile(filename: string, encoding?: string): Promise<Buffer>

/**
* 检查文件夹名是否安全
* @param {string} name
*/
checkSafeDirectoryName(name: string): boolean

/**
* 检查文件名是否安全
* @param {string} name
*/
checkSafeFilename(name: string): boolean
}
21 changes: 21 additions & 0 deletions src/lib/storage/local_file_storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,27 @@ export class LocalFileStorage implements FileStorageInterface {
}
}

/**
* 安全的文件、文件夹名只能由字母、数字、下划线、中划线和点组成
* @param name
* @returns
*/
checkSafeDirectoryName(name: string): boolean {
assert(typeof name === 'string', 'name must be a string')

const reg = /^([0-9]|[A-z]|_|-){3,64}$/
return reg.test(name)
}

/**
* 检查文件名是否安全
*/
checkSafeFilename(name: string): boolean {
assert(typeof name === 'string', 'name must be a string')
const reg = /^([0-9]|[A-z]|_|-|\.){3,64}$/
return reg.test(name)
}

/**
* 生成文件名
* @returns
Expand Down
105 changes: 64 additions & 41 deletions src/router/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as multer from 'multer'
import Config from '../../config'
import { LocalFileStorage } from '../../lib/storage/local_file_storage'
import { v4 as uuidv4 } from 'uuid'
import $ from 'validator'
import { parseToken } from '../../lib/utils/token'


export const FileRouter = express.Router()
Expand All @@ -26,28 +26,35 @@ FileRouter.use('/public', express.static(path.join(Config.LOCAL_STORAGE_ROOT_PAT
* @namespace {string} 上传的名字空间,做为二级目录使用,只支持一级,名字可以为数字或字母; 如 namespace=public,则文件路径为 /public/xxx.png
*/
FileRouter.post('/upload/:namespace', uploader.single('file'), async (req, res) => {
// 非登录用户不予上传
const auth = req['auth']
if (!auth) {
return res.status(401).send()

const namespace = req.params.namespace
if (!checkNamespace(namespace)) {
return res.status(422).send('invalid namespace')
}

// 验证上传 token
const uploadToken = req.query?.token
if(!uploadToken) {
return res.status(401).send('Unauthorized')
}

const prasedToken = parseToken(uploadToken as string)
if(!prasedToken){
return res.status(403).send('Invalid upload token')
}

if(!['create', 'all'].includes(prasedToken?.op)) {
return res.status(403).send('Permission denied')
}

if(prasedToken?.ns != namespace) {
return res.status(403).send('Permission denied')
}

// 文件不可为空
const file = req['file']
if (!file) {
return res.status(422).send({
code: 1,
error: 'file cannot be empty'
})
}

// namespace 只可为数字或字母组合,长底不得长于 32 位
const namespace = req.params.namespace
if (!$.isAlphanumeric(namespace) || !$.isLength(namespace, { max: 32, min: 1 })) {
return res.send({
code: 1,
error: 'invalid namespace'
})
return res.status(422).send('file cannot be empty')
}

// 存储上传文件
Expand All @@ -58,42 +65,58 @@ FileRouter.post('/upload/:namespace', uploader.single('file'), async (req, res)

// 不得暴露全路径给客户端
delete info.fullpath

return res.send({
code: 0,
data: info
})
})

FileRouter.get('/info/:namespace/:filename', async (req, res) => {
FileRouter.get('/download/:namespace/:filename', async (req, res) => {
const { namespace, filename } = req.params
if (!$.isAlphanumeric(namespace) || !$.isLength(namespace, { max: 32, min: 1 })) {
return res.send({
code: 1,
error: 'invalid namespace'
})
if (!checkNamespace(namespace)) {
return res.status(422).send('invalid namespace')
}

const localStorage = new LocalFileStorage(Config.LOCAL_STORAGE_ROOT_PATH, namespace)
if(!checkFilename(filename)) {
return res.status(422).send('invalid filename')
}

const info = await localStorage.getFileInfo(filename)
delete info.fullpath
return res.send({
code: 0,
data: info
})
})
// 验证访问 token
if(namespace !== 'public') {
const token = req.query?.token
if(!token) {
return res.status(401).send('Unauthorized')
}

FileRouter.get('/download/:namespace/:filename', async (req, res) => {
const { namespace, filename } = req.params
if (!$.isAlphanumeric(namespace) || !$.isLength(namespace, { max: 32, min: 1 })) {
return res.send({
code: 1,
error: 'invalid namespace'
})
}
const prasedToken = parseToken(token as string)
if(!prasedToken){
return res.status(403).send('Invalid token')
}

if(prasedToken?.ns != namespace) {
return res.status(403).send('Permission denied')
}

if(['read', 'all'].includes(prasedToken?.op)) {
return res.status(403).send('Permission denied')
}

if(prasedToken?.fn && prasedToken?.fn != filename) {
return res.status(403).send('Permission denied')
}
}

const localStorage = new LocalFileStorage(Config.LOCAL_STORAGE_ROOT_PATH, namespace)

const info = await localStorage.getFileInfo(filename)
return res.download(info.fullpath)
})
})

function checkNamespace(namespace: string) {
return (new LocalFileStorage('')).checkSafeDirectoryName(namespace)
}

function checkFilename(name: string) {
return (new LocalFileStorage('')).checkSafeFilename(name)
}

0 comments on commit 164ab59

Please sign in to comment.