| 1 | // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt |
| 2 | |
| 3 | import compress from 'koa-compress' |
| 4 | import Koa from 'koa' |
| 5 | import { API_URI, DEV } from './const' |
| 6 | import { ALLOW_SESSION_IP_CHANGE, DAY, hasDirTraversal, isLocalHost, netMatches, splitAt, stream2string, try_, tryJson } from './misc' |
| 7 | import { Readable } from 'stream' |
| 8 | import { applyBlock } from './block' |
| 9 | import { Account, accountCanLogin, accounts, getAccount, getFromAccount, normalizeUsername } from './perm' |
| 10 | import { Connection, normalizeIp, socket2connection, updateConnectionForCtx } from './connections' |
| 11 | import { clearTextLogin, invalidateSessionBefore, setLoggedIn } from './auth' |
| 12 | import { constants } from 'zlib' |
| 13 | import { getHttpsWorkingPort } from './listen' |
| 14 | import { defineConfig } from './config' |
| 15 | import session from 'koa-session' |
| 16 | import { app } from './index' |
| 17 | import events from './events' |
| 18 | |
| 19 | const forceHttps = defineConfig('force_https', true) |
| 20 | defineConfig('ignore_proxies', false) |
| 21 | const allowAuthorizationHeader = defineConfig('authorization_header', true) |
| 22 | export const sessionDuration = defineConfig('session_duration', Number(process.env.SESSION_DURATION) || DAY/1000, |
| 23 | v => v * 1000) |
| 24 | |
| 25 | export const gzipper = compress({ |
| 26 | threshold: 2048, |
| 27 | gzip: { flush: constants.Z_SYNC_FLUSH }, // flush is necessary for SSE, at least in Chrome145 |
| 28 | deflate: { flush: constants.Z_SYNC_FLUSH }, |
| 29 | zstd: { flush: constants.Z_SYNC_FLUSH }, |
| 30 | br: false, // disable brotli |
| 31 | filter(type) { |
| 32 | return /text|javascript|style/i.test(type) |
| 33 | }, |
| 34 | }) |
| 35 | |
| 36 | export const headRequests: Koa.Middleware = async (ctx, next) => { |
| 37 | const head = ctx.method === 'HEAD' |
| 38 | if (head) |
| 39 | ctx.method = 'GET' // let other middlewares work, so we can collect the size at the end |
| 40 | await next() |
| 41 | if (!head || ctx.body === undefined) return |
| 42 | const { length, status } = ctx.response |
| 43 | if (ctx.body) |
| 44 | ctx.body = Readable.from('') // empty the body for this is a HEAD request. Using Readable avoids koa from trying to set length to 0 |
| 45 | ctx.status = status |
| 46 | if (length) |
| 47 | ctx.response.length = length |
| 48 | } |
| 49 | |
| 50 | let proxyDetected: undefined | Koa.Context |
| 51 | export let cloudflareDetected: undefined | Date |
| 52 | export const someSecurity: Koa.Middleware = (ctx, next) => { |
| 53 | const ss = ctx.session |
| 54 | if (ss?.username && !ss?.[ALLOW_SESSION_IP_CHANGE]) |
| 55 | if (!ss.ip) |
| 56 | ss.ip = ctx.ip |
| 57 | else if (ss.ip !== ctx.ip) { |
| 58 | delete ss.username |
| 59 | ss.ip = ctx.ip |
| 60 | } |
| 61 | |
| 62 | if (!ctx.state.skipFilters && applyBlock(ctx.socket, ctx.ip)) |
| 63 | return |
| 64 | const decodedPath = try_(() => decodeURI(ctx.path)) |
| 65 | if (!decodedPath || hasDirTraversal(decodedPath)) |
| 66 | return |
| 67 | |
| 68 | if (ctx.get('X-Forwarded-For') |
| 69 | // we have some dev-proxies to ignore |
| 70 | && !(DEV && [process.env.FRONTEND_PROXY, process.env.ADMIN_PROXY].includes(ctx.get('X-Forwarded-port')))) { |
| 71 | proxyDetected = ctx |
| 72 | ctx.state.whenProxyDetected = new Date() |
| 73 | } |
| 74 | if (ctx.get('cf-ray')) |
| 75 | cloudflareDetected = new Date() |
| 76 | if (!ctx.secure && forceHttps.get() && getHttpsWorkingPort() && !isLocalHost(ctx)) { |
| 77 | const { URL } = ctx |
| 78 | URL.protocol = 'https' |
| 79 | URL.port = getHttpsWorkingPort() |
| 80 | ctx.status = 307 // this ensures the client doesn't switch to a simpler GET request |
| 81 | return ctx.redirect(URL.href) |
| 82 | } |
| 83 | return next() |
| 84 | } |
| 85 | |
| 86 | // limited to http proxies |
| 87 | export function getProxyDetected() { |
| 88 | if (Number(proxyDetected?.state.whenProxyDetected) < Date.now() - DAY) // detection is reset after a day |
| 89 | proxyDetected = undefined |
| 90 | return proxyDetected && { from: proxyDetected.socket.remoteAddress, for: proxyDetected.get('X-Forwarded-For') } |
| 91 | } |
| 92 | |
| 93 | export const prepareState: Koa.Middleware = async (ctx, next) => { |
| 94 | // normalize once so auth, filters and logging agree on the same client address |
| 95 | ctx.request.ip = normalizeIp(ctx.ip) |
| 96 | const s = ctx.session |
| 97 | if (s?.username) { |
| 98 | if (s.ts < invalidateSessionBefore.get(s?.username)!) |
| 99 | delete s.username |
| 100 | s.maxAge = sessionDuration.compiled() |
| 101 | } |
| 102 | // calculate these once and for all |
| 103 | ctx.state.connection = socket2connection(ctx.socket)! |
| 104 | // explicit credentials and existing sessions must take precedence, so a matching IP cannot override a chosen account |
| 105 | let a = await urlLogin() || await getHttpAccount() || !s?.username && autoLogin() |
| 106 | const loggedInNotBySession = a |
| 107 | ctx.state.account = a ||= getAccount(s?.username, false) // with least precedence, we consider session |
| 108 | if (a) |
| 109 | if (!accountCanLogin(a) || failAllowNet(ctx, a)) // enforce allow_net also after login |
| 110 | await setLoggedIn(ctx, false) |
| 111 | else if (loggedInNotBySession) { |
| 112 | if (a.username) |
| 113 | await setLoggedIn(ctx, a.username) |
| 114 | ctx.headers['x-username'] = a.username // give an easier way to determine if the login was successful |
| 115 | } |
| 116 | |
| 117 | ctx.state.revProxyPath = ctx.get('x-forwarded-prefix') |
| 118 | updateConnectionForCtx(ctx) |
| 119 | await next() |
| 120 | |
| 121 | function urlLogin() { |
| 122 | const { login } = ctx.query |
| 123 | if (!login) return |
| 124 | const [u, p] = splitAt(':', String(login)) |
| 125 | ctx.redirect(ctx.originalUrl.slice(0, -ctx.querystring.length-1)) // redirect to hide credentials |
| 126 | return u && clearTextLogin(ctx, u, p, 'url') |
| 127 | } |
| 128 | |
| 129 | function getHttpAccount() { |
| 130 | const b64 = allowAuthorizationHeader.get() && ctx.get('authorization')?.split(' ')[1] |
| 131 | if (!b64) return |
| 132 | try { |
| 133 | const [u, p] = atob(b64).split(':') |
| 134 | if (!u || u === s?.username) return // providing credentials, but not needed |
| 135 | return clearTextLogin(ctx, u, p||'', 'header') |
| 136 | } |
| 137 | catch {} |
| 138 | } |
| 139 | |
| 140 | function autoLogin() { |
| 141 | // keep the mask direct so group inheritance cannot make identity depend on account order |
| 142 | return Object.values(accounts.get()).find(a => |
| 143 | accountCanLogin(a) && a.auto_login_net && netMatches(ctx.ip, a.auto_login_net, true)) |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | export function failAllowNet(ctx: Koa.Context, a: Account | undefined) { |
| 148 | // a cached mask is valid only for the identity that stored it |
| 149 | const sameAccount = ctx.session?.username === normalizeUsername(a?.username || '') |
| 150 | const cached = sameAccount ? ctx.session?.allowNet : undefined |
| 151 | const mask = cached ?? getFromAccount(a || '', a => a.allow_net) |
| 152 | if (sameAccount && !cached && mask) |
| 153 | ctx.session.allowNet = mask // must be deleted on logout by setLoggedIn |
| 154 | const ret = mask && !netMatches(ctx.ip, mask, true) |
| 155 | if (ret) |
| 156 | console.debug("Login failed: allow_net") |
| 157 | return ret |
| 158 | } |
| 159 | |
| 160 | declare module "koa" { |
| 161 | interface DefaultState { |
| 162 | params: Record<string, any> |
| 163 | account?: Account // user logged in |
| 164 | revProxyPath: string // must not have final slash |
| 165 | connection: Connection |
| 166 | whenProxyDetected?: Date |
| 167 | } |
| 168 | } |
| 169 | export const paramsDecoder: Koa.Middleware = async (ctx, next) => { |
| 170 | ctx.state.params = ctx.method === 'POST' && ctx.originalUrl.startsWith(API_URI) |
| 171 | && (tryJson(await stream2string(ctx.req)) || {}) |
| 172 | await next() |
| 173 | } |
| 174 | |
| 175 | // once https cookie is created, http cannot do the same. The solution is to use 2 different cookies. |
| 176 | // But koa-session doesn't support 2 cookies, so I made this hacky solution: keep track of the options object, to modify the key at run-time. |
| 177 | let internalSessionMw: any |
| 178 | let options: any |
| 179 | events.once('app', () => // wait for app to be defined |
| 180 | internalSessionMw = session(options = { signed: true, renew: true, sameSite: 'lax' } as const, app) ) |
| 181 | export const sessionMiddleware: Koa.Middleware = (ctx, next) => { |
| 182 | options.key = 'hfs_' + ctx.protocol |
| 183 | return internalSessionMw(ctx, next) |
| 184 | } |