Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion app.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,16 @@ app.use(i18n.init)
// static files
app.use('/', express.static(path.join(__dirname, '/public'), { maxAge: config.staticCacheTime, index: false }))
app.use('/docs', express.static(path.resolve(__dirname, config.docsPath), { maxAge: config.staticCacheTime }))
app.use('/uploads', express.static(path.resolve(__dirname, config.uploadsPath), { maxAge: config.staticCacheTime }))
app.use('/uploads', express.static(path.resolve(__dirname, config.uploadsPath), {
maxAge: config.staticCacheTime,
setHeaders: function (res, filePath) {
res.setHeader('X-Content-Type-Options', 'nosniff')
if (path.extname(filePath).toLowerCase() === '.svg') {
// defense in depth: an SVG opened directly must not run scripts or load resources
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox")
}
}
}))
app.use('/default.md', express.static(path.resolve(__dirname, config.defaultNotePath), { maxAge: config.staticCacheTime }))
app.use(require('./lib/metrics').router)

Expand Down
28 changes: 27 additions & 1 deletion lib/imageRouter/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,41 @@ const mime = require('mime-types')
const config = require('../config')
const logger = require('../logger')
const response = require('../response')
const sanitizeSvg = require('./sanitizeSvg')

const imageRouter = module.exports = Router()

const SVG_MAX_BYTES = 2 * 1024 * 1024

/**
* SVG is plain XML without a magic number, so image-type can never detect it.
* Sanitize it instead and replace the temporary upload with the clean version.
*/
function sanitizeSvgFile (filepath) {
if (fs.statSync(filepath).size > SVG_MAX_BYTES) return false

const sanitized = sanitizeSvg(fs.readFileSync(filepath, 'utf8'))
if (!sanitized) return false

fs.writeFileSync(filepath, sanitized)
return true
}

/**
* Check whether an uploaded file is an allowed image.
* Uploaded SVG files are sanitized in place.
*/
function checkImageValid (filepath) {
try {
const mimeTypeFromExt = mime.lookup(path.extname(filepath))

if (mimeTypeFromExt === 'image/svg+xml') {
return config.allowedUploadMimeTypes.includes(mimeTypeFromExt) && sanitizeSvgFile(filepath)
}

const buffer = readChunk.sync(filepath, 0, 12)
/** @type {{ ext: string, mime: string } | null} */
const mimetypeFromBuf = imageType(buffer)
const mimeTypeFromExt = mime.lookup(path.extname(filepath))

return mimetypeFromBuf && config.allowedUploadMimeTypes.includes(mimetypeFromBuf.mime) &&
mimeTypeFromExt && config.allowedUploadMimeTypes.includes(mimeTypeFromExt)
Expand Down
40 changes: 40 additions & 0 deletions lib/imageRouter/sanitizeSvg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
'use strict'

const createDOMPurify = require('dompurify')
const { JSDOM } = require('jsdom')

const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'

const { window } = new JSDOM('')
const DOMPurify = createDOMPurify(window)
const serializer = new window.XMLSerializer()

/**
* Sanitize an uploaded SVG image so it can be served from the CodiMD origin.
*
* DOMPurify (SVG profile) strips scripts, event handlers, javascript: URLs,
* <foreignObject> and everything else that is not plain SVG. The input is
* parsed as HTML, so DTDs and XML entities are never expanded. The result is
* re-serialized as standalone XML, which also adds the SVG namespace that
* browsers require to render the file through <img>.
*
* @param {string} input raw SVG source
* @returns {string|null} sanitized SVG document, or null if the input does not
* consist of a single <svg> root element
*/
function sanitizeSvg (input) {
const fragment = DOMPurify.sanitize(String(input), {
USE_PROFILES: { svg: true, svgFilters: true },
RETURN_DOM_FRAGMENT: true
})

const roots = fragment.children
if (roots.length !== 1) return null

const root = roots[0]
if (root.namespaceURI !== SVG_NAMESPACE || root.localName !== 'svg') return null

return '<?xml version="1.0" encoding="UTF-8"?>\n' + serializer.serializeToString(root) + '\n'
}

module.exports = sanitizeSvg
Loading