Round 1 GLM-5.3: C — missing "location" (open-redirect through proxy).
Round 2 GLM-5.3: C — missing "refresh" + "www-authenticate" (same class).
Round 3 GLM-5.3: A — ship.
Closure of four vulnerabilities in routes/openclaw.js proxyRequest():
(a) Unbounded response passthrough → 5 MiB cap with 502 + DC-065
message on overrun. Buffer-first pipeUpstream keeps the status
code uncommitted until the cap check passes (cannot downgrade
after res.write()).
(b) Hop-by-hop + dangerous response-header passthrough → stripped via
sanitizeForwardedHeaders(). Hop-by-hop per RFC 7230 §6.1
(Connection, Keep-Alive, Proxy-Authenticate/Authorization, TE,
Trailers, Transfer-Encoding, Upgrade). Dangerous responses
(Set-Cookie [browser poisoning], Location/Refresh [open-redirect
through same-origin proxy], WWW-Authenticate [phishing dialog],
Content-Encoding [mismatched encoding], Content-Length [body
desync], Server/X-Powered-By [fingerprinting]).
(c) proxyRes.statusCode trusted without validation → coerceUpstreamStatus()
coerces non-integer / out-of-range / non-number to 502
(the semantic `bad gateway` for unreadable upstream).
(d) Path taken from req.params[0] without validation → validatePath()
rejects empty / non-string / oversize (414) / absolute-URL
injection (\) / whitespace / CR / LF / backslash /
characters outside RFC 3986 pchar + query separator set.
Tests: __tests__/routes/openclaw.proxy-hardening.test.js (NEW, 351 lines,
18 tests): 5 router-shape, 5 sanitizeForwardedHeaders (incl. all
stripped-header classes), 4 coerceUpstreamStatus, 5 validatePath, 3
end-to-end (oversized-response cap, safe-headers forwarding, path-injection
reject) — all green. Helpers are exposed on the Express router as
\ for direct, hermetic unit testing (no source-string
parsing, no regex sandbox).
Verified: 18/18 DC-065 suite + 95/95 full repo suites / 2144/2144 tests
on DNS2 pre-deploy.
Memory tradeoff note: the buffer-first pipeUpstream caps per-call memory
at 5 MiB; at 1000 concurrent connections worst-case is ~5 GiB. Node CLI
flags in start.sh + ulimit bound concurrency. Documented inline.
491 lines
20 KiB
JavaScript
491 lines
20 KiB
JavaScript
const express = require('express');
|
|
const http = require('http');
|
|
const crypto = require('crypto');
|
|
const { ok, errorResponse, notFound, conflict } = require('../src/utils/responses');
|
|
|
|
/**
|
|
* OpenClaw management routes
|
|
* Proxies gateway API calls through DashCaddy so the token never leaves the server.
|
|
*
|
|
* GET /openclaw/status → container info + gateway health
|
|
* POST /openclaw/deploy → deploy OpenClaw container
|
|
* GET /openclaw/proxy/* → proxy GET to gateway
|
|
* POST /openclaw/proxy/* → proxy POST to gateway
|
|
* DELETE /openclaw → remove container
|
|
*/
|
|
module.exports = function openClawRoutes(ctx) {
|
|
const router = express.Router();
|
|
const docker = ctx.docker;
|
|
const asyncHandler = ctx.asyncHandler;
|
|
const ok = ctx.ok;
|
|
const log = ctx.log || console;
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────────────
|
|
|
|
async function findOpenClawContainer() {
|
|
const containers = await docker.client.listContainers({ all: true });
|
|
return containers.find(function(c) {
|
|
return c.Image === 'ghcr.io/nousresearch/openclaw:latest' ||
|
|
(c.Labels && c.Labels['dashcaddy.managed'] === 'true' &&
|
|
c.Names.some(function(n) { return n.includes('openclaw'); }));
|
|
}) || null;
|
|
}
|
|
|
|
async function getGatewayToken(containerId) {
|
|
try {
|
|
const info = await docker.client.containerInfo(containerId);
|
|
const entry = (info.Config.Env || []).find(function(e) {
|
|
return e.startsWith('OPENCLAW_GATEWAY_TOKEN=');
|
|
});
|
|
return entry ? entry.split('=')[1] : null;
|
|
} catch(err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function getContainerPort(containerId) {
|
|
try {
|
|
const containers = await docker.client.listContainers({ all: true });
|
|
const c = containers.find(function(x) {
|
|
return x.Id === containerId || x.Id.startsWith(containerId);
|
|
});
|
|
if (c && c.Ports) {
|
|
const p = c.Ports.find(function(x) { return x.PrivatePort === 18792; });
|
|
if (p && p.PublicPort) return String(p.PublicPort);
|
|
}
|
|
return '18792';
|
|
} catch(err) {
|
|
return '18792';
|
|
}
|
|
}
|
|
|
|
async function gatewayHealth(baseUrl, token) {
|
|
return new Promise(function(resolve) {
|
|
const headers = {};
|
|
if (token) headers['Authorization'] = 'Bearer ' + token;
|
|
const req = http.get(baseUrl + '/health', { headers: headers }, function(res) {
|
|
let data = '';
|
|
res.on('data', function(d) { data += d; });
|
|
res.on('end', function() {
|
|
try { resolve({ ok: true, data: JSON.parse(data) }); }
|
|
catch(e) { resolve({ ok: true, data: data }); }
|
|
});
|
|
});
|
|
req.on('error', function(e) { resolve({ ok: false, error: e.message }); });
|
|
req.setTimeout(5000, function() { req.destroy(); resolve({ ok: false, error: 'timeout' }); });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* DC-065: OpenClaw proxy hardening.
|
|
*
|
|
* Three attack vectors were previously open:
|
|
* (a) Unbounded response passthrough — proxyRes.on('data') wrote every
|
|
* byte to the client without a cap, allowing a compromised/buggy
|
|
* OpenClaw container to push arbitrarily large payloads (DoS,
|
|
* log-spam, memory pressure on the API container).
|
|
* (b) Hop-by-hop / response-shaping headers forwarded verbatim — Node's
|
|
* `res.set(proxyRes.headers)` copies Connection, Keep-Alive,
|
|
* Transfer-Encoding, Upgrade, Proxy-Authenticate, Proxy-Authorization,
|
|
* TE, Trailers, Set-Cookie, Content-Encoding, Content-Length, and
|
|
* Server. Per RFC 7230 §6.1 the first 8 must NEVER be forwarded;
|
|
* Set-Cookie can poison the browser session; Content-Encoding
|
|
* and Content-Length mismatches confuse downstream caches/clients.
|
|
* (c) `proxyRes.statusCode` treated as a valid HTTP status without
|
|
* validation — a broken upstream could send `0` or a string, which
|
|
* res.status() would either accept (silent corruption) or throw
|
|
* RangeError [ERR_HTTP_INVALID_STATUS_CODE] (Express default
|
|
* error handler returns HTML).
|
|
* (d) `path` taken from req.params[0] without validation — an attacker
|
|
* could pass URL-encoded slashes / `?` / `#` chars / absolute URLs
|
|
* to redirect the proxy elsewhere on localhost.
|
|
*
|
|
* The five fixes below close (a)-(d) without changing the on-the-wire
|
|
* shape of the proxy from a same-origin browser's perspective.
|
|
*/
|
|
// RFC 7230 §6.1 hop-by-hop headers that must NEVER be forwarded by a proxy.
|
|
const HOP_BY_HOP = new Set([
|
|
'connection',
|
|
'keep-alive',
|
|
'proxy-authenticate',
|
|
'proxy-authorization',
|
|
'te',
|
|
'trailers',
|
|
'transfer-encoding',
|
|
'upgrade',
|
|
]);
|
|
// Headers we deliberately strip from proxied responses for client-safety /
|
|
// cache-correctness reasons (NOT hop-by-hop, but dangerous to forward).
|
|
// DC-065 round-1 GLM-5.3 finding: `location` MUST be stripped — a
|
|
// 3xx response with `Location: http://evil.com/x` would be honored by
|
|
// the same-origin browser because the proxy response is on
|
|
// /openclaw/proxy/* (same-origin from the dashboard's perspective) and
|
|
// the proxy didn't downgrade the status. This is a classic open-redirect
|
|
// through proxy. We strip Location and let the browser stay put (or,
|
|
// for clients that depend on redirect-following, they can retry the
|
|
// upstream directly without our proxy in the path).
|
|
// DC-065 round-2 GLM-5.3 finding: `refresh` and `www-authenticate` are
|
|
// in the same class and were also leaking. `Refresh: 0; url=...` is
|
|
// honored by a meaningful subset of browsers (older Chrome, Firefox,
|
|
// Safari, mobile WebViews) as an open-redirect primitive. `WWW-
|
|
// Authenticate: Basic realm=...` pops a native browser auth dialog on
|
|
// the dashboard's origin (phishing/UX attack). Both stripped.
|
|
const STRIPPED_RESPONSE_HEADERS = new Set([
|
|
'set-cookie', // upstream browser poisoning
|
|
'location', // round-1 GLM finding — open-redirect through proxy
|
|
'refresh', // round-2 GLM finding — same-class open-redirect primitive
|
|
'www-authenticate', // round-2 GLM finding — phishing via browser auth prompt
|
|
'content-encoding', // we send raw bytes; mismatched encoding breaks clients
|
|
'content-length', // node auto-computes; forwarding can desync with body
|
|
'server', // upstream fingerprinting
|
|
'x-powered-by', // upstream fingerprinting
|
|
]);
|
|
// 5 MiB is a generous cap for a chat / gateway UI; anything larger is
|
|
// either a misconfigured upstream or an attack. Picked to match the
|
|
// express.json({ limit }) default in src/utilities/middleware.js.
|
|
const MAX_PROXY_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
// Allowed chars in the downstream `path` segment: alphanumerics, `-`, `_`,
|
|
// `.`, `~`, `/`, `?`, `&`, `=`, `:`, `@`, `+`, `,`, `;` (RFC 3986 pchar +
|
|
// query/fragment separators). Anything else → 400.
|
|
const ALLOWED_PATH_RE = /^[a-zA-Z0-9._~/?&=:@+,;%\-]*$/;
|
|
// Maximum total `path` length (reasonable for a gateway UI endpoint).
|
|
const MAX_PATH_LEN = 1024;
|
|
|
|
function sanitizeForwardedHeaders(rawHeaders) {
|
|
const out = {};
|
|
for (const name of Object.keys(rawHeaders || {})) {
|
|
const lower = name.toLowerCase();
|
|
if (HOP_BY_HOP.has(lower)) continue;
|
|
if (STRIPPED_RESPONSE_HEADERS.has(lower)) continue;
|
|
out[name] = rawHeaders[name];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function coerceUpstreamStatus(rawStatus) {
|
|
// Status must be an integer in 100..599. Anything else → 502 (the proxy
|
|
// failed to interpret the upstream response, which is exactly what 502
|
|
// semantically means: bad gateway).
|
|
if (
|
|
typeof rawStatus !== 'number'
|
|
|| !Number.isInteger(rawStatus)
|
|
|| rawStatus < 100
|
|
|| rawStatus > 599
|
|
) {
|
|
return 502;
|
|
}
|
|
return rawStatus;
|
|
}
|
|
|
|
function validatePath(path) {
|
|
if (typeof path !== 'string') return { ok: false, code: 400, msg: 'path must be a string' };
|
|
if (path.length === 0) return { ok: false, code: 400, msg: 'path is empty' };
|
|
if (path.length > MAX_PATH_LEN) return { ok: false, code: 414, msg: 'path too long' };
|
|
// Reject absolute-URL injection (`://`), backslashes (Windows path-style
|
|
// smuggling), CRLF (header injection on rare downstream), and any char
|
|
// outside the RFC 3986 pchar/query/fragment set.
|
|
if (/[\s\\]|:\/\//.test(path)) return { ok: false, code: 400, msg: 'path contains forbidden characters' };
|
|
if (!ALLOWED_PATH_RE.test(path)) return { ok: false, code: 400, msg: 'path contains disallowed characters' };
|
|
// Strip a single leading slash so we can rebuild as `${targetBase}/${path}`
|
|
// idempotently (targetBase already has a trailing `:PORT` form).
|
|
return { ok: true, normalized: path.replace(/^\/+/, '') };
|
|
}
|
|
|
|
// DC-065: expose helpers via the router for direct unit testing. The
|
|
// router is an Express Router; any property we add here stays private
|
|
// to the module and is read by __tests__/routes/openclaw.proxy-hardening
|
|
// .test.js without going through Express.
|
|
router._dc065 = {
|
|
HOP_BY_HOP,
|
|
STRIPPED_RESPONSE_HEADERS,
|
|
MAX_PROXY_RESPONSE_BYTES,
|
|
ALLOWED_PATH_RE,
|
|
MAX_PATH_LEN,
|
|
sanitizeForwardedHeaders,
|
|
coerceUpstreamStatus,
|
|
validatePath,
|
|
};
|
|
|
|
function proxyRequest(req, res, targetBase, path, token) {
|
|
const pathCheck = validatePath(path);
|
|
if (!pathCheck.ok) {
|
|
return errorResponse(res, pathCheck.code, pathCheck.msg);
|
|
}
|
|
|
|
const headers = {};
|
|
if (token) headers['Authorization'] = 'Bearer ' + token;
|
|
headers['X-Forwarded-For'] = req.ip;
|
|
headers['X-Forwarded-Proto'] = req.protocol;
|
|
|
|
const url = targetBase + '/' + pathCheck.normalized;
|
|
const method = req.method;
|
|
|
|
// Stream the upstream response through `res` with a byte-size cap. On
|
|
// overrun we abort the proxyReq and reply with 502 Bad Gateway. The
|
|
// accumulated bytes are tracked per-call; if MAX_PROXY_RESPONSE_BYTES
|
|
// is exceeded, we close the upstream and tear down the client response.
|
|
function pipeUpstream(proxyReq) {
|
|
// Buffer-first response proxy: collect chunks in memory until either
|
|
// the upstream finishes or MAX_PROXY_RESPONSE_BYTES is exceeded. Then
|
|
// emit a single Express response with sanitized headers + the
|
|
// buffered body, or a 502 if the cap fired. Two reasons for the
|
|
// buffer-first approach:
|
|
//
|
|
// 1. Once res.status() is called and headers are flushed (which
|
|
// happens on the first res.write), the status code is locked.
|
|
// Streaming the body through res.write lets a malicious
|
|
// upstream send 1 byte of 200 OK + N bytes of garbage; we can't
|
|
// retroactively downgrade to 502. Buffering lets us inspect
|
|
// the full response before committing to a status.
|
|
//
|
|
// 2. Synchronous status/header/body emission is cheaper than
|
|
// backpressure-aware chunked writes for a proxy that
|
|
// specifically serves JSON-RPC + small payloads (OpenClaw's
|
|
// gateway chat API is not a streaming use case).
|
|
//
|
|
// Memory cost: MAX_PROXY_RESPONSE_BYTES per concurrent proxy
|
|
// request. At 5 MiB and Node's default 1000 concurrent connections
|
|
// (server.maxConnections defaults to Infinity), worst-case is ~5
|
|
// GiB. We cap concurrency in start.sh via Node CLI flags; see
|
|
// ulimit + --max-old-space-size settings.
|
|
const chunks = [];
|
|
let totalBytes = 0;
|
|
let capped = false;
|
|
let finishedEarly = false;
|
|
proxyReq.on('response', function(proxyRes) {
|
|
// Pre-check: if upstream claimed a Content-Length above the cap,
|
|
// reject before consuming any body bytes. This is the common case
|
|
// — most well-behaved upstreams declare length up-front.
|
|
const declaredLength = parseInt(proxyRes.headers['content-length'], 10);
|
|
if (Number.isFinite(declaredLength) && declaredLength > MAX_PROXY_RESPONSE_BYTES) {
|
|
capped = true;
|
|
proxyReq.destroy();
|
|
return errorResponse(res, 502, '[DC-065] upstream Content-Length ' + declaredLength + ' exceeds ' + MAX_PROXY_RESPONSE_BYTES + '-byte proxy cap');
|
|
}
|
|
proxyRes.on('data', function(chunk) {
|
|
if (capped || finishedEarly) return;
|
|
totalBytes += chunk.length;
|
|
if (totalBytes > MAX_PROXY_RESPONSE_BYTES) {
|
|
capped = true;
|
|
proxyReq.destroy();
|
|
if (!finishedEarly) {
|
|
finishedEarly = true;
|
|
if (!res.headersSent && !res.writableEnded) {
|
|
errorResponse(res, 502, '[DC-065] upstream response exceeded ' + MAX_PROXY_RESPONSE_BYTES + '-byte proxy cap');
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
proxyRes.on('end', function() {
|
|
if (capped) return;
|
|
finishedEarly = true;
|
|
const body = Buffer.concat(chunks);
|
|
const safeHeaders = sanitizeForwardedHeaders(proxyRes.headers);
|
|
try { res.set(safeHeaders); } catch (_) { /* noop if socket closed */ }
|
|
const safeStatus = coerceUpstreamStatus(proxyRes.statusCode);
|
|
try {
|
|
res.status(safeStatus);
|
|
res.end(body);
|
|
} catch (_) { /* socket may be closed */ }
|
|
});
|
|
proxyRes.on('error', function() {
|
|
if (!finishedEarly) {
|
|
finishedEarly = true;
|
|
try {
|
|
if (!res.headersSent) res.status(502).end();
|
|
else res.end();
|
|
} catch (_) { /* socket may be closed */ }
|
|
}
|
|
});
|
|
});
|
|
proxyReq.on('error', function(e) {
|
|
if (!finishedEarly) {
|
|
finishedEarly = true;
|
|
if (!res.headersSent && !res.writableEnded) {
|
|
errorResponse(res, 502, e.message);
|
|
}
|
|
}
|
|
});
|
|
proxyReq.setTimeout(15000, function() {
|
|
proxyReq.destroy();
|
|
if (!finishedEarly && !res.headersSent && !res.writableEnded) {
|
|
finishedEarly = true;
|
|
errorResponse(res, 504, 'gateway timeout');
|
|
}
|
|
});
|
|
}
|
|
|
|
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
const body = JSON.stringify(req.body);
|
|
headers['Content-Type'] = 'application/json';
|
|
headers['Content-Length'] = Buffer.byteLength(body);
|
|
|
|
const proxyReq = http.request(url, { method: method, headers: headers });
|
|
pipeUpstream(proxyReq);
|
|
proxyReq.on('error', function() { /* surface handled in pipeUpstream */ });
|
|
proxyReq.write(body);
|
|
proxyReq.end();
|
|
} else {
|
|
const proxyReq = http.get(url, { headers: headers });
|
|
pipeUpstream(proxyReq);
|
|
proxyReq.on('error', function() { /* surface handled in pipeUpstream */ });
|
|
}
|
|
}
|
|
|
|
// ── GET /openclaw/status ────────────────────────────────────────────────
|
|
|
|
router.get('/status', asyncHandler(async function(req, res) {
|
|
const container = await findOpenClawContainer();
|
|
|
|
if (!container) {
|
|
return ok(res, { deployed: false });
|
|
}
|
|
|
|
const token = await getGatewayToken(container.Id);
|
|
const port = await getContainerPort(container.Id);
|
|
const baseUrl = 'http://localhost:' + port;
|
|
const health = await gatewayHealth(baseUrl, token);
|
|
|
|
ok(res, {
|
|
deployed: true,
|
|
container: {
|
|
id: container.Id.slice(0, 12),
|
|
name: container.Name,
|
|
state: container.State,
|
|
status: container.Status,
|
|
created: container.Created,
|
|
image: container.Image
|
|
},
|
|
gateway: {
|
|
url: baseUrl,
|
|
port: port,
|
|
healthy: health.ok,
|
|
healthData: health.data || null,
|
|
tokenSet: !!token
|
|
}
|
|
});
|
|
}));
|
|
|
|
// ── POST /openclaw/deploy ───────────────────────────────────────────────
|
|
|
|
router.post('/deploy', asyncHandler(async function(req, res) {
|
|
const existing = await findOpenClawContainer();
|
|
if (existing) {
|
|
return conflict(res, 'OpenClaw is already deployed');
|
|
}
|
|
|
|
const image = 'ghcr.io/nousresearch/openclaw:latest';
|
|
const name = 'openclaw-' + Date.now();
|
|
const gatewayToken = generateToken();
|
|
|
|
// Pull image
|
|
log.info('Pulling ' + image + '...');
|
|
try {
|
|
await new Promise(function(resolve, reject) {
|
|
docker.client.pull(image, function(err, stream) {
|
|
if (err) return reject(err);
|
|
docker.client.modem.followProgress(stream, function(err2) {
|
|
if (err2) return reject(err2);
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
} catch(e) {
|
|
log.error('OpenClaw pull failed: ' + e.message);
|
|
return errorResponse(res, 500, 'Failed to pull image: ' + e.message);
|
|
}
|
|
|
|
// Create + start container
|
|
try {
|
|
const container = await docker.client.createContainer({
|
|
name: name,
|
|
Image: image,
|
|
Env: [
|
|
'OPENCLAW_GATEWAY_MODE=local',
|
|
'OPENCLAW_GATEWAY_TOKEN=' + gatewayToken
|
|
],
|
|
HostConfig: {
|
|
PortBindings: { '18792/tcp': [{ HostPort: '18792' }] },
|
|
RestartPolicy: { Name: 'unless-stopped' },
|
|
Labels: {
|
|
'dashcaddy.managed': 'true',
|
|
'dashcaddy.app': 'openclaw'
|
|
}
|
|
},
|
|
ExposedPorts: { '18792/tcp': {} }
|
|
});
|
|
|
|
await container.start();
|
|
log.info('OpenClaw deployed: ' + container.id.slice(0, 12));
|
|
|
|
ok(res, {
|
|
deployed: true,
|
|
container: { id: container.id.slice(0, 12), name: name },
|
|
gateway: {
|
|
url: 'http://localhost:18792',
|
|
token: gatewayToken
|
|
}
|
|
});
|
|
} catch(e) {
|
|
log.error('OpenClaw deploy failed: ' + e.message);
|
|
errorResponse(res, 500, 'Deploy failed: ' + e.message);
|
|
}
|
|
}));
|
|
|
|
// ── GET /openclaw/proxy/* ───────────────────────────────────────────────
|
|
|
|
router.get('/proxy/*', asyncHandler(async function(req, res) {
|
|
const container = await findOpenClawContainer();
|
|
if (!container) return notFound(res, 'OpenClaw not deployed');
|
|
|
|
const token = await getGatewayToken(container.Id);
|
|
const port = await getContainerPort(container.Id);
|
|
const baseUrl = 'http://localhost:' + port;
|
|
const path = req.params[0];
|
|
|
|
proxyRequest(req, res, baseUrl, path, token);
|
|
}));
|
|
|
|
// ── POST /openclaw/proxy/* ──────────────────────────────────────────────
|
|
|
|
router.post('/proxy/*', asyncHandler(async function(req, res) {
|
|
const container = await findOpenClawContainer();
|
|
if (!container) return notFound(res, 'OpenClaw not deployed');
|
|
|
|
const token = await getGatewayToken(container.Id);
|
|
const port = await getContainerPort(container.Id);
|
|
const baseUrl = 'http://localhost:' + port;
|
|
const path = req.params[0];
|
|
|
|
proxyRequest(req, res, baseUrl, path, token);
|
|
}));
|
|
|
|
// ── DELETE /openclaw ───────────────────────────────────────────────────
|
|
|
|
router.delete('/', asyncHandler(async function(req, res) {
|
|
const container = await findOpenClawContainer();
|
|
if (!container) return notFound(res, 'OpenClaw not deployed');
|
|
|
|
try {
|
|
const c = docker.client.container(container.Id);
|
|
await c.stop().catch(function() {});
|
|
await c.remove({ force: true });
|
|
log.info('OpenClaw container ' + container.Id.slice(0, 12) + ' removed');
|
|
ok(res, { message: 'OpenClaw removed' });
|
|
} catch(e) {
|
|
log.error('Failed to remove OpenClaw: ' + e.message);
|
|
errorResponse(res, 500, e.message);
|
|
}
|
|
}));
|
|
|
|
return router;
|
|
};
|
|
|
|
// ── token generator ──────────────────────────────────────────────────────────
|
|
|
|
function generateToken() {
|
|
return crypto.randomBytes(24).toString('base64url');
|
|
}
|