main
ts 184 lines 7.61 KB
Raw
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 }