diff --git a/apxtri/apxtri.js b/apxtri/apxtri.js index e78ccd9..0414cb1 100755 --- a/apxtri/apxtri.js +++ b/apxtri/apxtri.js @@ -43,6 +43,14 @@ SEE README.md to start ********************************************/ const apxtri = {}; +apxtri.istribe = (tribe) => { + if (typeof tribe !== "string" || !/^[a-z0-9_-]{1,64}$/.test(tribe)) { + return false; + } + if (tribe === "adminapi") return true; + return fs.existsSync(`../${tribe}/apxtri/routes`); +}; + apxtri.main = async () => { if (process.env.NODE_MODE !== 'dev' && !fs.existsSync("/etc/nginx/nginx.conf")) { console.log( @@ -51,39 +59,97 @@ apxtri.main = async () => { process.exit(0); } //Check prerequest data - if ( - fs.existsSync("../adminapi/objects/tribes/idx/tribes_dns.json") && - fs.existsSync("../adminapi/objects/tribes/itm/adminapi.json") - ) { - // check all tribes are in tribes_dns.json - const conf = fs.readJSONSync( - "../adminapi/objects/tribes/itm/adminapi.json" + const adminapipath = "../adminapi/objects/tribes/itm/adminapi.json"; + const tribednspath = "../adminapi/objects/tribes/idx/tribes_dns.json"; + // An existing adminapi.json means this is not a first install: never + // overwrite it from the template, even if tribes_dns.json is missing. + if (fs.existsSync(adminapipath)) { + // check all tribes are in tribes_dns.json (rebuild it if needed) + const conf = fs.readJSONSync(adminapipath); + const tribesdns = fs.existsSync(tribednspath) + ? fs.readJsonSync(tribednspath) + : {}; + Object.keys(tribesdns).forEach((tribe) => { + if (!apxtri.istribe(tribe)) { + console.log( + `\x1b[31m drop unknown tribe from tribes_dns.json: ${tribe}\x1b[0m` + ); + delete tribesdns[tribe]; + } + }); + // A partial/restored adminapi.json must not crash the whole server: fill + // the runtime configuration from the install template and warn loudly. + const initconf = fs.readJsonSync( + "../adminapi/apxtri/setup/initadminapi.json" ); - const tribesdns = fs.readJsonSync( - `../adminapi/objects/tribes/idx/tribes_dns.json` - ); - //check if new tribe was add - glob + if (!conf.api) { + conf.api = JSON.parse(JSON.stringify(initconf.api)); + console.log( + "\x1b[31m adminapi.json is missing 'api', using defaults from apxtri/setup/initadminapi.json. Restore this file from a backup.\x1b[0m" + ); + } + if (!conf.socket) { + conf.socket = JSON.parse(JSON.stringify(initconf.socket)); + console.log( + "\x1b[31m adminapi.json is missing 'socket', using default port from apxtri/setup/initadminapi.json.\x1b[0m" + ); + } + //check if new tribe was add (only real tribe folders, never tmp/.git/...) + const tribedirs = glob .sync("../*") .filter((f) => fs.lstatSync(f).isDirectory()) - .forEach(async (t) => { - const tribe = path.basename(t); - //console.log(tribe); - if (![".", ".."].includes(tribe) && !tribesdns[tribe]) { - await apxtri.setuptribe(tribe, conf); + .filter((f) => fs.existsSync(`${f}/apxtri/routes`)); + for (const t of tribedirs) { + const tribe = path.basename(t); + if ([".", ".."].includes(tribe) || tribesdns[tribe]) continue; + // Never re-initialize an already configured tribe: if its itm file + // exists, just restore its dns entry instead of overwriting the config. + const itmpath = `../adminapi/objects/tribes/itm/${tribe}.json`; + if (fs.existsSync(itmpath)) { + try { + const existing = fs.readJSONSync(itmpath); + if (Array.isArray(existing.dns) && existing.dns.length > 0) { + tribesdns[tribe] = existing.dns; + console.log( + `tribe ${tribe} already configured, restored in tribes_dns.json` + ); + continue; + } + } catch (err) { + console.log( + `unreadable itm/${tribe}.json, skipping:`, + err.message + ); + continue; } - }); + } + await apxtri.setuptribe(tribe, conf); + } + // merge with the on-disk index so setuptribe writes are preserved + const ondiskdns = fs.existsSync(tribednspath) + ? fs.readJsonSync(tribednspath) + : {}; + Object.keys(ondiskdns).forEach((tribe) => { + if (!apxtri.istribe(tribe)) delete ondiskdns[tribe]; + }); + fs.outputJSONSync( + tribednspath, + Object.assign({}, ondiskdns, tribesdns), + { space: 2 } + ); + // run api with the (possibly repaired) in-memory conf + await apxtri.runexpress(fs.readJsonSync(tribednspath), conf); } else { const initconf = fs.readJsonSync( "../adminapi/apxtri/setup/initadminapi.json" ); await apxtri.setuptribe("adminapi", initconf); + // run api with update conf + await apxtri.runexpress( + fs.readJsonSync(`../adminapi/objects/tribes/idx/tribes_dns.json`), + fs.readJSONSync("../adminapi/objects/tribes/itm/adminapi.json") + ); } - // run api with update conf - apxtri.runexpress( - fs.readJsonSync(`../adminapi/objects/tribes/idx/tribes_dns.json`), - fs.readJSONSync("../adminapi/objects/tribes/itm/adminapi.json") - ); }; apxtri.getip = async () => { /** @@ -112,6 +178,21 @@ apxtri.getip = async () => { return network; }; apxtri.setuptribe = async (tribe, conf) => { + if (!apxtri.istribe(tribe)) { + console.log( + `\x1b[31m skip setuptribe: ${tribe} is not an existing tribe folder\x1b[0m` + ); + return; + } + if (tribe != "adminapi") { + const existingitm = `../adminapi/objects/tribes/itm/${tribe}.json`; + if (fs.existsSync(existingitm)) { + console.log( + `\x1b[31m skip setuptribe: ${tribe} is already configured\x1b[0m` + ); + return; + } + } let inittribe; if (tribe == "adminapi") { console.log( @@ -131,9 +212,13 @@ apxtri.setuptribe = async (tribe, conf) => { } } else { console.log(`a new tribe called ${tribe} was detected`); - const adminapiconf = fs.readJSONSync( - "../adminapi/objects/tribes/itm/adminapi.json" + const initconf = fs.readJsonSync( + "../adminapi/apxtri/setup/initadminapi.json" ); + const adminapiconf = + conf && conf.api && conf.socket + ? conf + : fs.readJSONSync("../adminapi/objects/tribes/itm/adminapi.json"); inittribe = { tribeId: tribe, townpath: __dirname.replace("/adminapi/apxtri", ""), @@ -142,8 +227,8 @@ apxtri.setuptribe = async (tribe, conf) => { nationId: conf.nationId, townId: conf.townId, activelog: [], - api: { port: adminapiconf.api.port }, - socket: { port: adminapiconf.socket.port }, + api: { port: (adminapiconf.api || initconf.api).port }, + socket: { port: (adminapiconf.socket || initconf.socket).port }, }; } inittribe.sudoUser = process.env.USER; @@ -171,7 +256,7 @@ apxtri.setuptribe = async (tribe, conf) => { let lsttribe = fs.existsSync(lst_tribeIdpath) ? fs.readJsonSync(lst_tribeIdpath) : []; - lsttribe.push(tribe); + if (!lsttribe.includes(tribe)) lsttribe.push(tribe); fs.outputJSONSync(lst_tribeIdpath, lsttribe); const tribes_dnspath = "../adminapi/objects/tribes/idx/tribes_dns.json"; const tribedns = fs.existsSync(tribes_dnspath) @@ -248,7 +333,12 @@ apxtri.setuptribe = async (tribe, conf) => { apxtri.runexpress = async (tribesdns, conf) => { const Odmdb = require(path.resolve("./apxtri/models/Odmdb.js")); - let tribeIds = Object.keys(tribesdns); + const checkRequestSafety = require( + path.resolve("./apxtri/middlewares/checkRequestSafety.js") + ); + let tribeIds = Object.keys(tribesdns).filter((tribe) => + apxtri.istribe(tribe) + ); // context is store in /itm/tribename.json ={contexte:{routes:[],models:[{model:,tplstringslg:[]}]} // routes={url,route} check how to add plugin tribe route later // keep only the 2 last part (.) of domain name to validate requests with it (generic domain) @@ -260,6 +350,13 @@ apxtri.runexpress = async (tribesdns, conf) => { if (!doms.includes(dm)) doms.push(dm); //reindex database attention check dev-ants/.. a bug was fixed glob.sync(`../${t}/objects/*`).forEach((o) => { + if (!fs.existsSync(o) || !fs.lstatSync(o).isDirectory()) return; + if ( + !fs.existsSync(path.join(o, "itm")) && + !fs.existsSync(path.join(o, "conf.json")) + ) { + return; + } console.log("reindex: ", o); Odmdb.runidx(o); }); @@ -292,18 +389,34 @@ apxtri.runexpress = async (tribesdns, conf) => { }); const app = express(); // load express parameter from conf - Object.keys(conf.api.appset).forEach((p) => { - app.set(p, conf.api.appset[p]); + const apiconf = conf.api || {}; + if (apiconf.appset && apiconf.appset["trust proxy"] === true) { + apiconf.appset["trust proxy"] = 1; + console.log( + "\x1b[31m api.appset['trust proxy']=true is unsafe, using 1 (nginx only)\x1b[0m" + ); + } + Object.keys(apiconf.appset || {}).forEach((p) => { + app.set(p, apiconf.appset[p]); }); // To set depending of data form or get size to send tribeIds.forEach((t) => { - app.use(`/${t}`, bodyParser.urlencoded(conf.api.bodyparse.urlencoded)); - app.use(`/${t}`, bodyParser.json(conf.api.bodyparse.json)); + app.use( + `/${t}`, + bodyParser.urlencoded( + (apiconf.bodyparse && apiconf.bodyparse.urlencoded) || {}, + ), + ); + app.use( + `/${t}`, + bodyParser.json((apiconf.bodyparse && apiconf.bodyparse.json) || {}), + ); // To set depending of post put json data size to send - app.use(`/${t}`, express.json(conf.api.json)); + app.use(`/${t}`, express.json(apiconf.json || {})); }); app.disable("x-powered-by"); // for security app.locals.tribeids = tribeIds; + app.use(checkRequestSafety); const currentmod = "apxtri"; const log = process.env.ACTIVELOG ? process.env.ACTIVELOG.split(',').includes(currentmod) : false; console.log( diff --git a/apxtri/middlewares/checkHeaders.js b/apxtri/middlewares/checkHeaders.js index 5517dd4..f83b140 100755 --- a/apxtri/middlewares/checkHeaders.js +++ b/apxtri/middlewares/checkHeaders.js @@ -60,6 +60,14 @@ const log = process.env.ACTIVELOG ? process.env.ACTIVELOG.split(',').includes(cu * data: {xalias} * } */ +const aliaspattern = /^[a-z0-9]{3,64}$/; +const namepattern = /^[A-Za-z0-9_.:-]{1,64}$/; +const indexpattern = /^[A-Za-z0-9_.-]{1,128}$/; +const uuidpattern = + /^(0|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/; +const emailpattern = + /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + const checkHeaders = (req, res, next) => { req.session = {}; const header = {}; @@ -90,14 +98,118 @@ const checkHeaders = (req, res, next) => { data: {missingheader}, }); } + const allowedtribes = [ + "town", + "adminapi", + ...(Array.isArray(req.app.locals.tribeids) ? req.app.locals.tribeids : []), + ]; + const invalidheader = (field, value) => + res.status(400).json({ + status: 400, + ref: "middlewares", + msg: "headervalueinvalid", + data: { field, value }, + }); + if (header.xalias !== "anonymous" && !aliaspattern.test(header.xalias)) { + return invalidheader("xalias", header.xalias); + } + if (!uuidpattern.test(header.xuuid)) { + return invalidheader("xuuid", header.xuuid); + } + if ( + header.xapp === "." || + header.xapp === ".." || + !namepattern.test(header.xapp) + ) { + return invalidheader("xapp", header.xapp); + } + if (header.xdays !== undefined && !/^\d{0,16}$/.test(header.xdays)) { + return invalidheader("xdays", header.xdays); + } + if ( + header.xtrkversion !== undefined && + !/^\d{0,10}$/.test(header.xtrkversion) + ) { + return invalidheader("xtrkversion", header.xtrkversion); + } + if ( + req.headers.xworkon !== undefined && + !allowedtribes.includes(req.headers.xworkon) + ) { + return res.status(404).json({ + status: 404, + ref: "middlewares", + msg: "tribeiddoesnotexist", + data: { xworkon: req.headers.xworkon }, + }); + } + const invalidparam = (param, value) => + res.status(400).json({ + status: 400, + ref: "middlewares", + msg: "paramvalueinvalid", + data: { param, value }, + }); + for (const param of ["tribe", "tribeId", "tribeid"]) { + if ( + req.params[param] !== undefined && + !allowedtribes.includes(req.params[param]) + ) { + return res.status(404).json({ + status: 404, + ref: "middlewares", + msg: "tribeiddoesnotexist", + data: { tribe: req.params[param] }, + }); + } + } + const paramrules = { + alias: aliaspattern, + objectname: indexpattern, + optionname: indexpattern, + indexname: indexpattern, + primaryindex: /^[A-Za-z0-9_.@+-]{1,128}$/, + primaryid: /^[A-Za-z0-9_.@+-]{1,128}$/, + apxid: /^[A-Za-z0-9_]{1,64}$/, + articleid: indexpattern, + evaluation: /^(follow|[0-4])$/, + app: namepattern, + key: namepattern, + jobadid: indexpattern, + jobstepid: indexpattern, + personalias: aliaspattern, + recruiter: aliaspattern, + seeker: aliaspattern, + profil: namepattern, + wconame: namepattern, + appname: namepattern, + pagename: namepattern, + website: namepattern, + template: namepattern, + mlist: namepattern, + srckey: namepattern, + action: namepattern, + pluginname: namepattern, + filename: namepattern, + email: emailpattern, + siret: /^\d{14}$/, + typekey: /^[A-Za-z0-9_.-]{1,32}$/, + data: /^[A-Za-z0-9_.@+-]{1,128}$/, + validation: /^[A-Za-z0-9_-]{1,128}$/, + digit6: /^\d{1,6}$/, + version: /^\d{1,16}$/, + }; + for (const param of Object.keys(paramrules)) { + const value = req.params[param]; + if (value === undefined || value === "") continue; + if (value === "." || value === ".." || !paramrules[param].test(value)) { + return invalidparam(param, value); + } + } //console.log( req.app.locals.tribeids ) // xtribe == "town" is used during the setup process // xtribe == "adminapi" is used to access /adminapi - if ( - !( - ["town","adminapi"].includes(header.xtribe) || req.app.locals.tribeids.includes(header.xtribe) - ) - ) { + if (!allowedtribes.includes(header.xtribe)) { return res.status(404).json({ status:404, ref: "middlewares", diff --git a/apxtri/middlewares/checkRequestSafety.js b/apxtri/middlewares/checkRequestSafety.js new file mode 100644 index 0000000..656e37b --- /dev/null +++ b/apxtri/middlewares/checkRequestSafety.js @@ -0,0 +1,67 @@ +const Security = require("../models/Security.js"); + +const currentmod = "checkRequestSafety"; +const log = process.env.ACTIVELOG + ? process.env.ACTIVELOG.split(",").includes(currentmod) + : false; + +const pathHeaders = ["xtribe", "xapp", "xuuid", "xalias", "xworkon"]; +const enforce = process.env.SECURITY_TRAVERSAL_ENFORCE === "true"; + +const collect = (value, field, findings, depth = 0) => { + if (depth > 8 || findings.length >= 5) return; + if (typeof value === "string") { + if (Security.freeTextFields.includes(field)) return; + if (/^imgbase64_/i.test(field)) return; + if (Security.isTraversal(value)) findings.push({ field, value }); + return; + } + if (Array.isArray(value)) { + value.slice(0, 100).forEach((item) => collect(item, field, findings, depth + 1)); + return; + } + if (value && typeof value === "object") { + Object.keys(value) + .slice(0, 100) + .forEach((key) => collect(value[key], key, findings, depth + 1)); + } +}; + +const checkRequestSafety = (req, res, next) => { + const findings = []; + collect(req.originalUrl, "url", findings); + collect(req.path, "path", findings); + collect(req.params, "params", findings); + collect(req.query, "query", findings); + collect(req.body, "body", findings); + pathHeaders.forEach((header) => { + const value = req.headers[header]; + if (value !== undefined && Security.isTraversal(value)) { + findings.push({ field: header, value }); + } + }); + + const unique = []; + findings.forEach((finding) => { + const key = `${finding.field}|${finding.value}`; + if (!unique.some((item) => `${item.field}|${item.value}` === key)) { + unique.push(finding); + } + }); + + if (unique.length > 0) { + if (log) console.log(currentmod, "suspicious request", unique); + unique.forEach((finding) => Security.alert(req, finding)); + if (enforce) { + return res.status(400).json({ + status: 400, + ref: "middlewares", + msg: "invalidrequest", + data: { fields: unique.map((finding) => finding.field) }, + }); + } + } + next(); +}; + +module.exports = checkRequestSafety; diff --git a/apxtri/middlewares/isAuthenticated.js b/apxtri/middlewares/isAuthenticated.js index 1192261..2477013 100755 --- a/apxtri/middlewares/isAuthenticated.js +++ b/apxtri/middlewares/isAuthenticated.js @@ -117,13 +117,33 @@ const isAuthenticated = async (req, res, next) => { if (log) console.log(currentmod,"time out penalty"); } }; + const paganpath = `../adminapi/objects/pagans/itm/${req.session.header.xalias}.json`; + const paganexists = fs.existsSync(paganpath); + const base = req.baseUrl || ""; + const ispaganroute = /\/pagans\/?$/.test(base); + const iscreatepagan = + req.method === "POST" && + ispaganroute && + (req.path === "/" || req.path === ""); + if (!paganexists && !iscreatepagan) { + resnotauth.status = 401; + resnotauth.data.xaliasexists = false; + return res.status(resnotauth.status).json(resnotauth); + } + if (iscreatepagan && req.body.alias !== req.session.header.xalias) { + return res.status(400).json({ + status: 400, + ref: "Middlewares", + msg: "aliasmismatch", + data: { xalias: req.session.header.xalias, alias: req.body.alias }, + }); + } if (!fs.existsSync(tmpfs)) { // need to check detached sign let publickey = ""; - const aliasinfo = `../adminapi/objects/pagans/itm/${req.session.header.xalias}.json`; - if (fs.existsSync(aliasinfo)) { - publickey = fs.readJsonSync(aliasinfo).publickey; - } else if (req.body.publickey) { + if (paganexists) { + publickey = fs.readJsonSync(paganpath).publickey; + } else if (iscreatepagan && req.body.publickey) { resnotauth.data.xaliasexists = false; publickey = req.body.publickey; } diff --git a/apxtri/models/Checkjson.js b/apxtri/models/Checkjson.js index 99b700c..6be2a78 100755 --- a/apxtri/models/Checkjson.js +++ b/apxtri/models/Checkjson.js @@ -106,7 +106,8 @@ Checkjson.schema.properties.format = { ipv4: /^([0–9]{1,3}.){3}.([0–9]{1,3})$/, ipv6: /^((([0–9A-Fa-f]{1,4}:){7}[0–9A-Fa-f]{1,4})|(([0–9A-Fa-f]{1,4}:){6}:[0–9A-Fa-f]{1,4})|(([0–9A-Fa-f]{1,4}:){5}:([0–9A-Fa-f]{1,4}:)?[0–9A-Fa-f]{1,4})|(([0–9A-Fa-f]{1,4}:){4}:([0–9A-Fa-f]{1,4}:){0,2}[0–9A-Fa-f]{1,4})|(([0–9A-Fa-f]{1,4}:){3}:([0–9A-Fa-f]{1,4}:){0,3}[0–9A-Fa-f]{1,4})|(([0–9A-Fa-f]{1,4}:){2}:([0–9A-Fa-f]{1,4}:){0,4}[0–9A-Fa-f]{1,4})|(([0–9A-Fa-f]{1,4}:){6}((b((25[0–5])|(1d{2})|(2[0–4]d)|(d{1,2}))b).){3}(b((25[0–5])|(1d{2})|(2[0–4]d)|(d{1,2}))b))|(([0–9A-Fa-f]{1,4}:){0,5}:((b((25[0–5])|(1d{2})|(2[0–4]d)|(d{1,2}))b).){3}(b((25[0–5])|(1d{2})|(2[0–4]d)|(d{1,2}))b))|(::([0–9A-Fa-f]{1,4}:){0,5}((b((25[0–5])|(1d{2})|(2[0–4]d)|(d{1,2}))b).){3}(b((25[0–5])|(1d{2})|(2[0–4]d)|(d{1,2}))b))|([0–9A-Fa-f]{1,4}::([0–9A-Fa-f]{1,4}:){0,5}[0–9A-Fa-f]{1,4})|(::([0–9A-Fa-f]{1,4}:){0,6}[0–9A-Fa-f]{1,4})|(([0–9A-Fa-f]{1,4}:){1,7}:))$/, telephonefr: /^0[1-9][0-9]{8}$/, - imgbase64: /data:image\/(png|jpg|jpeg|svg);base64,(?:[A-Za-z0-9+\/]+)/, + imgbase64: + /^data:image\/(png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/]+={0,2}$/i, telephoneinter: /^\+*(\d{3})*[0-9,\-]{8,}/, password: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[$@$!%*?&.])[A-Za-z\d$@$!%*?&.{}:|\s]{8,}/, diff --git a/apxtri/models/Notifications.js b/apxtri/models/Notifications.js index ab18aeb..02230e8 100644 --- a/apxtri/models/Notifications.js +++ b/apxtri/models/Notifications.js @@ -21,7 +21,18 @@ const log = process.env.ACTIVELOG const Notifications = {}; Notifications.get = (alias, tribeId) => { - const notiffile = `../${req.params.tribe}/notifications/${req.params.alias}.json`; + if ( + !/^[a-z0-9]{3,64}$/.test(String(alias)) || + !/^[a-z0-9_-]{1,64}$/.test(String(tribeId)) + ) { + return { + status: 400, + ref: "Notification", + msg: "paramsinvalid", + data: { alias, tribeId }, + }; + } + const notiffile = `../${tribeId}/notifications/${alias}.json`; const msg = fs.existsSync(notiffile) ? fs.readJSONSync(notiffile) : {}; return { status: 200, @@ -50,8 +61,6 @@ Notifications.statmaillist = (tribe) => { }); }); }); - // fichier csv stocker en local en attendant d'avoir un back pour stocker la reponse dans data.csv - fs.outputFileSync(`../${tribe}/mailinglst.csv`, csv, "utf-8"); return { status: 200, ref: "Notifications", diff --git a/apxtri/models/Odmdb.js b/apxtri/models/Odmdb.js index ee373c9..e653ece 100644 --- a/apxtri/models/Odmdb.js +++ b/apxtri/models/Odmdb.js @@ -625,6 +625,145 @@ Odmdb.indexAllowed = (indexaccess, role) => { * */ +Odmdb.prepareUploadedImages = async (objectPathname, itm) => { + const getschema = Odmdb.Schema(objectPathname, true); + if (getschema.status != 200) return getschema; + const schema = getschema.data.schema; + const imagekeys = Object.keys(itm).filter( + (key) => key.startsWith("imgbase64_") && itm[key] !== "" + ); + if (imagekeys.length == 0) { + return { + status: 200, + ref: "Odmdb", + msg: "imagesprepared", + data: { written: [] }, + }; + } + const apxidvalue = itm[schema.apxid]; + if (!apxidvalue) { + return { + status: 406, + ref: "Odmdb", + msg: "apxidmissing", + data: { missingkey: schema.apxid }, + }; + } + const allowed = /^data:image\/(png|jpeg|jpg|webp|gif);base64,([A-Za-z0-9+/=\s]+)$/i; + const validmagic = (buffer) => { + if (buffer.length < 12) return false; + const ispng = + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 && + buffer[4] === 0x0d && + buffer[5] === 0x0a && + buffer[6] === 0x1a && + buffer[7] === 0x0a; + const isjpeg = + buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; + const gif = buffer.slice(0, 6).toString(); + const isgif = gif === "GIF87a" || gif === "GIF89a"; + const iswebp = + buffer.slice(0, 4).toString() === "RIFF" && + buffer.slice(8, 12).toString() === "WEBP"; + return ispng || isjpeg || isgif || iswebp; + }; + const sharp = require("sharp"); + const objectname = path.basename(objectPathname); + const imgdir = path.resolve(objectPathname, "img"); + const safeid = String(apxidvalue) + .replace(/[^A-Za-z0-9_-]/g, "_") + .slice(0, 64); + const writes = []; + for (const key of imagekeys) { + if (typeof itm[key] !== "string") { + return { + status: 422, + ref: "Odmdb", + msg: "imageinvalid", + data: { field: key }, + }; + } + const match = itm[key].match(allowed); + if (!match) { + return { + status: 422, + ref: "Odmdb", + msg: "imageinvalid", + data: { field: key }, + }; + } + const newk = key.slice("imgbase64_".length); + if (!schema.properties[newk]) { + return { + status: 422, + ref: "Odmdb", + msg: "imgpropertiesmissing", + data: { field: key, propertie: newk }, + }; + } + const buffer = Buffer.from(match[2].replace(/\s+/g, ""), "base64"); + if (buffer.length == 0 || buffer.length > 5 * 1024 * 1024) { + return { + status: 422, + ref: "Odmdb", + msg: "imageinvalid", + data: { field: key, size: buffer.length }, + }; + } + if (!validmagic(buffer)) { + return { + status: 422, + ref: "Odmdb", + msg: "imageinvalid", + data: { field: key }, + }; + } + let webp; + try { + webp = await sharp(buffer, { limitInputPixels: 25000000 }) + .rotate() + .webp({ quality: 80 }) + .toBuffer(); + } catch (err) { + return { + status: 422, + ref: "Odmdb", + msg: "imageinvalid", + data: { field: key, error: err.message }, + }; + } + const safek = String(newk) + .replace(/[^A-Za-z0-9_-]/g, "_") + .slice(0, 64); + const filename = `apximg_${safeid}_${safek}.webp`; + const finalpath = path.resolve(imgdir, filename); + if (!finalpath.startsWith(`${imgdir}${path.sep}`)) { + return { + status: 422, + ref: "Odmdb", + msg: "imagepathinvalid", + data: { field: key }, + }; + } + writes.push({ key, newk, filename, webp, finalpath }); + } + fs.ensureDirSync(imgdir); + writes.forEach((write) => { + fs.outputFileSync(write.finalpath, write.webp); + itm[write.newk] = `objectimg/${objectname}/img/${write.filename}`; + delete itm[write.key]; + }); + return { + status: 200, + ref: "Odmdb", + msg: "imagesprepared", + data: { written: writes.map((write) => write.finalpath) }, + }; +}; + Odmdb.cud = (objectPathname, crud, itm, role, runindex = true) => { const getschema = Odmdb.Schema(objectPathname, true); if (getschema.status != 200) return getschema; @@ -637,6 +776,17 @@ Odmdb.cud = (objectPathname, crud, itm, role, runindex = true) => { data: { missingkey: getschema.data.schema.apxid }, }; } + const pendingimages = Object.keys(itm).filter( + (key) => key.startsWith("imgbase64_") && itm[key] !== "" + ); + if (pendingimages.length > 0) { + return { + status: 422, + ref: "Odmdb", + msg: "imgbase64requirespreprocessing", + data: { fields: pendingimages }, + }; + } fs.ensureDirSync(`${objectPathname}/itm/`); fs.ensureDirSync(`${objectPathname}/idx/`); const existid = fs.existsSync( @@ -774,40 +924,6 @@ Odmdb.cud = (objectPathname, crud, itm, role, runindex = true) => { //console.log(k, itmtostore[k], itmtostore[k] === ""); if (itmtostore[k] === "") delete itmtostore[k]; }); - Object.keys(itmtostore).forEach((k) => { - //Manage base64 case image to convert ans store in webp - if (k.includes("imgbase64_") && itmtostore[k] != "") { - fs.ensureDirSync(`${objectPathname}/img/`); - //console.log("check this for k:", k, " itmtostore[k]:", itmtostore[k]); - const imgb64 = itmtostore[k].replace( - /^data:image\/(png|png|gif|bmp|jpg|jpeg);base64,/, - "" - ); //.replace(/\+/g," "); - const extension = itmtostore[k].split(";base64,")[0].split("/")[1]; - const newk = k.split("_").slice(1).join("_"); - const filenameimg = `/img/${ - itmtostore[getschema.data.schema.apxid] - }_${newk}.${extension}`; - if (!getschema.data.schema.properties[newk]) { - if (log) - console.log( - currentmod, - " Check your schema it miss a propertie because imgbase64_blabla must have a blabla propertie to host the pathfile image" - ); - } else { - const objectname = objectPathname.split("/").slice(-1)[0]; - itmtostore[newk] = `objectimg/${objectname}${filenameimg}`; - //const webp = require("webp-converter"); - //const buf = Buffer.from(itmtostore[k], 'base64'); - //const imgwebp = await webp.bufferToWebp(buf); - //@TODO use sharp to resize img with attribut sizeHW in propertie - fs.writeFileSync(`${objectPathname}/${filenameimg}`, imgb64, { - encoding: "base64", - }); - delete itmtostore[k]; - } - } - }); // check consistency of datatostore //console.log("itmstostore::::::", itmtostore); const chkdata = Checkjson.schema.data( @@ -858,7 +974,18 @@ Odmdb.cud = (objectPathname, crud, itm, role, runindex = true) => { * */ Odmdb.runidx = (objectPathname, schema) => { - const starttime=process.hrtime.bigint(); + const starttime=process.hrtime.bigint(); + if ( + !fs.existsSync(objectPathname) || + !fs.lstatSync(objectPathname).isDirectory() + ) { + return { + status: 404, + ref: "Odmdb", + msg: "objectdoesnotexist", + data: { objectPathname }, + }; + } if (!schema || !schema.apxid) { const getschema = Odmdb.Schema(objectPathname, true); if (getschema.status != 200) { @@ -867,6 +994,14 @@ Odmdb.runidx = (objectPathname, schema) => { } schema = getschema.data.schema; } + if (!Array.isArray(schema.apxidx)) { + return { + status: 406, + ref: "Odmdb", + msg: "apxidxmissing", + data: { objectPathname }, + }; + } const ventil = {}; schema.apxidx.forEach((id) => { ventil[id.name] = id; @@ -881,7 +1016,7 @@ Odmdb.runidx = (objectPathname, schema) => { } else { if (!schema.properties[id.keyval]){ console.log(`Check ${objectPathname} index ${id.name} property ${id.keyval} does not exist`) - process.exit(0) + return } ventil[id.name].isunique = schema.apxuniquekey.includes(id.keyval); ventil[id.name].nbobjkey = id.objkey ? id.objkey.length : 0; diff --git a/apxtri/models/Pagans.js b/apxtri/models/Pagans.js index bee72ca..d2540e3 100644 --- a/apxtri/models/Pagans.js +++ b/apxtri/models/Pagans.js @@ -84,6 +84,20 @@ Pagans.keyrecovery = async (emailalias, tribe, search, lg) => { data: { emailalias, tribe, search, lg }, }; } + const searchvalid = + emailalias === "alias" + ? /^[a-z0-9]{3,64}$/.test(String(search)) + : /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test( + String(search), + ); + if (!/^[a-z0-9_-]{1,64}$/.test(String(tribe)) || !searchvalid) { + return { + status: 400, + ref: "Pagans", + msg: "searchinvalid", + data: { emailalias, tribe, search }, + }; + } let emaillist = []; if (emailalias == "email") { search = search.toLowerCase(); @@ -148,6 +162,14 @@ Pagans.keyrecovery = async (emailalias, tribe, search, lg) => { * **/ Pagans.getalias = (alias) => { + if (!/^[a-z0-9]{3,64}$/.test(String(alias))) { + return { + status: 400, + ref: "Pagans", + msg: "aliasinvalid", + data: { alias }, + }; + } //bypass Odmdb cause all is public save ressources console.log(path.resolve(`../adminapi/objects/pagans/itm/${alias}.json`)); if (fs.existsSync(`../adminapi/objects/pagans/itm/${alias}.json`)) { diff --git a/apxtri/models/Security.js b/apxtri/models/Security.js new file mode 100644 index 0000000..b0ebe5f --- /dev/null +++ b/apxtri/models/Security.js @@ -0,0 +1,139 @@ +const crypto = require("crypto"); +const conf = require(`../../../adminapi/objects/tribes/itm/adminapi.json`); + +const Security = {}; + +Security.freeTextFields = [ + "biography", + "title", + "description", + "message", + "html", + "text", + "content", + "comment", + "subject", +]; + +Security.hash = (value) => + crypto + .createHash("sha256") + .update(String(value === undefined ? "" : value)) + .digest("hex") + .slice(0, 16); + +Security.normalize = (value) => { + let normalized = String(value); + for (let i = 0; i < 3; i++) { + try { + const decoded = decodeURIComponent(normalized); + if (decoded === normalized) break; + normalized = decoded; + } catch (err) { + break; + } + } + return normalized.replace(/\\/g, "/"); +}; + +Security.isTraversal = (value) => { + if (typeof value !== "string" || value === "") return false; + const normalized = Security.normalize(value); + if (normalized.includes("\0")) return true; + return normalized + .split("/") + .some((segment) => segment === ".." || segment === "..;"); +}; + +Security.redact = (value, depth = 0) => { + if (depth > 6) return "[depthlimit]"; + if (value === null || value === undefined) return value; + if (typeof value === "string") { + return value.length > 2000 ? `${value.slice(0, 2000)}...[truncated]` : value; + } + if (Array.isArray(value)) { + return value.slice(0, 50).map((item) => Security.redact(item, depth + 1)); + } + if (typeof value === "object") { + const redacted = {}; + Object.keys(value) + .slice(0, 100) + .forEach((key) => { + if (/privatekey|passphrase|xhash|password|secret|token|apikey|smtp|brevo/i.test(key)) { + redacted[key] = "[redacted]"; + } else if (/^imgbase64_/i.test(key)) { + redacted[key] = "[image]"; + } else { + redacted[key] = Security.redact(value[key], depth + 1); + } + }); + return redacted; + } + return value; +}; + +Security.throttlekey = (ip, field, value) => + `${ip}|${field}|${Security.hash(value)}`; + +Security.throttled = new Map(); +Security.emailtimes = []; + +Security.alert = (req, details = {}) => { + const header = (req.session && req.session.header) || {}; + const ip = + req.ip || (req.socket && req.socket.remoteAddress) || "unknown"; + const field = details.field || "unknown"; + const value = + typeof details.value === "string" + ? details.value.slice(0, 500) + : details.value; + const now = Date.now(); + const throttlekey = Security.throttlekey(ip, field, value); + const last = Security.throttled.get(throttlekey) || 0; + if (now - last < 10 * 60 * 1000) return; + Security.throttled.set(throttlekey, now); + if (Security.throttled.size > 5000) { + Security.throttled.forEach((ts, key) => { + if (now - ts > 10 * 60 * 1000) Security.throttled.delete(key); + }); + } + + const entry = { + at: new Date(now).toISOString(), + method: req.method, + url: req.originalUrl, + ip, + xtribe: header.xtribe, + xalias: header.xalias, + field, + value, + body: Security.redact(req.body), + }; + console.log("ALERT security", JSON.stringify(entry)); + + const emailalertsecu = + conf && typeof conf.emailalertsecu === "string" + ? conf.emailalertsecu.trim() + : ""; + if (!emailalertsecu) return; + Security.emailtimes = Security.emailtimes.filter( + (ts) => now - ts < 60 * 60 * 1000 + ); + if (Security.emailtimes.length >= 30) return; + Security.emailtimes.push(now); + + const Notifications = require("./Notifications.js"); + Notifications.sendmail( + { + to: emailalertsecu, + subject: `[apXtri security] ${field} - ${req.method} ${req.originalUrl}`, + text: JSON.stringify(entry, null, 2), + html: `
${JSON.stringify(entry, null, 2)}
`, + }, + "adminapi" + ).catch((err) => { + console.log("ALERT security email failed", err.message); + }); +}; + +module.exports = Security; diff --git a/apxtri/models/Wwws.js b/apxtri/models/Wwws.js index 2fe00fc..c7ccf94 100644 --- a/apxtri/models/Wwws.js +++ b/apxtri/models/Wwws.js @@ -16,6 +16,24 @@ const log = process.env.ACTIVELOG ? process.env.ACTIVELOG.split(',').includes(cu const Wwws = {}; Wwws.getwco = (wconame, ctx) => { + const safename = (value) => + typeof value === "string" && /^[A-Za-z0-9_.:-]{1,64}$/.test(value); + const tagids = ctx.tagid ? String(ctx.tagid).split(",") : []; + if ( + !safename(wconame) || + !safename(ctx.tribe) || + !safename(ctx.wcotribe) || + !safename(ctx.xapp) || + !safename(ctx.pagename) || + tagids.some((tagid) => !safename(tagid)) + ) { + return { + status: 400, + ref: "Wwws", + msg: "queryinvalid", + data: { wconame, query: ctx }, + }; + } /** * In a webpage to add a wco * @@ -486,6 +504,22 @@ Wwws.initlocalwco = (tribwco, profils, lg) => { }; Wwws.initlocaldata = (tribe, appname, pagename, version, profils, lg) => { + const safename = (value) => + typeof value === "string" && /^[A-Za-z0-9_.:-]{1,64}$/.test(value); + if ( + !safename(tribe) || + !safename(appname) || + !safename(pagename) || + !/^[A-Za-z]{2}$/.test(String(lg)) || + !/^\d{1,16}$/.test(String(version)) + ) { + return { + status: 400, + ref: "Wwws", + msg: "paraminvalid", + data: { tribe, appname, pagename, version, lg }, + }; + } const fileparam = `../${tribe}/objects/wwws/itm/${appname}.json`; //console.log(path.resolve(fileparam)); if (!fs.existsSync(fileparam)) { diff --git a/apxtri/routes/notifications.js b/apxtri/routes/notifications.js index f279c16..b35b172 100644 --- a/apxtri/routes/notifications.js +++ b/apxtri/routes/notifications.js @@ -1,9 +1,13 @@ const express = require("express"); +const crypto = require("crypto"); const fs = require("fs-extra"); const path = require("path"); // Classes const Notifications = require("../models/Notifications.js"); +const emailpattern = + /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + // Middlewares const checkHeaders = require("../middlewares/checkHeaders"); const isAuthenticated = require("../middlewares/isAuthenticated"); @@ -26,7 +30,7 @@ const router = express.Router(); **/ router.get("/messages/:alias/:tribeId", (req, res) => { const getnot = Notifications.get(req.params.alias, req.params.tribeId); - res.status(getalias.status).send(getalias); + res.status(getnot.status).json(getnot); }); /** @@ -119,6 +123,34 @@ router.post("/registeranonymous", checkHeaders, (req, res) => { data: { typekey: req.body.typekey }, }); } + const allowedtribes = [ + "adminapi", + ...(Array.isArray(req.app.locals.tribeids) ? req.app.locals.tribeids : []), + ]; + if (!allowedtribes.includes(req.body.tribe)) { + return res.status(404).json({ + status: 404, + ref: "Notifications", + msg: "tribeiddoesnotexist", + data: { tribe: req.body.tribe }, + }); + } + if (!/^[A-Za-z0-9_.-]{1,64}$/.test(req.body.mlist || "")) { + return res.status(400).json({ + status: 400, + ref: "Notifications", + msg: "mlistinvalid", + data: { mlist: req.body.mlist }, + }); + } + if (!/^[A-Za-z0-9_.-]{1,64}$/.test(req.body.srckey || "")) { + return res.status(400).json({ + status: 400, + ref: "Notifications", + msg: "srckeyinvalid", + data: { srckey: req.body.srckey }, + }); + } const key = req.body.contactpoint ? req.body.contactpoint : req.body[req.body.typekey]; @@ -201,7 +233,18 @@ router.get( * */ router.get("/stat/maillinglst/:tribe", checkHeaders, async (req, res) => { - console.log("passe là"); + const allowedtribes = [ + "adminapi", + ...(Array.isArray(req.app.locals.tribeids) ? req.app.locals.tribeids : []), + ]; + if (!allowedtribes.includes(req.params.tribe)) { + return res.status(404).json({ + status: 404, + ref: "Notifications", + msg: "tribeiddoesnotexist", + data: { tribe: req.params.tribe }, + }); + } const resstat = Notifications.statmaillist(req.params.tribe); res.status(resstat.status).json(resstat); }); @@ -270,8 +313,19 @@ router.get("/contact", checkHeaders, isAuthenticated, (req, res) => { */ router.get("/checkemail/:email/:digit6", checkHeaders, async (req, res) => { const { email, digit6 } = req.params; + if (!emailpattern.test(email)) { + return res.status(400).json({ + status: 400, + ref: "Notifications", + msg: "emailinvalid", + data: { email }, + }); + } const tmpDir = path.join(__dirname, "../../tmp"); - const tmpFile = path.join(tmpDir, email); + const tmpFile = path.join( + tmpDir, + `${crypto.createHash("sha256").update(email).digest("hex")}.txt` + ); if (digit6 === "0") { const code = email.includes("smatchit.io") ? "789000" : Math.floor(100000 + Math.random() * 900000).toString(); diff --git a/apxtri/routes/odmdb.js b/apxtri/routes/odmdb.js index acd2787..80dfb56 100644 --- a/apxtri/routes/odmdb.js +++ b/apxtri/routes/odmdb.js @@ -82,7 +82,7 @@ router.get( checkHeaders, isAuthenticated, (req, res) => { - const objectPathname = `../${req.params.tribe}/objects/options/${req.params.optionname}_${req.header.xlang}.json`; + const objectPathname = `../${req.params.tribe}/objects/options/${req.params.optionname}_${req.session.header.xlang}.json`; if (fs.existsSync(objectPathname)) { res.status(200).json({ status: 200, @@ -293,14 +293,24 @@ router.post( "/itm/:tribe/:objectname", checkHeaders, isAuthenticated, - (req, res) => { + async (req, res) => { // Create an item of an object with no specificities // if specificities then create a route / model that import odmdb const objectPathname = `../${req.params.tribe}/objects/${req.params.objectname}`; + const prepared = await Odmdb.prepareUploadedImages( + objectPathname, + req.body + ); + if (prepared.status != 200) { + return res.status(prepared.status).json(prepared); + } const postitm = Odmdb.cud(objectPathname, "C", req.body, { xprofils: req.session.header.xprofils, xalias: req.session.header.xalias, }); + if (postitm.status != 200 && prepared.data.written.length > 0) { + prepared.data.written.forEach((file) => fs.removeSync(file)); + } res.status(postitm.status).json(postitm); } ); @@ -336,14 +346,24 @@ router.put( "/itm/:tribe/:objectname", checkHeaders, isAuthenticated, - (req, res) => { + async (req, res) => { // Create an item of an object with no specificities // if specificities then create a route / model that import odmdb const objectPathname = `../${req.params.tribe}/objects/${req.params.objectname}`; + const prepared = await Odmdb.prepareUploadedImages( + objectPathname, + req.body + ); + if (prepared.status != 200) { + return res.status(prepared.status).json(prepared); + } const postitm = Odmdb.cud(objectPathname, "U", req.body, { xprofils: req.session.header.xprofils, xalias: req.session.header.xalias, }); + if (postitm.status != 200 && prepared.data.written.length > 0) { + prepared.data.written.forEach((file) => fs.removeSync(file)); + } res.status(postitm.status).json(postitm); } ); @@ -761,7 +781,7 @@ router.put( isAuthenticated, (req, res) => { const fullpath = path.resolve( - `${__dirname}/tribes/${req.session.header.xworkon}/schema/${req.params.pathobjectname}.json` + `${__dirname}/tribes/${req.session.header.xworkon}/schema/${req.params.objectname}.json` ); const set = Odmdb.setObject( path.resolve(`${__dirname}/tribes/${req.session.header.xworkon}`) diff --git a/apxtri/routes/pagans.js b/apxtri/routes/pagans.js index 62b52bf..b7d2219 100755 --- a/apxtri/routes/pagans.js +++ b/apxtri/routes/pagans.js @@ -142,6 +142,31 @@ router.post("/", checkHeaders, isAuthenticated, async (req, res) => { }); return; } + if ( + !/^[a-z0-9]{3,64}$/.test(req.body.alias || "") || + req.body.alias !== req.session.header.xalias + ) { + res.status(400).json({ + status: 400, + ref: "Pagans", + msg: "aliasinvalid", + data: { alias: req.body.alias, xalias: req.session.header.xalias }, + }); + return; + } + const allowedtribes = [ + "adminapi", + ...(Array.isArray(req.app.locals.tribeids) ? req.app.locals.tribeids : []), + ]; + if (!allowedtribes.includes(req.body.trustedtribe)) { + res.status(404).json({ + status: 404, + ref: "Pagans", + msg: "tribeiddoesnotexist", + data: { tribe: req.body.trustedtribe }, + }); + return; + } const objpagan = { alias: req.body.alias, publickey: req.body.publickey }; console.log(path.resolve(`../adminapi/objects/pagans`)); const newpagan = Odmdb.cud(`../adminapi/objects/pagans`, "C", objpagan, role); @@ -219,6 +244,14 @@ router.post("/", checkHeaders, isAuthenticated, async (req, res) => { * Delete an alias and his publickey, this mean that publickey disapear as well as alias. We set dt_delete * */ router.delete("/alias/:alias", checkHeaders, isAuthenticated, (req, res) => { + if (req.params.alias !== req.session.header.xalias) { + return res.status(403).json({ + status: 403, + ref: "Pagans", + msg: "profilnotallow", + data: { alias: req.params.alias, xalias: req.session.header.xalias }, + }); + } const personpath = `../adminapi/objects/pagans`; const role = { xalias: req.session.header.xalias, @@ -263,6 +296,17 @@ router.delete( checkHeaders, isAuthenticated, (req, res) => { + const privileged = + req.session.header.xprofils.includes("druid") || + req.session.header.xprofils.includes("mayor"); + if (req.params.alias !== req.session.header.xalias && !privileged) { + return res.status(403).json({ + status: 403, + ref: "Pagans", + msg: "profilnotallow", + data: { alias: req.params.alias, xalias: req.session.header.xalias }, + }); + } const personpath = `../${req.params.tribe}/objects/persons`; const role = { xalias: req.session.header.xalias, @@ -331,8 +375,30 @@ router.get("/person/:alias", checkHeaders, isAuthenticated, (req, res) => { * @apiBody {string} [addprofils] must be alone no other Body field * @apiBody {string} [removeprofils] must be alone */ -router.put("/person/:tribe", checkHeaders, isAuthenticated, (req, res) => { +router.put("/person/:tribe", checkHeaders, isAuthenticated, async (req, res) => { //console.log(req.body); + if (!req.body || !/^[a-z0-9]{3,64}$/.test(req.body.alias || "")) { + return res.status(400).json({ + status: 400, + ref: "Pagans", + msg: "aliasinvalid", + data: { alias: req.body && req.body.alias }, + }); + } + const privileged = + req.session.header.xprofils.includes("druid") || + req.session.header.xprofils.includes("mayor"); + if ( + req.body.alias !== req.session.header.xalias && + !privileged + ) { + return res.status(403).json({ + status: 403, + ref: "Pagans", + msg: "profilnotallow", + data: { alias: req.body.alias, xalias: req.session.header.xalias }, + }); + } const pathobj = `../${req.params.tribe}/objects/persons`; const action = fs.existsSync(`${pathobj}/itm/${req.body.alias}.json`) ? "U" @@ -386,10 +452,17 @@ router.put("/person/:tribe", checkHeaders, isAuthenticated, (req, res) => { if (!req.body.profils) { req.body.profils = ["anonymous", "pagans", "persons"]; } + const prepared = await Odmdb.prepareUploadedImages(pathobj, req.body); + if (prepared.status != 200) { + return res.status(prepared.status).json(prepared); + } const personup = Odmdb.cud(pathobj, action, req.body, { xprofils: req.session.header.xprofils, xalias: req.session.header.xalias, }); + if (personup.status != 200 && prepared.data.written.length > 0) { + prepared.data.written.forEach((file) => fs.removeSync(file)); + } if (log) console.log(currentmod, " personupdate or create:", personup); res.status(personup.status).json(personup); } @@ -415,6 +488,18 @@ router.put("/person/:tribe", checkHeaders, isAuthenticated, (req, res) => { * */ router.post("/keyrecovery", checkHeaders, async (req, res) => { + const allowedtribes = [ + "adminapi", + ...(Array.isArray(req.app.locals.tribeids) ? req.app.locals.tribeids : []), + ]; + if (!allowedtribes.includes(req.body.tribe)) { + return res.status(404).json({ + status: 404, + ref: "Pagans", + msg: "tribeiddoesnotexist", + data: { tribe: req.body.tribe }, + }); + } try { const recores = await Pagans.keyrecovery( req.body.emailalias, diff --git a/apxtri/routes/trackings.js b/apxtri/routes/trackings.js index c18de50..6d07ea8 100644 --- a/apxtri/routes/trackings.js +++ b/apxtri/routes/trackings.js @@ -33,6 +33,27 @@ const router = express.Router(); */ router.post("/newdevice", checkHeaders, async (req, res) => { if (log) console.log(currentmod, "post new device with", req.body); + const xuuid = req.session.header.xuuid; + if ( + !/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( + xuuid + ) + ) { + return res.status(400).json({ + status: 400, + ref: "Trackings", + msg: "xuuidinvalid", + data: { xuuid }, + }); + } + if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) { + return res.status(400).json({ + status: 400, + ref: "Trackings", + msg: "bodyinvalid", + data: {}, + }); + } const newdevice = { status: 200, ref: "Trackings", msg: "success", data: {} }; const role = { xalias: req.session.header.xalias, @@ -57,9 +78,9 @@ router.post("/newdevice", checkHeaders, async (req, res) => { confdevice ); } - const devicepath = `../${req.session.header.xtribe}/objects/devices/itm/${req.session.header.xuuid}.json`; + const devicepath = `../${req.session.header.xtribe}/objects/devices/itm/${xuuid}.json`; const device = req.body; - device.xuuid = req.session.header.xuuid; + device.xuuid = xuuid; if (req.session.header.xalias != "anonymous") { device.alias = req.session.header.xalias; } diff --git a/apxtri/routes/tribes.js b/apxtri/routes/tribes.js index 6fdb367..df95961 100755 --- a/apxtri/routes/tribes.js +++ b/apxtri/routes/tribes.js @@ -11,6 +11,32 @@ const checkHeaders = require( '../middlewares/checkHeaders' ); const isAuthenticated = require( '../middlewares/isAuthenticated' ); const router = express.Router(); +const disabledroutes = [ + '/config', + '/www', + '/actionanonyme', + '/action', + '/clientconf', + '/spaceweb', + '/plugins', + '/dirls', + '/ls', + '/sendjson', + '/downloadls', + '/upfilepond', + '/file', + '/uploadfile', + '/uploadzip', + '/upload', +]; +router.use( ( req, res, next ) => { + if( disabledroutes.some( p => req.path === p || req.path.startsWith( `${p}/` ) ) ) { + return res.status( 410 ) + .json( { status: 410, ref: 'Tribes', msg: 'routedisabled' } ); + } + next(); +} ); + /** * @api {get} adminapi/tribes/conf/:tribe - tribe list diff --git a/apxtri/routes/wwws.js b/apxtri/routes/wwws.js index 0ad71b6..30ce556 100644 --- a/apxtri/routes/wwws.js +++ b/apxtri/routes/wwws.js @@ -53,10 +53,32 @@ router.get("/getwco/:wconame", (req, res) => { missquery+=q+" " } }) + const namepattern = /^[A-Za-z0-9_.:-]{1,64}$/; + const allowedtribes = [ + "adminapi", + ...(Array.isArray(req.app.locals.tribeids) ? req.app.locals.tribeids : []), + ]; + const wconame = req.params.wconame.replace(/\.js$/, ""); + const tagids = req.query.tagid ? String(req.query.tagid).split(",") : []; + const invalid = + !namepattern.test(wconame) || + !allowedtribes.includes(req.query.tribe) || + !allowedtribes.includes(req.query.wcotribe) || + !namepattern.test(req.query.xapp || "") || + !namepattern.test(req.query.pagename || "") || + String(req.query.code || "").length > 128 || + tagids.some((tagid) => !namepattern.test(tagid)); if (missquery!=""){ res.status(403).json({status:403,ref:"Wwws",msg:"missquery",data:{missquery}}) + }else if (invalid) { + res.status(400).json({ + status: 400, + ref: "Wwws", + msg: "queryinvalid", + data: { wconame, query: req.query }, + }); }else{ - const getwco=Wwws.getwco(req.params.wconame.replace(/\.js$/,''),req.query) + const getwco=Wwws.getwco(wconame,req.query) if (getwco.status==200){ res.set('Content-Type', 'application/javascript'); res.sendFile(getwco.data.file) diff --git a/apxtri/setup/cleanupimages.js b/apxtri/setup/cleanupimages.js new file mode 100644 index 0000000..eadab76 --- /dev/null +++ b/apxtri/setup/cleanupimages.js @@ -0,0 +1,137 @@ +#!/usr/bin/env node +const fs = require("fs-extra"); +const path = require("path"); +const glob = require("glob"); + +const args = process.argv.slice(2); +const deletefiles = args.includes("--delete"); +const quarantine = args.includes("--quarantine"); +const dryrun = !deletefiles && !quarantine; +const adminapiroot = path.resolve(__dirname, "../.."); +const townroot = path.resolve(adminapiroot, ".."); +const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); +const quarantinedir = path.join( + adminapiroot, + "tmp", + "quarantine", + timestamp +); +const imageext = [".webp", ".png", ".jpg", ".jpeg", ".gif"]; +const imagelabel = /_(imgavatar|backgroundimage|companylogo)\./i; + +const readhead = (file) => { + const fd = fs.openSync(file, "r"); + const buffer = Buffer.alloc(12); + fs.readSync(fd, buffer, 0, 12, 0); + fs.closeSync(fd); + return buffer; +}; + +const validmagic = (buffer) => { + if (buffer.length < 12) return false; + const ispng = + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 && + buffer[4] === 0x0d && + buffer[5] === 0x0a && + buffer[6] === 0x1a && + buffer[7] === 0x0a; + const isjpeg = + buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; + const gif = buffer.slice(0, 6).toString(); + const isgif = gif === "GIF87a" || gif === "GIF89a"; + const iswebp = + buffer.slice(0, 4).toString() === "RIFF" && + buffer.slice(8, 12).toString() === "WEBP"; + return ispng || isjpeg || isgif || iswebp; +}; + +const objectdirs = []; +glob + .sync(`${townroot}/*/objects`, { nodir: false, follow: false }) + .forEach((dir) => { + if (!objectdirs.includes(dir)) objectdirs.push(dir); + }); +const adminobjects = path.join(adminapiroot, "objects"); +if (fs.existsSync(adminobjects) && !objectdirs.includes(adminobjects)) { + objectdirs.push(adminobjects); +} + +const flagged = []; +objectdirs.forEach((objectdir) => { + if (!fs.existsSync(objectdir)) return; + glob + .sync("**/*", { cwd: objectdir, nodir: true, dot: false, follow: false }) + .forEach((rel) => { + if (/(^|\/)(node_modules|tmp|logs|\.git)(\/|$)/.test(rel)) return; + const file = path.join(objectdir, rel); + let stat; + try { + stat = fs.lstatSync(file); + } catch (err) { + return; + } + if (!stat.isFile() || stat.isSymbolicLink()) return; + const base = path.basename(file); + const dirparts = rel.split(path.sep); + const top = dirparts[0]; + const inobjectimg = dirparts.length >= 3 && dirparts[1] === "img"; + const ext = path.extname(base).toLowerCase(); + let reason = ""; + if (base.startsWith("zz_")) { + reason = "zzartifact"; + } else if (["wwws", "apidoc"].includes(top)) { + return; + } else if (!inobjectimg && imagelabel.test(base)) { + reason = "imageoutsideimg"; + } else if (inobjectimg && !imageext.includes(ext)) { + reason = "extensionnotallowed"; + } else if ( + inobjectimg && + imageext.includes(ext) && + !validmagic(readhead(file)) + ) { + reason = "invalidmagic"; + } + if (reason) { + flagged.push({ file, reason, rel, objectdir }); + } + }); +}); + +if (flagged.length == 0) { + console.log("No suspicious image found."); + process.exit(0); +} + +if (!dryrun) fs.ensureDirSync(quarantinedir); + +flagged.forEach((item) => { + console.log( + `${dryrun ? "WOULD QUARANTINE" : deletefiles ? "DELETE" : "QUARANTINE"} [${item.reason}] ${item.file}` + ); + if (dryrun) return; + if (deletefiles) { + fs.removeSync(item.file); + return; + } + const dest = path.resolve( + quarantinedir, + path.relative(townroot, item.file) + ); + if (!dest.startsWith(`${quarantinedir}${path.sep}`)) { + console.log(`skip unsafe quarantine path: ${dest}`); + return; + } + fs.moveSync(item.file, dest, { overwrite: true }); +}); + +if (!dryrun) { + fs.ensureDirSync(quarantinedir); + fs.outputJSONSync(path.join(quarantinedir, "report.json"), flagged, { + spaces: 2, + }); + console.log(`Report written to ${path.join(quarantinedir, "report.json")}`); +} diff --git a/apxtri/setup/initadminapi.json b/apxtri/setup/initadminapi.json index 21c8041..9953351 100644 --- a/apxtri/setup/initadminapi.json +++ b/apxtri/setup/initadminapi.json @@ -27,7 +27,7 @@ "xtrkversion" ], "appset": { - "trust proxy": true + "trust proxy": 1 }, "json": { "limit": "10mb", @@ -49,6 +49,7 @@ "worker_connections": 1024 }, "emailcontact": "", + "emailalertsecu": "", "smtp": {}, "sms": {} } \ No newline at end of file diff --git a/apxtri/setup/nginx.wwwscf b/apxtri/setup/nginx.wwwscf index 2a67dda..9cc7782 100644 --- a/apxtri/setup/nginx.wwwscf +++ b/apxtri/setup/nginx.wwwscf @@ -56,6 +56,8 @@ location /api/ { proxy_pass http://localhost:{{{api.port}}}; proxy_redirect off; include proxy_params; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; } location /socket/ { proxy_pass http://127.0.0.1:{{socket.port}}; diff --git a/schema/persons.json b/schema/persons.json index 5d8bb59..1a09dd6 100644 --- a/schema/persons.json +++ b/schema/persons.json @@ -9,6 +9,7 @@ "title": "alias", "description": "A unique string identifying a unique public key", "type": "string", + "pattern": "^[a-z0-9]{3,64}$", "options": { "$ref": "adminapi/objects/pagans/idx/lst_alias" } @@ -17,6 +18,7 @@ "title": "Owner of this person", "description": "For accessright purpose this is always equal as alias", "type": "string", + "pattern": "^[a-z0-9]{3,64}$", "options": { "$ref": "adminapi/objects/pagans/idx/lst_alias" }