http.ts 5.8 KB
Newer Older
A
Asher 已提交
1
import { field, logger } from "@coder/logger"
A
Asher 已提交
2 3 4
import * as express from "express"
import * as expressCore from "express-serve-static-core"
import qs from "qs"
A
Asher 已提交
5
import { HttpCode, HttpError } from "../common/http"
A
Asher 已提交
6
import { normalize, Options } from "../common/util"
A
Asher 已提交
7
import { AuthType, DefaultedArgs } from "./cli"
A
Asher 已提交
8
import { commit, rootPath } from "./constants"
A
Asher 已提交
9
import { Heart } from "./heart"
10
import { getPasswordMethod, IsCookieValidArgs, isCookieValid, sanitizeString } from "./util"
A
Asher 已提交
11

A
Asher 已提交
12 13 14 15 16 17 18 19 20 21
declare global {
  // eslint-disable-next-line @typescript-eslint/no-namespace
  namespace Express {
    export interface Request {
      args: DefaultedArgs
      heart: Heart
    }
  }
}

A
Asher 已提交
22
/**
A
Asher 已提交
23
 * Replace common variable strings in HTML templates.
A
Asher 已提交
24
 */
A
Asher 已提交
25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
export const replaceTemplates = <T extends object>(
  req: express.Request,
  content: string,
  extraOpts?: Omit<T, "base" | "csStaticBase" | "logLevel">,
): string => {
  const base = relativeRoot(req)
  const options: Options = {
    base,
    csStaticBase: base + "/static/" + commit + rootPath,
    logLevel: logger.level,
    ...extraOpts,
  }
  return content
    .replace(/{{TO}}/g, (typeof req.query.to === "string" && req.query.to) || "/")
    .replace(/{{BASE}}/g, options.base)
    .replace(/{{CS_STATIC_BASE}}/g, options.csStaticBase)
    .replace(/"{{OPTIONS}}"/, `'${JSON.stringify(options)}'`)
A
Asher 已提交
42 43 44
}

/**
A
Asher 已提交
45
 * Throw an error if not authorized. Call `next` if provided.
A
Asher 已提交
46
 */
47 48 49 50 51 52 53
export const ensureAuthenticated = async (
  req: express.Request,
  _?: express.Response,
  next?: express.NextFunction,
): Promise<void> => {
  const isAuthenticated = await authenticated(req)
  if (!isAuthenticated) {
A
Asher 已提交
54
    throw new HttpError("Unauthorized", HttpCode.Unauthorized)
55
  }
A
Asher 已提交
56 57 58
  if (next) {
    next()
  }
A
Asher 已提交
59 60
}

A
Asher 已提交
61 62 63
/**
 * Return true if authenticated via cookies.
 */
64
export const authenticated = async (req: express.Request): Promise<boolean> => {
A
Asher 已提交
65
  switch (req.args.auth) {
66
    case AuthType.None: {
A
Asher 已提交
67
      return true
68 69
    }
    case AuthType.Password: {
A
Asher 已提交
70
      // The password is stored in the cookie after being hashed.
71 72 73 74
      const hashedPasswordFromArgs = req.args["hashed-password"]
      const passwordMethod = getPasswordMethod(hashedPasswordFromArgs)
      const isCookieValidArgs: IsCookieValidArgs = {
        passwordMethod,
75
        cookieKey: sanitizeString(req.cookies.key),
76 77 78 79 80
        passwordFromArgs: req.args.password || "",
        hashedPasswordFromArgs: req.args["hashed-password"],
      }

      return await isCookieValid(isCookieValidArgs)
81 82
    }
    default: {
A
Asher 已提交
83
      throw new Error(`Unsupported auth type ${req.args.auth}`)
84
    }
A
Asher 已提交
85
  }
A
Asher 已提交
86 87
}

A
Asher 已提交
88 89 90 91 92 93 94 95 96 97 98 99
/**
 * Get the relative path that will get us to the root of the page. For each
 * slash we need to go up a directory. For example:
 * / => .
 * /foo => .
 * /foo/ => ./..
 * /foo/bar => ./..
 * /foo/bar/ => ./../..
 */
export const relativeRoot = (req: express.Request): string => {
  const depth = (req.originalUrl.split("?", 1)[0].match(/\//g) || []).length
  return normalize("./" + (depth > 1 ? "../".repeat(depth - 1) : ""))
A
Asher 已提交
100 101
}

A
Asher 已提交
102
/**
103 104
 * Redirect relatively to `/${to}`. Query variables on the current URI will be preserved.
 * `to` should be a simple path without any query parameters
A
Asher 已提交
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124
 * `override` will merge with the existing query (use `undefined` to unset).
 */
export const redirect = (
  req: express.Request,
  res: express.Response,
  to: string,
  override: expressCore.Query = {},
): void => {
  const query = Object.assign({}, req.query, override)
  Object.keys(override).forEach((key) => {
    if (typeof override[key] === "undefined") {
      delete query[key]
    }
  })

  const relativePath = normalize(`${relativeRoot(req)}/${to}`, true)
  const queryString = qs.stringify(query)
  const redirectPath = `${relativePath}${queryString ? `?${queryString}` : ""}`
  logger.debug(`redirecting from ${req.originalUrl} to ${redirectPath}`)
  res.redirect(redirectPath)
125 126
}

A
Asher 已提交
127
/**
A
Asher 已提交
128
 * Get the value that should be used for setting a cookie domain. This will
129 130 131
 * allow the user to authenticate once no matter what sub-domain they use to log
 * in. This will use the highest level proxy domain (e.g. `coder.com` over
 * `test.coder.com` if both are specified).
A
Asher 已提交
132
 */
A
Asher 已提交
133 134 135
export const getCookieDomain = (host: string, proxyDomains: string[]): string | undefined => {
  const idx = host.lastIndexOf(":")
  host = idx !== -1 ? host.substring(0, idx) : host
136 137
  // If any of these are true we will still set cookies but without an explicit
  // `Domain` attribute on the cookie.
A
Asher 已提交
138
  if (
139
    // The host can be be blank or missing so there's nothing we can set.
A
Asher 已提交
140 141
    !host ||
    // IP addresses can't have subdomains so there's no value in setting the
142 143
    // domain for them. Assume that anything with a : is ipv6 (valid domain name
    // characters are alphanumeric or dashes)...
A
Asher 已提交
144
    host.includes(":") ||
145
    // ...and that anything entirely numbers and dots is ipv4 (currently tlds
A
Asher 已提交
146 147
    // cannot be entirely numbers).
    !/[^0-9.]/.test(host) ||
148 149
    // localhost subdomains don't seem to work at all (browser bug?). A cookie
    // set at dev.localhost cannot be read by 8080.dev.localhost.
A
Asher 已提交
150
    host.endsWith(".localhost") ||
151 152 153 154 155 156 157 158
    // Domains without at least one dot (technically two since domain.tld will
    // become .domain.tld) are considered invalid according to the spec so don't
    // set the domain for them. In my testing though localhost is the only
    // problem (the browser just doesn't store the cookie at all). localhost has
    // an additional problem which is that a reverse proxy might give
    // code-server localhost even though the domain is really domain.tld (by
    // default NGINX does this).
    !host.includes(".")
A
Asher 已提交
159 160 161 162 163 164 165 166 167 168 169 170
  ) {
    logger.debug("no valid cookie doman", field("host", host))
    return undefined
  }

  proxyDomains.forEach((domain) => {
    if (host.endsWith(domain) && domain.length < host.length) {
      host = domain
    }
  })

  logger.debug("got cookie doman", field("host", host))
A
Asher 已提交
171
  return host || undefined
A
Asher 已提交
172
}