validators.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. import { body, param } from "express-validator";
  2. import { isAfter, subDays, subHours } from "date-fns";
  3. import urlRegex from "url-regex";
  4. import { promisify } from "util";
  5. import axios from "axios";
  6. import dns from "dns";
  7. import URL from "url";
  8. import { CustomError, addProtocol } from "../utils";
  9. import query from "../queries";
  10. import knex from "../knex";
  11. import env from "../env";
  12. const dnsLookup = promisify(dns.lookup);
  13. export const preservedUrls = [
  14. "login",
  15. "logout",
  16. "signup",
  17. "reset-password",
  18. "resetpassword",
  19. "url-password",
  20. "url-info",
  21. "settings",
  22. "stats",
  23. "verify",
  24. "api",
  25. "404",
  26. "static",
  27. "images",
  28. "banned",
  29. "terms",
  30. "privacy",
  31. "protected",
  32. "report",
  33. "pricing"
  34. ];
  35. export const checkUser = (value, { req }) => !!req.user;
  36. export const createLink = [
  37. body("target")
  38. .exists({ checkNull: true, checkFalsy: true })
  39. .withMessage("Target is missing.")
  40. .isString()
  41. .trim()
  42. .isLength({ min: 1, max: 2040 })
  43. .withMessage("Maximum URL length is 2040.")
  44. .customSanitizer(addProtocol)
  45. .custom(
  46. value =>
  47. urlRegex({ exact: true, strict: false }).test(value) ||
  48. /^(?!https?)(\w+):\/\//.test(value)
  49. )
  50. .withMessage("URL is not valid.")
  51. .custom(value => URL.parse(value).host !== env.DEFAULT_DOMAIN)
  52. .withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
  53. body("password")
  54. .optional()
  55. .custom(checkUser)
  56. .withMessage("Only users can use this field.")
  57. .isString()
  58. .isLength({ min: 3, max: 64 })
  59. .withMessage("Password length must be between 3 and 64."),
  60. body("customurl")
  61. .optional()
  62. .custom(checkUser)
  63. .withMessage("Only users can use this field.")
  64. .isString()
  65. .trim()
  66. .isLength({ min: 1, max: 64 })
  67. .withMessage("Custom URL length must be between 1 and 64.")
  68. .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
  69. .withMessage("Custom URL is not valid")
  70. .custom(value => !preservedUrls.some(url => url.toLowerCase() === value))
  71. .withMessage("You can't use this custom URL."),
  72. body("reuse")
  73. .optional()
  74. .custom(checkUser)
  75. .withMessage("Only users can use this field.")
  76. .isBoolean()
  77. .withMessage("Reuse must be boolean."),
  78. body("domain")
  79. .optional()
  80. .custom(checkUser)
  81. .withMessage("Only users can use this field.")
  82. .isString()
  83. .withMessage("Domain should be string.")
  84. .customSanitizer(value => value.toLowerCase())
  85. .custom(async (address, { req }) => {
  86. const domain = await query.domain.find({
  87. address,
  88. user_id: req.user.id
  89. });
  90. req.body.domain = domain || null;
  91. return !!domain;
  92. })
  93. .withMessage("You can't use this domain.")
  94. ];
  95. export const redirectProtected = [
  96. body("password", "Password is invalid.")
  97. .exists({ checkFalsy: true, checkNull: true })
  98. .isString()
  99. .isLength({ min: 3, max: 64 })
  100. .withMessage("Password length must be between 3 and 64."),
  101. param("id", "ID is invalid.")
  102. .exists({ checkFalsy: true, checkNull: true })
  103. .isLength({ min: 36, max: 36 })
  104. ];
  105. export const addDomain = [
  106. body("address", "Domain is not valid")
  107. .exists({ checkFalsy: true, checkNull: true })
  108. .isLength({ min: 3, max: 64 })
  109. .withMessage("Domain length must be between 3 and 64.")
  110. .trim()
  111. .customSanitizer(value => {
  112. const parsed = URL.parse(value);
  113. return parsed.hostname || parsed.href;
  114. })
  115. .custom(value => urlRegex({ exact: true, strict: false }).test(value))
  116. .custom(value => value !== env.DEFAULT_DOMAIN)
  117. .withMessage("You can't use the default domain.")
  118. .custom(async (value, { req }) => {
  119. const domains = await query.domain.get({ user_id: req.user.id });
  120. return domains.length === 0;
  121. })
  122. .withMessage("You already own a domain. Contact support if you need more.")
  123. .custom(async value => {
  124. const domain = await query.domain.find({ address: value });
  125. return !domain || !domain.user_id || !domain.banned;
  126. })
  127. .withMessage("You can't add this domain."),
  128. body("homepage")
  129. .optional({ checkFalsy: true, nullable: true })
  130. .customSanitizer(addProtocol)
  131. .custom(value => urlRegex({ exact: true, strict: false }).test(value))
  132. .withMessage("Homepage is not valid.")
  133. ];
  134. export const removeDomain = [
  135. param("id", "ID is invalid.")
  136. .exists({
  137. checkFalsy: true,
  138. checkNull: true
  139. })
  140. .isLength({ min: 36, max: 36 })
  141. ];
  142. export const deleteLink = [
  143. param("id", "ID is invalid.")
  144. .exists({
  145. checkFalsy: true,
  146. checkNull: true
  147. })
  148. .isLength({ min: 36, max: 36 })
  149. ];
  150. export const reportLink = [
  151. body("link", "No link has been provided.")
  152. .exists({
  153. checkFalsy: true,
  154. checkNull: true
  155. })
  156. .custom(value => URL.parse(value).hostname === env.DEFAULT_DOMAIN)
  157. .withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`)
  158. ];
  159. export const banLink = [
  160. param("id", "ID is invalid.")
  161. .exists({
  162. checkFalsy: true,
  163. checkNull: true
  164. })
  165. .isLength({ min: 36, max: 36 }),
  166. body("host", '"host" should be a boolean.')
  167. .optional({
  168. nullable: true
  169. })
  170. .isBoolean(),
  171. body("user", '"user" should be a boolean.')
  172. .optional({
  173. nullable: true
  174. })
  175. .isBoolean(),
  176. body("userlinks", '"userlinks" should be a boolean.')
  177. .optional({
  178. nullable: true
  179. })
  180. .isBoolean(),
  181. body("domain", '"domain" should be a boolean.')
  182. .optional({
  183. nullable: true
  184. })
  185. .isBoolean()
  186. ];
  187. export const getStats = [
  188. param("id", "ID is invalid.")
  189. .exists({
  190. checkFalsy: true,
  191. checkNull: true
  192. })
  193. .isLength({ min: 36, max: 36 })
  194. ];
  195. export const signup = [
  196. body("password", "Password is not valid.")
  197. .exists({ checkFalsy: true, checkNull: true })
  198. .isLength({ min: 8, max: 64 })
  199. .withMessage("Password length must be between 8 and 64."),
  200. body("email", "Email is not valid.")
  201. .exists({ checkFalsy: true, checkNull: true })
  202. .trim()
  203. .isEmail()
  204. .isLength({ min: 0, max: 255 })
  205. .withMessage("Email length must be max 255.")
  206. .custom(async (value, { req }) => {
  207. const user = await query.user.find({ email: value });
  208. if (user) {
  209. req.user = user;
  210. }
  211. return !user || !user.verified;
  212. })
  213. .withMessage("You can't use this email address.")
  214. ];
  215. export const login = [
  216. body("password", "Password is not valid.")
  217. .exists({ checkFalsy: true, checkNull: true })
  218. .isLength({ min: 8, max: 64 })
  219. .withMessage("Password length must be between 8 and 64."),
  220. body("email", "Email is not valid.")
  221. .exists({ checkFalsy: true, checkNull: true })
  222. .trim()
  223. .isEmail()
  224. .isLength({ min: 0, max: 255 })
  225. .withMessage("Email length must be max 255.")
  226. ];
  227. export const changePassword = [
  228. body("password", "Password is not valid.")
  229. .exists({ checkFalsy: true, checkNull: true })
  230. .isLength({ min: 8, max: 64 })
  231. .withMessage("Password length must be between 8 and 64.")
  232. ];
  233. export const resetPasswordRequest = [
  234. body("email", "Email is not valid.")
  235. .exists({ checkFalsy: true, checkNull: true })
  236. .trim()
  237. .isEmail()
  238. .isLength({ min: 0, max: 255 })
  239. .withMessage("Email length must be max 255.")
  240. ];
  241. export const cooldown = (user: User) => {
  242. if (!env.GOOGLE_SAFE_BROWSING_KEY || !user || !user.cooldowns) return;
  243. // If has active cooldown then throw error
  244. const hasCooldownNow = user.cooldowns.some(cooldown =>
  245. isAfter(subHours(new Date(), 12), new Date(cooldown))
  246. );
  247. if (hasCooldownNow) {
  248. throw new CustomError("Cooldown because of a malware URL. Wait 12h");
  249. }
  250. };
  251. export const malware = async (user: User, target: string) => {
  252. if (!env.GOOGLE_SAFE_BROWSING_KEY) return;
  253. const isMalware = await axios.post(
  254. `https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${env.GOOGLE_SAFE_BROWSING_KEY}`,
  255. {
  256. client: {
  257. clientId: env.DEFAULT_DOMAIN.toLowerCase().replace(".", ""),
  258. clientVersion: "1.0.0"
  259. },
  260. threatInfo: {
  261. threatTypes: [
  262. "THREAT_TYPE_UNSPECIFIED",
  263. "MALWARE",
  264. "SOCIAL_ENGINEERING",
  265. "UNWANTED_SOFTWARE",
  266. "POTENTIALLY_HARMFUL_APPLICATION"
  267. ],
  268. platformTypes: ["ANY_PLATFORM", "PLATFORM_TYPE_UNSPECIFIED"],
  269. threatEntryTypes: [
  270. "EXECUTABLE",
  271. "URL",
  272. "THREAT_ENTRY_TYPE_UNSPECIFIED"
  273. ],
  274. threatEntries: [{ url: target }]
  275. }
  276. }
  277. );
  278. if (!isMalware.data || !isMalware.data.matches) return;
  279. if (user) {
  280. const [updatedUser] = await query.user.update(
  281. { id: user.id },
  282. {
  283. cooldowns: knex.raw("array_append(cooldowns, ?)", [
  284. new Date().toISOString()
  285. ]) as any
  286. }
  287. );
  288. // Ban if too many cooldowns
  289. if (updatedUser.cooldowns.length > 2) {
  290. await query.user.update({ id: user.id }, { banned: true });
  291. throw new CustomError("Too much malware requests. You are now banned.");
  292. }
  293. }
  294. throw new CustomError(
  295. user ? "Malware detected! Cooldown for 12h." : "Malware detected!"
  296. );
  297. };
  298. export const linksCount = async (user?: User) => {
  299. if (!user) return;
  300. const count = await query.link.total({
  301. user_id: user.id,
  302. created_at: [">", subDays(new Date(), 1).toISOString()]
  303. });
  304. if (count > env.USER_LIMIT_PER_DAY) {
  305. throw new CustomError(
  306. `You have reached your daily limit (${env.USER_LIMIT_PER_DAY}). Please wait 24h.`
  307. );
  308. }
  309. };
  310. export const bannedDomain = async (domain: string) => {
  311. const isBanned = await query.domain.find({
  312. address: domain,
  313. banned: true
  314. });
  315. if (isBanned) {
  316. throw new CustomError("URL is containing malware/scam.", 400);
  317. }
  318. };
  319. export const bannedHost = async (domain: string) => {
  320. let isBanned;
  321. try {
  322. const dnsRes = await dnsLookup(domain);
  323. if (!dnsRes || !dnsRes.address) return;
  324. isBanned = await query.host.find({
  325. address: dnsRes.address,
  326. banned: true
  327. });
  328. } catch (error) {
  329. isBanned = null;
  330. }
  331. if (isBanned) {
  332. throw new CustomError("URL is containing malware/scam.", 400);
  333. }
  334. };