import fs from 'fs'; import path from 'path'; import { validateName as utilValidateName, validatePackage as utilValidatePackage, getVersionFromTarball, isObject, ErrorCode } from '../lib/utils'; import { API_ERROR, HEADER_TYPE, HEADERS, HTTP_STATUS, TOKEN_BASIC, TOKEN_BEARER } from '../lib/constants'; import { stringToMD5 } from '../lib/crypto-utils'; import { $ResponseExtend, $RequestExtend, $NextFunctionVer, IAuth } from '../../types'; import { logger } from '../lib/logger'; import _ from 'lodash'; import buildDebug from 'debug'; import validator from 'validator'; import { Config, Package, RemoteUser } from '@verdaccio/types'; import { VerdaccioError } from '@verdaccio/commons-api'; const debug = buildDebug('verdaccio'); export function match(regexp: RegExp): any { return function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer, value: string): void { if (regexp.exec(value)) { next(); } else { next('route'); } }; } export function serveFavicon(config: Config) { return function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer) { try { // @ts-ignore const logoConf: string = config?.web?.favicon as string; if (logoConf === '') { debug('favicon disabled'); res.status(404); } else if (!_.isEmpty(logoConf)) { debug('custom favicon'); if ( validator.isURL(logoConf, { require_host: true, require_valid_protocol: true, }) ) { debug('redirect to %o', logoConf); res.redirect(logoConf); return; } else { const faviconPath = path.normalize(logoConf); debug('serving favicon from %o', faviconPath); fs.access(faviconPath, fs.constants.R_OK, (err) => { if (err) { debug('no read permissions to read: %o, reason:', logoConf, err?.message); return res.status(HTTP_STATUS.NOT_FOUND).end(); } else { res.setHeader('content-type', 'image/x-icon'); fs.createReadStream(faviconPath).pipe(res); debug('rendered custom ico'); } }); } } else { res.setHeader('content-type', 'image/x-icon'); fs.createReadStream(path.posix.join(__dirname, './web/html/favicon.ico')).pipe(res); debug('rendered ico'); } } catch (err) { debug('error triggered, favicon not found'); res.status(HTTP_STATUS.NOT_FOUND).end(); } }; } export function setSecurityWebHeaders(req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { // disable loading in frames (clickjacking, etc.) res.header(HEADERS.FRAMES_OPTIONS, 'deny'); // avoid stablish connections outside of domain res.header(HEADERS.CSP, "connect-src 'self'"); // https://stackoverflow.com/questions/18337630/what-is-x-content-type-options-nosniff res.header(HEADERS.CTO, 'nosniff'); // https://stackoverflow.com/questions/9090577/what-is-the-http-header-x-xss-protection res.header(HEADERS.XSS, '1; mode=block'); next(); } // flow: express does not match properly // flow info https://github.com/flowtype/flow-typed/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+express export function validateName(req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer, value: string, name: string): void { if (value === '-') { // special case in couchdb usually next('route'); } else if (utilValidateName(value)) { next(); } else { next(ErrorCode.getForbidden('invalid ' + name)); } } // flow: express does not match properly // flow info https://github.com/flowtype/flow-typed/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+express export function validatePackage(req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer, value: string, name: string): void { if (value === '-') { // special case in couchdb usually next('route'); } else if (utilValidatePackage(value)) { next(); } else { next(ErrorCode.getForbidden('invalid ' + name)); } } export function media(expect: string | null): any { return function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { if (req.headers[HEADER_TYPE.CONTENT_TYPE] !== expect) { next(ErrorCode.getCode(HTTP_STATUS.UNSUPPORTED_MEDIA, 'wrong content-type, expect: ' + expect + ', got: ' + req.get(HEADER_TYPE.CONTENT_TYPE))); } else { next(); } }; } export function encodeScopePackage(req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { if (req.url.indexOf('@') !== -1) { // e.g.: /@org/pkg/1.2.3 -> /@org%2Fpkg/1.2.3, /@org%2Fpkg/1.2.3 -> /@org%2Fpkg/1.2.3 req.url = req.url.replace(/^(\/@[^\/%]+)\/(?!$)/, '$1%2F'); } next(); } export function expectJson(req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { if (!isObject(req.body)) { return next(ErrorCode.getBadRequest("can't parse incoming json")); } next(); } export function antiLoop(config: Config): Function { return function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { if (req?.headers?.via != null) { const arr = req.headers.via.split(','); for (let i = 0; i < arr.length; i++) { const m = arr[i].match(/\s*(\S+)\s+(\S+)/); if (m && m[2] === config.server_id) { return next(ErrorCode.getCode(HTTP_STATUS.LOOP_DETECTED, 'loop detected')); } } } next(); }; } export function allow(auth: IAuth): Function { return function (action: string): Function { return function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { req.pause(); const packageName = req.params.scope ? `@${req.params.scope}/${req.params.package}` : req.params.package; let packageVersion: string | undefined = undefined; if (req.params.filename) { packageVersion = getVersionFromTarball(req.params.filename) || undefined; } else if (typeof req.body.versions === 'object') { packageVersion = Object.keys(req.body.versions)[0]; } const remote: RemoteUser = req.remote_user; debug('[middleware/allow][%o] allow for %o', action, remote?.name); auth['allow_' + action]({ packageName, packageVersion }, remote, function (error, allowed): void { req.resume(); if (error) { next(error); } else if (allowed) { next(); } else { // last plugin (that's our built-in one) returns either // cb(err) or cb(null, true), so this should never happen throw ErrorCode.getInternalError(API_ERROR.PLUGIN_ERROR); } }); }; }; } export interface MiddlewareError { error: string; } export type FinalBody = Package | MiddlewareError | string; export function final(body: FinalBody, req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { if (res.statusCode === HTTP_STATUS.UNAUTHORIZED && !res.getHeader(HEADERS.WWW_AUTH)) { // they say it's required for 401, so... res.header(HEADERS.WWW_AUTH, `${TOKEN_BASIC}, ${TOKEN_BEARER}`); } try { if (_.isString(body) || _.isObject(body)) { if (!res.getHeader(HEADERS.CONTENT_TYPE)) { res.header(HEADERS.CONTENT_TYPE, HEADERS.JSON); } if (typeof body === 'object' && _.isNil(body) === false) { if (typeof (body as MiddlewareError).error === 'string') { res.locals._verdaccio_error = (body as MiddlewareError).error; } body = JSON.stringify(body, undefined, ' ') + '\n'; } // don't send etags with errors if (!res.statusCode || (res.statusCode >= HTTP_STATUS.OK && res.statusCode < HTTP_STATUS.MULTIPLE_CHOICES)) { res.header(HEADERS.ETAG, '"' + stringToMD5(body as string) + '"'); } } else { // send(null), send(204), etc. } } catch (err) { // if verdaccio sends headers first, and then calls res.send() // as an error handler, we can't report error properly, // and should just close socket if (err.message.match(/set headers after they are sent/)) { if (_.isNil(res.socket) === false) { // @ts-ignore res.socket.destroy(); } return; } throw err; } res.send(body); } export const LOG_STATUS_MESSAGE = "@{status}, user: @{user}(@{remoteIP}), req: '@{request.method} @{request.url}'"; export const LOG_VERDACCIO_ERROR = `${LOG_STATUS_MESSAGE}, error: @{!error}`; export const LOG_VERDACCIO_BYTES = `${LOG_STATUS_MESSAGE}, bytes: @{bytes.in}/@{bytes.out}`; export function log(config: Config) { return function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { const _auth = req.headers.authorization; if (_.isNil(_auth) === false) { req.headers.authorization = ''; } const _cookie = req.get('cookie'); if (_.isNil(_cookie) === false) { req.headers.cookie = ''; } req.url = req.originalUrl; // avoid log noise data from static content if (req.originalUrl.match(/static/) === null) { logger.http({ req: req, ip: req.ip }, "@{ip} requested '@{req.method} @{req.url}'"); } req.originalUrl = req.url; if (_.isNil(_auth) === false) { req.headers.authorization = _auth; } if (_.isNil(_cookie) === false) { req.headers.cookie = _cookie; } let bytesin = 0; if (config?.experiments?.bytesin_off !== true) { req.on('data', function (chunk): void { bytesin += chunk.length; }); } let bytesout = 0; const _write = res.write; // FIXME: res.write should return boolean // @ts-ignore res.write = function (buf): boolean { bytesout += buf.length; /* eslint prefer-rest-params: "off" */ // @ts-ignore _write.apply(res, arguments); }; let logHasBeenCalled = false; const log = function (): void { if (logHasBeenCalled) { return; } logHasBeenCalled = true; const forwardedFor = req.get('x-forwarded-for'); const remoteAddress = req.connection.remoteAddress; const remoteIP = forwardedFor ? `${forwardedFor} via ${remoteAddress}` : remoteAddress; let message; if (res.locals._verdaccio_error) { message = LOG_VERDACCIO_ERROR; } else { message = LOG_VERDACCIO_BYTES; } req.url = req.originalUrl; // avoid log noise data from static content if (req.url.match(/static/) === null) { logger.http( { request: { method: req.method, url: req.url, }, user: (req.remote_user && req.remote_user.name) || null, remoteIP, status: res.statusCode, error: res.locals._verdaccio_error, bytes: { in: bytesin, out: bytesout, }, }, message ); req.originalUrl = req.url; } }; req.on('close', function (): void { log(); }); const _end = res.end; res.end = function (buf): void { if (buf) { bytesout += buf.length; } /* eslint prefer-rest-params: "off" */ // @ts-ignore _end.apply(res, arguments); log(); }; next(); }; } // Middleware export function errorReportingMiddleware(req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { res.locals.report_error = res.locals.report_error || function (err: VerdaccioError): void { if (err.status && err.status >= HTTP_STATUS.BAD_REQUEST && err.status < 600) { if (!res.headersSent) { res.status(err.status); next({ error: err.message || API_ERROR.UNKNOWN_ERROR }); } } else { logger.error({ err: err }, 'unexpected error: @{!err.message}\n@{err.stack}'); if (!res.status || !res.send) { logger.error('this is an error in express.js, please report this'); res.destroy(); } else if (!res.headersSent) { res.status(HTTP_STATUS.INTERNAL_ERROR); next({ error: API_ERROR.INTERNAL_SERVER_ERROR }); } else { // socket should be already closed } } }; next(); }