mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 02:52:33 -05:00
A grain has no network but the bridge proxy, so an outgoing webhook now leaves only through an API a board admin approved in the Powerbox for that webhook's address: each saved webhook offers "Allow this grain to reach this address". The grant belongs to the destination, not to a person, is found by the URL being sent to, and is approved as the address's parent path so the bridge delivers to the exact address. Every sender - immediate, grouped and stored - goes through webhookFetch, which off Sandstorm, or for an address no grant covers, is exactly the SSRF-guarded fetchSafe. The REST API called with a Sandstorm API key acts as the key's user: in a grain the bridge sets X-Sandstorm-User-Id itself. Only in a grain, only after a WeKan token, only for an existing enabled account, and a key without participate reads only. New strings translated in all languages. Thanks to xet7 !
222 lines
10 KiB
JavaScript
222 lines
10 KiB
JavaScript
// API middleware: body parsing, auth token handling, API gate, and JSON response helper.
|
|
// Replaces communitypackages:json-routes middleware chain.
|
|
// Must be imported before model files that register API routes.
|
|
|
|
const { Meteor } = require('meteor/meteor');
|
|
const { WebApp } = require('meteor/webapp');
|
|
const { safeJsonStringify } = require('/server/lib/apiResponseHelpers');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. Body parsing (previously registered by json-routes)
|
|
// ---------------------------------------------------------------------------
|
|
// Reuse Meteor's Express parsers. Rspack 2 no longer brings body-parser into
|
|
// the app's node_modules through its development server dependencies.
|
|
// A bounce webhook's signature is over the bytes as they were sent (SendGrid,
|
|
// Resend), so for those paths the raw body is kept beside the parsed one.
|
|
const keepRawBody = (req, res, buf) => {
|
|
if (/^\/api\/inbound-email\/bounces\//.test(String(req.url || ''))) req.rawBody = buf.toString('utf8');
|
|
};
|
|
WebApp.handlers.use(WebApp.express.urlencoded({ limit: '50mb', extended: false, verify: keepRawBody }));
|
|
WebApp.handlers.use(WebApp.express.json({ limit: '50mb', verify: keepRawBody }));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. API gate — check WITH_API env var (previously in models/users.js)
|
|
// ---------------------------------------------------------------------------
|
|
// Express matches routes case-insensitively and on the decoded path, so the
|
|
// gate must too: with WITH_API off, /API/... and /%61pi/... reached the API
|
|
// (2026-10-02).
|
|
function isApiPath(url) {
|
|
let pathname = String(url || '').split('?')[0];
|
|
try { pathname = decodeURIComponent(pathname); } catch (e) { /* keep it raw */ }
|
|
return /^\/api(\/|$)/i.test(pathname);
|
|
}
|
|
|
|
// The inbound email endpoints are not the REST API: a mail provider (or Amazon
|
|
// SES through SNS) posts email to them, and they have gates of their own - the
|
|
// Admin Panel's email-to-card switch and webhook secret, the reply address's
|
|
// signature, SNS's message signature (server/routes/inboundEmail.js,
|
|
// server/emailBounces.js). Email to card must not need the whole REST API
|
|
// turned on, so these exact POST paths pass the WITH_API gate.
|
|
const INBOUND_EMAIL_PATHS = ['/api/inbound-email', '/api/inbound-email/ses'];
|
|
// The bounce webhooks of the other sending services (server/emailBounces.js).
|
|
const BOUNCE_WEBHOOK_PATH = /^\/api\/inbound-email\/bounces\/(mailgun|sendgrid|postmark|brevo|mailjet|sparkpost|mandrill|resend)$/;
|
|
function isInboundEmailPath(req) {
|
|
if (req.method !== 'POST') return false;
|
|
let pathname = String(req.url || '').split('?')[0];
|
|
try { pathname = decodeURIComponent(pathname); } catch (e) { return false; }
|
|
pathname = pathname.replace(/\/+$/, '');
|
|
return INBOUND_EMAIL_PATHS.includes(pathname) || BOUNCE_WEBHOOK_PATH.test(pathname);
|
|
}
|
|
|
|
WebApp.handlers.use(function apiGate(req, res, next) {
|
|
const api = isApiPath(req.url);
|
|
// CacheBleed, API sibling (2026-10-02): every API answer is the caller's -
|
|
// exports, attachment downloads as base64, board data - and none said so, so
|
|
// a shared cache was free to keep one by heuristics. A route may still set
|
|
// its own policy.
|
|
if (api) res.setHeader('Cache-Control', 'no-store');
|
|
if ((api && process.env.WITH_API === 'true') || !api || isInboundEmailPath(req)) {
|
|
return next();
|
|
}
|
|
// REFUSED, IN WORDS - not a redirect to the front page.
|
|
//
|
|
// This used to answer `301 Location: /`, and every export in the interface is
|
|
// a download from an `/api/...` address: a board or a card to PDF, Excel,
|
|
// JSON, .zip, CSV. The browser followed the redirect, got WeKan's own HTML
|
|
// page, and wrote it to the file the download link had named - so "the API is
|
|
// off" arrived as a PDF that no reader could open, with nothing anywhere
|
|
// saying why. A 403 with a sentence in it cannot be mistaken for the file
|
|
// that was asked for, and it says what to change.
|
|
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
return res.end(
|
|
'WeKan API is disabled, so this address answers nothing.\n'
|
|
+ '\n'
|
|
+ 'Exports use it too - a board or card exported to PDF, Excel, JSON, .zip\n'
|
|
+ 'or CSV is a download from /api/..., so with the API off none of them can\n'
|
|
+ 'work. Set the environment variable WITH_API=true and restart WeKan.\n',
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. Bearer token parser (replaces communitypackages:rest-bearer-token-parser)
|
|
// ---------------------------------------------------------------------------
|
|
WebApp.handlers.use(function parseBearerToken(req, res, next) {
|
|
// Check Authorization header first
|
|
const authHeader = req.headers.authorization;
|
|
if (authHeader) {
|
|
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
if (match) {
|
|
req.authToken = match[1];
|
|
}
|
|
}
|
|
|
|
// Fallback to access_token query param
|
|
if (!req.authToken && req.query && req.query.access_token) {
|
|
req.authToken = req.query.access_token;
|
|
}
|
|
|
|
next();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4. User authentication (replaces communitypackages:authenticate-user-by-token)
|
|
// ---------------------------------------------------------------------------
|
|
WebApp.handlers.use(async function authenticateByToken(req, res, next) {
|
|
if (req.authToken) {
|
|
try {
|
|
const user = await require('/server/lib/activeUser').activeUserByToken(req.authToken, 'apiMiddleware', req);
|
|
if (user) {
|
|
req.userId = user._id;
|
|
}
|
|
} catch (e) {
|
|
// Ignore auth errors — routes handle missing userId themselves
|
|
}
|
|
}
|
|
next();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4a. Sandstorm API keys: in a grain, the bridge says who is calling
|
|
// (models/lib/sandstormApiIdentity.js). Never off Sandstorm, never in place of
|
|
// a WeKan token, and only for an account that already exists and is enabled.
|
|
// ---------------------------------------------------------------------------
|
|
const isSandstormGrain = !!(Meteor.settings && Meteor.settings.public && Meteor.settings.public.sandstorm);
|
|
WebApp.handlers.use(async function authenticateSandstormApiKey(req, res, next) {
|
|
if (req.userId || !isSandstormGrain) return next();
|
|
try {
|
|
const { sandstormApiIdentity } = require('/models/lib/sandstormApiIdentity');
|
|
const identity = sandstormApiIdentity(req.headers, { sandstorm: isSandstormGrain, method: req.method });
|
|
if (identity) {
|
|
const user = await Meteor.users.findOneAsync({ 'services.sandstorm.id': identity.sandstormId }, { fields: { _id: 1, loginDisabled: 1 } });
|
|
if (user && require('/server/lib/activeUser').allowActiveUser(user, 'sandstormApiKey', req)) {
|
|
req.userId = user._id;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Ignore auth errors — routes handle missing userId themselves
|
|
}
|
|
next();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4b. API usage counting (Admin Panel -> Problems -> API)
|
|
// ---------------------------------------------------------------------------
|
|
// After authentication, so a call has an account, and in front of the routes, so
|
|
// a route cannot be added without being counted. It counts on the response's
|
|
// `finish` event - by then Express has filled req.route.path, which is the route
|
|
// PATTERN and therefore one row per endpoint rather than one per board id.
|
|
WebApp.handlers.use(require('/server/lib/apiUsageLog').apiUsageMiddleware);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 5. sendJsonResult — drop-in replacement for JsonRoutes.sendResult
|
|
// ---------------------------------------------------------------------------
|
|
function sendJsonResult(res, options) {
|
|
const { containsFields } = require('/models/lib/adminOnlyCustomFields');
|
|
if (!containsFields(options?.data)) return writeJsonResult(res, options);
|
|
return require('/server/lib/adminOnlyCustomFields').redactFields(options.data, res.req?.userId)
|
|
.then(data => writeJsonResult(res, { ...options, data }))
|
|
.catch(() => writeJsonResult(res, { code: 500, data: { error: 'Response unavailable' } }));
|
|
}
|
|
|
|
function writeJsonResult(res, options) {
|
|
options = options || {};
|
|
|
|
// Default response headers (matching json-routes behavior)
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.setHeader('Pragma', 'no-cache');
|
|
|
|
// Custom headers
|
|
if (options.headers) {
|
|
Object.entries(options.headers).forEach(function ([key, value]) {
|
|
res.setHeader(key, value);
|
|
});
|
|
}
|
|
|
|
// Status code
|
|
res.statusCode = options.code || 200;
|
|
|
|
// JSON body
|
|
if (options.data !== undefined) {
|
|
const shouldPrettyPrint = process.env.NODE_ENV === 'development';
|
|
const spacer = shouldPrettyPrint ? 2 : null;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
// Use a crash-proof serializer: some error payloads (e.g. a circular
|
|
// SimpleSchema validation error) would otherwise throw inside
|
|
// JSON.stringify and surface as a generic HTTP 500. See #5804.
|
|
res.write(safeJsonStringify(options.data, spacer));
|
|
}
|
|
|
|
res.end();
|
|
}
|
|
|
|
// GHSA-3gcg-g6rf-w2rx: an export route that throws must not escape as an unhandled
|
|
// promise rejection. This app turns one of those into a full process crash - the
|
|
// note in server/ldapGroupSync.js explains why - so a single crafted request could
|
|
// take the server down for everyone. Every route body wrapped in this answers with a
|
|
// 500 instead. Only the registered route pattern and a fixed error category
|
|
// are folded into API Problems; concrete requests and exception text are secret.
|
|
//
|
|
// The guards inside the handlers are still the real fix; this is the net under them,
|
|
// so the next missing null check is one broken request rather than an outage.
|
|
function safeRoute(handler) {
|
|
return async function safeRouteHandler(req, res, ...rest) {
|
|
try {
|
|
return await handler.call(this, req, res, ...rest);
|
|
} catch (error) {
|
|
try { require('/server/lib/apiFailureLog').recordApiFailure(req, error); }
|
|
catch (_) { /* Logging is best effort and must never prevent the response. */ }
|
|
try {
|
|
if (!res.headersSent) {
|
|
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
}
|
|
res.end('Internal server error');
|
|
} catch (_) {
|
|
// The response is already gone; nothing left to answer with.
|
|
}
|
|
return undefined;
|
|
}
|
|
};
|
|
}
|
|
|
|
module.exports = { sendJsonResult, safeRoute };
|