Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/node/db/API.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,13 @@ exports.appendChatMessage = async (padID: string, text: string|object, authorID:
time = Date.now();
}

// @TODO - missing getPadSafe() call ?
// Reject messages addressed to a pad that doesn't exist. Without this check
// the downstream padManager.getPad() would create the pad on demand with
// default content, so the documented {code:1,"padID does not exist"} result
// would never be returned.
if (!await padManager.doesPadExists(padID)) {
throw new CustomError('padID does not exist', 'apierror');
}

// save chat message to database and send message to all connected clients
await padMessageHandler.sendChatMessageToPadClients(new ChatMessage(text, authorID, time), padID);
Expand Down
10 changes: 9 additions & 1 deletion src/node/handler/RestAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1471,7 +1471,15 @@ export const expressCreateServer = async (hookName: string, {app}: ArgsExpressTy
}
}

const fields = Object.assign({}, headers, params, query, formData);
// Merge with clear precedence: body > query > path params, matching the
// openapi.ts handler. Forward only the authorization header explicitly
// instead of merging all request headers into the field set.
const fields = Object.assign({}, params, query, formData);
if (headers && headers.authorization) {
// Match the openapi.ts handler: fall back to the header whenever the
// field value is falsy (absent or empty), not only when it is null.
fields.authorization = fields.authorization || headers.authorization;
}

if (mapping.has(method) && pathToFunction in mapping.get(method)!) {
const {apiVersion, functionName} = mapping.get(method)![pathToFunction]!
Expand Down
5 changes: 4 additions & 1 deletion src/node/hooks/express/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,11 @@ exports.expressCreateServer = (hookName: string, args: ArgsExpressType, cb: Func
// read file from file system
fs.readFile(pathname, function (err, data) {
if (err) {
// Log the detailed error server-side; return a generic message to the
// client rather than echoing the filesystem error.
console.error(`admin: error reading ${pathname}: ${err}`);
res.statusCode = 500;
res.end(`Error getting the file: ${err}.`);
res.end('Error getting the file.');
} else {
let dataToSend:Buffer|string = data
// if the file is found, set Content-type and send data
Expand Down
42 changes: 32 additions & 10 deletions src/node/security/OAuth2Provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ import express from 'express';
import {format} from 'url'
import {ParsedUrlQuery} from "node:querystring";
import {MapArrayType} from "../types/MapType";
import crypto from "node:crypto";

// Small fixed delay applied to every failed interactive login, mirroring
// webaccess.authnFailureDelayMs, so failures take a consistent amount of time.
const OAUTH_LOGIN_FAILURE_DELAY_MS = 1000;

// Constant-time string comparison using crypto.timingSafeEqual. Unequal-length
// inputs short-circuit to false (that length difference is covered by the
// uniform failure delay below). The raw bytes are compared directly — the
// values are not hashed/stored, this only avoids a content-dependent compare.
const constantTimeEquals = (a: string, b: string): boolean => {
const ba = Buffer.from(String(a), 'utf8');
const bb = Buffer.from(String(b), 'utf8');
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
};

const configuration: Configuration = {
scopes: ['openid', 'profile', 'email'],
Expand Down Expand Up @@ -171,21 +186,28 @@ export const expressCreateServer = async (hookName: string, args: ArgsExpressTyp
admin: boolean;
}
}
const usersArray1 = Object.keys(users).map((username) => ({
username,
...users[username]
}));
const account = usersArray1.find((user) => user.username === login as unknown as string && user.password === password as unknown as string);
const loginStr = String(login ?? '');
const passwordStr = String(password ?? '');
// Look up by own property only, then compare the password
// with constantTimeEquals.
const user = Object.prototype.hasOwnProperty.call(users, loginStr)
? users[loginStr] : undefined;
const passwordOk = user != null &&
constantTimeEquals(passwordStr, String(user.password));
const account = passwordOk ? {username: loginStr, ...user} : undefined;
if (!account) {
// Apply the failure delay and stop here (explicit break)
// so a failed login never reaches the grant branch.
await new Promise((resolve) =>
setTimeout(resolve, OAUTH_LOGIN_FAILURE_DELAY_MS));
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({error: "Invalid login"}));
break;
}

if (account) {
await oidc.interactionFinished(req, res, {
login: {accountId: account.username}
}, {mergeWithLastSubmission: false});
}
await oidc.interactionFinished(req, res, {
login: {accountId: account.username}
}, {mergeWithLastSubmission: false});
break;
}
case 'consent': {
Expand Down
19 changes: 15 additions & 4 deletions src/static/js/pad_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,22 @@ import jsCookie, {CookiesStatic} from 'js-cookie'
*/
export const randomString = (len?: number) => {
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
let randomstring = '';
len = len || 20;
for (let i = 0; i < len; i++) {
const rnum = Math.floor(Math.random() * chars.length);
randomstring += chars.substring(rnum, rnum + 1);
// Generate these IDs from crypto.getRandomValues (a CSPRNG) rather than
// Math.random. getRandomValues is available in browsers and in Node >= 20
// (Node 24 is required) and, unlike crypto.subtle, needs no secure context.
// Rejection-sample to the largest multiple of chars.length (62*4=248) to
// avoid modulo bias.
const maxUnbiased = 256 - (256 % chars.length); // 248
let randomstring = '';
while (randomstring.length < len) {
const bytes = new Uint8Array(len - randomstring.length);
globalThis.crypto.getRandomValues(bytes);
for (const b of bytes) {
if (b >= maxUnbiased) continue; // drop biased samples, keep going
randomstring += chars[b % chars.length];
if (randomstring.length === len) break;
}
}
return randomstring;
};
Expand Down
17 changes: 17 additions & 0 deletions src/static/js/pluginfw/LinkInstaller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,20 @@ export class LinkInstaller {
}
}

// A dependency name comes from a plugin's package.json and is used to build
// filesystem paths (readFileSync / symlinkSync), so validate it is a plain npm
// package name (optional scope, no path separators or "..") before use.
private isValidDependencyName(dependency: string): boolean {
return typeof dependency === 'string' &&
!dependency.includes('..') &&
/^(@[a-z0-9-._~]+\/)?[a-z0-9-._~]+$/i.test(dependency);
}

private async addSubDependency(plugin: string, dependency: string) {
if (!this.isValidDependencyName(dependency)) {
console.error(`Skipping plugin dependency with invalid name: ${dependency}`)
return
}
if (this.dependenciesMap.has(dependency)) {
// We already added the sub dependency
this.dependenciesMap.get(dependency)?.add(plugin)
Expand All @@ -201,6 +214,10 @@ export class LinkInstaller {
}

private linkDependency(dependency: string) {
if (!this.isValidDependencyName(dependency)) {
console.error(`Skipping plugin dependency with invalid name: ${dependency}`)
return
}
try {
// Check if the dependency is already installed
accessSync(path.join(node_modules, dependency), constants.F_OK)
Expand Down
Loading