validators.handler.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. const { isAfter, subDays, subHours, addMilliseconds } = require("date-fns");
  2. const { body, param } = require("express-validator");
  3. const promisify = require("util").promisify;
  4. const bcrypt = require("bcryptjs");
  5. const dns = require("dns");
  6. const URL = require("url");
  7. const ms = require("ms");
  8. const query = require("../queries");
  9. const utils = require("../utils");
  10. const knex = require("../knex");
  11. const env = require("../env");
  12. const dnsLookup = promisify(dns.lookup);
  13. const checkUser = (value, { req }) => !!req.user;
  14. const sanitizeCheckbox = value => value === true || value === "on" || value;
  15. const createLink = [
  16. body("target")
  17. .exists({ checkNull: true, checkFalsy: true })
  18. .withMessage("Target is missing.")
  19. .isString()
  20. .trim()
  21. .isLength({ min: 1, max: 2040 })
  22. .withMessage("Maximum URL length is 2040.")
  23. .customSanitizer(utils.addProtocol)
  24. .custom(value => utils.urlRegex.test(value) || /^(?!https?|ftp)(\w+:|\/\/)/.test(value))
  25. .withMessage("URL is not valid.")
  26. .custom(value => utils.removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
  27. .withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
  28. body("password")
  29. .optional({ nullable: true, checkFalsy: true })
  30. .custom(checkUser)
  31. .withMessage("Only users can use this field.")
  32. .isString()
  33. .isLength({ min: 3, max: 64 })
  34. .withMessage("Password length must be between 3 and 64."),
  35. body("customurl")
  36. .optional({ nullable: true, checkFalsy: true })
  37. .custom(checkUser)
  38. .withMessage("Only users can use this field.")
  39. .isString()
  40. .trim()
  41. .isLength({ min: 1, max: 64 })
  42. .withMessage("Custom URL length must be between 1 and 64.")
  43. .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
  44. .withMessage("Custom URL is not valid.")
  45. .custom(value => !utils.preservedURLs.some(url => url.toLowerCase() === value))
  46. .withMessage("You can't use this custom URL."),
  47. body("reuse")
  48. .optional({ nullable: true })
  49. .custom(checkUser)
  50. .withMessage("Only users can use this field.")
  51. .isBoolean()
  52. .withMessage("Reuse must be boolean."),
  53. body("description")
  54. .optional({ nullable: true, checkFalsy: true })
  55. .isString()
  56. .trim()
  57. .isLength({ min: 1, max: 2040 })
  58. .withMessage("Description length must be between 1 and 2040."),
  59. body("expire_in")
  60. .optional({ nullable: true, checkFalsy: true })
  61. .isString()
  62. .trim()
  63. .custom(value => {
  64. try {
  65. return !!ms(value);
  66. } catch {
  67. return false;
  68. }
  69. })
  70. .withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
  71. .customSanitizer(ms)
  72. .custom(value => value >= ms("1m"))
  73. .withMessage("Expire time should be more than 1 minute.")
  74. .customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
  75. body("domain")
  76. .optional({ nullable: true, checkFalsy: true })
  77. .custom(checkUser)
  78. .withMessage("Only users can use this field.")
  79. .isString()
  80. .withMessage("Domain should be string.")
  81. .customSanitizer(value => value.toLowerCase())
  82. .custom(async (address, { req }) => {
  83. if (address === env.DEFAULT_DOMAIN) {
  84. req.body.domain = null;
  85. return;
  86. }
  87. const domain = await query.domain.find({
  88. address,
  89. user_id: req.user.id
  90. });
  91. req.body.fetched_domain = domain || null;
  92. if (!domain) return Promise.reject();
  93. })
  94. .withMessage("You can't use this domain.")
  95. ];
  96. const editLink = [
  97. body("target")
  98. .optional({ checkFalsy: true, nullable: true })
  99. .isString()
  100. .trim()
  101. .isLength({ min: 1, max: 2040 })
  102. .withMessage("Maximum URL length is 2040.")
  103. .customSanitizer(utils.addProtocol)
  104. .custom(value => utils.urlRegex.test(value) || /^(?!https?|ftp)(\w+:|\/\/)/.test(value))
  105. .withMessage("URL is not valid.")
  106. .custom(value => utils.removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
  107. .withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
  108. body("password")
  109. .optional({ nullable: true, checkFalsy: true })
  110. .isString()
  111. .isLength({ min: 3, max: 64 })
  112. .withMessage("Password length must be between 3 and 64."),
  113. body("address")
  114. .optional({ checkFalsy: true, nullable: true })
  115. .isString()
  116. .trim()
  117. .isLength({ min: 1, max: 64 })
  118. .withMessage("Custom URL length must be between 1 and 64.")
  119. .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
  120. .withMessage("Custom URL is not valid")
  121. .custom(value => !utils.preservedURLs.some(url => url.toLowerCase() === value))
  122. .withMessage("You can't use this custom URL."),
  123. body("expire_in")
  124. .optional({ nullable: true, checkFalsy: true })
  125. .isString()
  126. .trim()
  127. .custom(value => {
  128. try {
  129. return !!ms(value);
  130. } catch {
  131. return false;
  132. }
  133. })
  134. .withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
  135. .customSanitizer(ms)
  136. .custom(value => value >= ms("1m"))
  137. .withMessage("Expire time should be more than 1 minute.")
  138. .customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
  139. body("description")
  140. .optional({ nullable: true, checkFalsy: true })
  141. .isString()
  142. .trim()
  143. .isLength({ min: 0, max: 2040 })
  144. .withMessage("Description length must be between 0 and 2040."),
  145. param("id", "ID is invalid.")
  146. .exists({ checkFalsy: true, checkNull: true })
  147. .isLength({ min: 36, max: 36 })
  148. ];
  149. const redirectProtected = [
  150. body("password", "Password is invalid.")
  151. .exists({ checkFalsy: true, checkNull: true })
  152. .isString()
  153. .isLength({ min: 3, max: 64 })
  154. .withMessage("Password length must be between 3 and 64."),
  155. param("id", "ID is invalid.")
  156. .exists({ checkFalsy: true, checkNull: true })
  157. .isLength({ min: 36, max: 36 })
  158. ];
  159. const addDomain = [
  160. body("address", "Domain is not valid.")
  161. .exists({ checkFalsy: true, checkNull: true })
  162. .isLength({ min: 3, max: 64 })
  163. .withMessage("Domain length must be between 3 and 64.")
  164. .trim()
  165. .customSanitizer(utils.addProtocol)
  166. .custom(value => utils.urlRegex.test(value))
  167. .customSanitizer(value => {
  168. const parsed = URL.parse(value);
  169. return utils.removeWww(parsed.hostname || parsed.href);
  170. })
  171. .custom(value => value !== env.DEFAULT_DOMAIN)
  172. .withMessage("You can't use the default domain.")
  173. .custom(async value => {
  174. const domain = await query.domain.find({ address: value });
  175. if (domain?.user_id || domain?.banned) return Promise.reject();
  176. })
  177. .withMessage("You can't add this domain."),
  178. body("homepage")
  179. .optional({ checkFalsy: true, nullable: true })
  180. .customSanitizer(utils.addProtocol)
  181. .custom(value => utils.urlRegex.test(value) || /^(?!https?|ftp)(\w+:|\/\/)/.test(value))
  182. .withMessage("Homepage is not valid.")
  183. ];
  184. const removeDomain = [
  185. param("id", "ID is invalid.")
  186. .exists({
  187. checkFalsy: true,
  188. checkNull: true
  189. })
  190. .isLength({ min: 36, max: 36 })
  191. ];
  192. const deleteLink = [
  193. param("id", "ID is invalid.")
  194. .exists({
  195. checkFalsy: true,
  196. checkNull: true
  197. })
  198. .isLength({ min: 36, max: 36 })
  199. ];
  200. const reportLink = [
  201. body("link", "No link has been provided.")
  202. .exists({
  203. checkFalsy: true,
  204. checkNull: true
  205. })
  206. .customSanitizer(utils.addProtocol)
  207. .custom(
  208. value => utils.removeWww(URL.parse(value).host) === env.DEFAULT_DOMAIN
  209. )
  210. .withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`)
  211. ];
  212. const banLink = [
  213. param("id", "ID is invalid.")
  214. .exists({
  215. checkFalsy: true,
  216. checkNull: true
  217. })
  218. .isLength({ min: 36, max: 36 }),
  219. body("host", '"host" should be a boolean.')
  220. .optional({
  221. nullable: true
  222. })
  223. .customSanitizer(sanitizeCheckbox)
  224. .isBoolean(),
  225. body("user", '"user" should be a boolean.')
  226. .optional({
  227. nullable: true
  228. })
  229. .customSanitizer(sanitizeCheckbox)
  230. .isBoolean(),
  231. body("userLinks", '"userLinks" should be a boolean.')
  232. .optional({
  233. nullable: true
  234. })
  235. .customSanitizer(sanitizeCheckbox)
  236. .isBoolean(),
  237. body("domain", '"domain" should be a boolean.')
  238. .optional({
  239. nullable: true
  240. })
  241. .customSanitizer(sanitizeCheckbox)
  242. .isBoolean()
  243. ];
  244. const getStats = [
  245. param("id", "ID is invalid.")
  246. .exists({
  247. checkFalsy: true,
  248. checkNull: true
  249. })
  250. .isLength({ min: 36, max: 36 })
  251. ];
  252. const signup = [
  253. body("password", "Password is not valid.")
  254. .exists({ checkFalsy: true, checkNull: true })
  255. .isLength({ min: 8, max: 64 })
  256. .withMessage("Password length must be between 8 and 64."),
  257. body("email", "Email is not valid.")
  258. .exists({ checkFalsy: true, checkNull: true })
  259. .trim()
  260. .isEmail()
  261. .isLength({ min: 0, max: 255 })
  262. .withMessage("Email length must be max 255.")
  263. .custom(async (value, { req }) => {
  264. const user = await query.user.find({ email: value });
  265. if (user)
  266. req.user = user;
  267. if (user?.verified)
  268. return Promise.reject();
  269. })
  270. .withMessage("You can't use this email address.")
  271. ];
  272. const login = [
  273. body("password", "Password is not valid.")
  274. .exists({ checkFalsy: true, checkNull: true })
  275. .isLength({ min: 8, max: 64 })
  276. .withMessage("Password length must be between 8 and 64."),
  277. body("email", "Email is not valid.")
  278. .exists({ checkFalsy: true, checkNull: true })
  279. .trim()
  280. .isEmail()
  281. .isLength({ min: 1, max: 255 })
  282. .withMessage("Email length must be max 255.")
  283. ];
  284. const changePassword = [
  285. body("currentpassword", "Password is not valid.")
  286. .exists({ checkFalsy: true, checkNull: true })
  287. .isLength({ min: 8, max: 64 })
  288. .withMessage("Password length must be between 8 and 64."),
  289. body("newpassword", "Password is not valid.")
  290. .exists({ checkFalsy: true, checkNull: true })
  291. .isLength({ min: 8, max: 64 })
  292. .withMessage("Password length must be between 8 and 64.")
  293. ];
  294. const changeEmail = [
  295. body("password", "Password is not valid.")
  296. .exists({ checkFalsy: true, checkNull: true })
  297. .isLength({ min: 8, max: 64 })
  298. .withMessage("Password length must be between 8 and 64."),
  299. body("email", "Email address is not valid.")
  300. .exists({ checkFalsy: true, checkNull: true })
  301. .trim()
  302. .isEmail()
  303. .isLength({ min: 1, max: 255 })
  304. .withMessage("Email length must be max 255.")
  305. ];
  306. const resetPassword = [
  307. body("email", "Email is not valid.")
  308. .exists({ checkFalsy: true, checkNull: true })
  309. .trim()
  310. .isEmail()
  311. .isLength({ min: 0, max: 255 })
  312. .withMessage("Email length must be max 255.")
  313. ];
  314. const deleteUser = [
  315. body("password", "Password is not valid.")
  316. .exists({ checkFalsy: true, checkNull: true })
  317. .isLength({ min: 8, max: 64 })
  318. .custom(async (password, { req }) => {
  319. const isMatch = await bcrypt.compare(password, req.user.password);
  320. if (!isMatch) return Promise.reject();
  321. })
  322. .withMessage("Password is not correct.")
  323. ];
  324. // TODO: if user has posted malware should do something better
  325. function cooldown(user) {
  326. if (!env.GOOGLE_SAFE_BROWSING_KEY || !user || !user.cooldown) return;
  327. // If user has active cooldown then throw error
  328. const hasCooldownNow = isAfter(subHours(new Date(), 12), new Date(user.cooldown))
  329. if (hasCooldownNow) {
  330. throw new utils.CustomError("Cooldown because of a malware URL. Wait 12h");
  331. }
  332. }
  333. // TODO: if user or non-user has posted malware should do something better
  334. async function malware(user, target) {
  335. if (!env.GOOGLE_SAFE_BROWSING_KEY) return;
  336. const isMalware = await fetch(
  337. `https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${env.GOOGLE_SAFE_BROWSING_KEY}`,
  338. {
  339. method: "post",
  340. body: JSON.stringify({
  341. client: {
  342. clientId: env.DEFAULT_DOMAIN.toLowerCase().replace(".", ""),
  343. clientVersion: "1.0.0"
  344. },
  345. threatInfo: {
  346. threatTypes: [
  347. "THREAT_TYPE_UNSPECIFIED",
  348. "MALWARE",
  349. "SOCIAL_ENGINEERING",
  350. "UNWANTED_SOFTWARE",
  351. "POTENTIALLY_HARMFUL_APPLICATION"
  352. ],
  353. platformTypes: ["ANY_PLATFORM", "PLATFORM_TYPE_UNSPECIFIED"],
  354. threatEntryTypes: [
  355. "EXECUTABLE",
  356. "URL",
  357. "THREAT_ENTRY_TYPE_UNSPECIFIED"
  358. ],
  359. threatEntries: [{ url: target }]
  360. }
  361. })
  362. }
  363. ).then(res => res.json());
  364. if (!isMalware.data || !isMalware.data.matches) return;
  365. if (user) {
  366. const [updatedUser] = await query.user.update(
  367. { id: user.id },
  368. {
  369. cooldown: new Date().toISOString(),
  370. },
  371. ['malicious_attempts']
  372. );
  373. // Ban if too many cooldowns
  374. if (updatedUser.malicious_attempts.length > 2) {
  375. await query.user.update({ id: user.id }, { banned: true });
  376. throw new utils.CustomError("Too much malware requests. You are now banned.");
  377. }
  378. }
  379. throw new utils.CustomError(
  380. user ? "Malware detected! Cooldown for 12h." : "Malware detected!"
  381. );
  382. };
  383. async function linksCount(user) {
  384. if (!user) return;
  385. const count = await query.link.total({
  386. user_id: user.id,
  387. "links.created_at": [">", subDays(new Date(), 1).toISOString()]
  388. });
  389. if (count > env.USER_LIMIT_PER_DAY) {
  390. throw new utils.CustomError(
  391. `You have reached your daily limit (${env.USER_LIMIT_PER_DAY}). Please wait 24h.`
  392. );
  393. }
  394. };
  395. async function bannedDomain(domain) {
  396. const isBanned = await query.domain.find({
  397. address: domain,
  398. banned: true
  399. });
  400. if (isBanned) {
  401. throw new utils.CustomError("URL is containing malware/scam.", 400);
  402. }
  403. };
  404. async function bannedHost(domain) {
  405. let isBanned;
  406. try {
  407. const dnsRes = await dnsLookup(domain);
  408. if (!dnsRes || !dnsRes.address) return;
  409. isBanned = await query.host.find({
  410. address: dnsRes.address,
  411. banned: true
  412. });
  413. } catch (error) {
  414. isBanned = null;
  415. }
  416. if (isBanned) {
  417. throw new utils.CustomError("URL is containing malware/scam.", 400);
  418. }
  419. };
  420. module.exports = {
  421. addDomain,
  422. banLink,
  423. bannedDomain,
  424. bannedHost,
  425. changeEmail,
  426. changePassword,
  427. checkUser,
  428. cooldown,
  429. createLink,
  430. deleteLink,
  431. deleteUser,
  432. editLink,
  433. getStats,
  434. linksCount,
  435. login,
  436. malware,
  437. redirectProtected,
  438. removeDomain,
  439. reportLink,
  440. resetPassword,
  441. signup,
  442. }