587 lines
15 KiB
JavaScript
587 lines
15 KiB
JavaScript
import crypto from 'crypto';
|
|
|
|
const BOOTSTRAP_TOKEN_COOKIE_SAFE_BYTES = 32;
|
|
const TUNNEL_SESSION_COOKIE_NAME = 'oc_tunnel_session';
|
|
|
|
const CONNECT_RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
|
const CONNECT_RATE_LIMIT_LOCK_MS = 10 * 60 * 1000;
|
|
const CONNECT_RATE_LIMIT_MAX_ATTEMPTS = 20;
|
|
const CONNECT_RATE_LIMIT_NO_IP_MAX_ATTEMPTS = 5;
|
|
|
|
const parseCookies = (cookieHeader) => {
|
|
if (!cookieHeader || typeof cookieHeader !== 'string') {
|
|
return {};
|
|
}
|
|
|
|
return cookieHeader.split(';').reduce((acc, segment) => {
|
|
const [name, ...rest] = segment.split('=');
|
|
if (!name) {
|
|
return acc;
|
|
}
|
|
const key = name.trim();
|
|
if (!key) {
|
|
return acc;
|
|
}
|
|
const value = rest.join('=').trim();
|
|
acc[key] = decodeURIComponent(value || '');
|
|
return acc;
|
|
}, {});
|
|
};
|
|
|
|
const isSecureRequest = (req) => {
|
|
if (req.secure) {
|
|
return true;
|
|
}
|
|
const forwardedProto = req.headers['x-forwarded-proto'];
|
|
if (typeof forwardedProto === 'string') {
|
|
const firstProto = forwardedProto.split(',')[0]?.trim().toLowerCase();
|
|
return firstProto === 'https';
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const buildCookie = ({ name, value, maxAge, secure }) => {
|
|
const attributes = [
|
|
`${name}=${value}`,
|
|
'Path=/',
|
|
'HttpOnly',
|
|
'SameSite=Lax',
|
|
];
|
|
|
|
if (typeof maxAge === 'number') {
|
|
attributes.push(`Max-Age=${Math.max(0, Math.floor(maxAge))}`);
|
|
}
|
|
|
|
const expires = maxAge === 0
|
|
? 'Thu, 01 Jan 1970 00:00:00 GMT'
|
|
: new Date(Date.now() + maxAge * 1000).toUTCString();
|
|
|
|
attributes.push(`Expires=${expires}`);
|
|
|
|
if (secure) {
|
|
attributes.push('Secure');
|
|
}
|
|
|
|
return attributes.join('; ');
|
|
};
|
|
|
|
const nowTs = () => Date.now();
|
|
|
|
const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex');
|
|
|
|
const normalizeHost = (candidate) => {
|
|
if (typeof candidate !== 'string') {
|
|
return null;
|
|
}
|
|
const trimmed = candidate.trim().toLowerCase();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
return trimmed.replace(/:\d+$/, '');
|
|
};
|
|
|
|
const normalizeIpCandidate = (candidate) => {
|
|
if (typeof candidate !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = candidate.trim().toLowerCase();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const withoutBrackets = trimmed.startsWith('[') && trimmed.endsWith(']')
|
|
? trimmed.slice(1, -1)
|
|
: trimmed;
|
|
|
|
const withoutZone = withoutBrackets.split('%')[0];
|
|
if (!withoutZone) {
|
|
return null;
|
|
}
|
|
|
|
if (withoutZone.startsWith('::ffff:')) {
|
|
const mappedIpv4 = withoutZone.slice('::ffff:'.length);
|
|
if (/^\d+\.\d+\.\d+\.\d+$/.test(mappedIpv4)) {
|
|
return mappedIpv4;
|
|
}
|
|
}
|
|
|
|
return withoutZone;
|
|
};
|
|
|
|
const getSocketRemoteIp = (req) => {
|
|
const remoteAddress = req?.socket?.remoteAddress || req?.connection?.remoteAddress;
|
|
return normalizeIpCandidate(remoteAddress);
|
|
};
|
|
|
|
const isPrivateOrLoopbackIpv4 = (candidate) => {
|
|
const octets = candidate.split('.').map((part) => Number(part));
|
|
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
return false;
|
|
}
|
|
|
|
const [first, second] = octets;
|
|
if (first === 127) {
|
|
return true;
|
|
}
|
|
if (first === 10) {
|
|
return true;
|
|
}
|
|
if (first === 172 && second >= 16 && second <= 31) {
|
|
return true;
|
|
}
|
|
if (first === 192 && second === 168) {
|
|
return true;
|
|
}
|
|
if (first === 169 && second === 254) {
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const isPrivateOrLoopbackIpv6 = (candidate) => {
|
|
if (candidate === '::1') {
|
|
return true;
|
|
}
|
|
|
|
if (candidate.startsWith('fc') || candidate.startsWith('fd')) {
|
|
return true;
|
|
}
|
|
|
|
return candidate.startsWith('fe8')
|
|
|| candidate.startsWith('fe9')
|
|
|| candidate.startsWith('fea')
|
|
|| candidate.startsWith('feb');
|
|
};
|
|
|
|
const isPrivateOrLoopbackIp = (candidate) => {
|
|
const normalized = normalizeIpCandidate(candidate);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
|
|
if (normalized.includes(':')) {
|
|
return isPrivateOrLoopbackIpv6(normalized);
|
|
}
|
|
|
|
return isPrivateOrLoopbackIpv4(normalized);
|
|
};
|
|
|
|
const isLocalHost = (host, req) => {
|
|
if (!host) {
|
|
return false;
|
|
}
|
|
|
|
const isLocalHostname = host === 'localhost'
|
|
|| host === 'host.docker.internal'
|
|
|| isPrivateOrLoopbackIp(host);
|
|
return isLocalHostname && isPrivateOrLoopbackIp(getSocketRemoteIp(req));
|
|
};
|
|
|
|
const getClientIp = (req) => {
|
|
const forwarded = req.headers['x-forwarded-for'];
|
|
if (typeof forwarded === 'string') {
|
|
const ip = forwarded.split(',')[0].trim();
|
|
if (ip.startsWith('::ffff:')) {
|
|
return ip.substring(7);
|
|
}
|
|
return ip;
|
|
}
|
|
|
|
const ip = req.ip || req.connection?.remoteAddress;
|
|
if (ip) {
|
|
if (ip.startsWith('::ffff:')) {
|
|
return ip.substring(7);
|
|
}
|
|
return ip;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const getRateLimitKey = (req) => {
|
|
const ip = getClientIp(req);
|
|
if (ip) {
|
|
return ip;
|
|
}
|
|
return 'connect-rate-limit:no-ip';
|
|
};
|
|
|
|
const rateLimitMaxForKey = (key) => {
|
|
if (key === 'connect-rate-limit:no-ip') {
|
|
return CONNECT_RATE_LIMIT_NO_IP_MAX_ATTEMPTS;
|
|
}
|
|
return CONNECT_RATE_LIMIT_MAX_ATTEMPTS;
|
|
};
|
|
|
|
export const createTunnelAuth = () => {
|
|
let activeTunnelId = null;
|
|
let activeTunnelHost = null;
|
|
let activeTunnelMode = null;
|
|
let activeTunnelPublicUrl = null;
|
|
let bootstrapRecord = null;
|
|
|
|
const tunnelSessions = new Map();
|
|
const connectRateLimiter = new Map();
|
|
|
|
const clearTunnelSessionCookie = (req, res) => {
|
|
const secure = isSecureRequest(req);
|
|
const header = buildCookie({
|
|
name: TUNNEL_SESSION_COOKIE_NAME,
|
|
value: '',
|
|
maxAge: 0,
|
|
secure,
|
|
});
|
|
res.setHeader('Set-Cookie', header);
|
|
};
|
|
|
|
const setTunnelSessionCookie = (req, res, sessionId, ttlMs) => {
|
|
const secure = isSecureRequest(req);
|
|
const maxAge = Math.max(0, Math.floor(ttlMs / 1000));
|
|
const header = buildCookie({
|
|
name: TUNNEL_SESSION_COOKIE_NAME,
|
|
value: encodeURIComponent(sessionId),
|
|
maxAge,
|
|
secure,
|
|
});
|
|
res.setHeader('Set-Cookie', header);
|
|
};
|
|
|
|
const classifyRequestScope = (req) => {
|
|
const hostHeader = normalizeHost(typeof req.headers.host === 'string' ? req.headers.host : '');
|
|
const reqHost = normalizeHost(typeof req.hostname === 'string' ? req.hostname : '') || hostHeader;
|
|
|
|
if (activeTunnelHost && reqHost === activeTunnelHost) {
|
|
return 'tunnel';
|
|
}
|
|
|
|
if (isLocalHost(reqHost, req)) {
|
|
return 'local';
|
|
}
|
|
|
|
if (!activeTunnelId) {
|
|
return 'local';
|
|
}
|
|
|
|
return 'unknown-public';
|
|
};
|
|
|
|
const revokeBootstrapToken = () => {
|
|
if (!bootstrapRecord) {
|
|
return 0;
|
|
}
|
|
if (bootstrapRecord.revokedAt) {
|
|
return 0;
|
|
}
|
|
if (!bootstrapRecord.revokedAt) {
|
|
bootstrapRecord.revokedAt = nowTs();
|
|
}
|
|
return 1;
|
|
};
|
|
|
|
const invalidateTunnelSessions = (tunnelId, reason = 'tunnel-stopped') => {
|
|
const revokedAt = nowTs();
|
|
let count = 0;
|
|
for (const record of tunnelSessions.values()) {
|
|
if (record.tunnelId === tunnelId && !record.revokedAt) {
|
|
record.revokedAt = revokedAt;
|
|
record.revokedReason = reason;
|
|
count += 1;
|
|
}
|
|
}
|
|
return count;
|
|
};
|
|
|
|
const revokeTunnelArtifacts = (tunnelId) => {
|
|
const revokedBootstrapCount = bootstrapRecord && bootstrapRecord.tunnelId === tunnelId
|
|
? revokeBootstrapToken()
|
|
: 0;
|
|
const invalidatedSessionCount = invalidateTunnelSessions(tunnelId, 'tunnel-revoked');
|
|
return { revokedBootstrapCount, invalidatedSessionCount };
|
|
};
|
|
|
|
const setActiveTunnel = ({ tunnelId, publicUrl, mode = null }) => {
|
|
activeTunnelId = tunnelId;
|
|
activeTunnelMode = mode;
|
|
activeTunnelPublicUrl = publicUrl || null;
|
|
try {
|
|
activeTunnelHost = normalizeHost(new URL(publicUrl).host);
|
|
} catch {
|
|
activeTunnelHost = null;
|
|
}
|
|
};
|
|
|
|
const clearActiveTunnel = () => {
|
|
if (activeTunnelId) {
|
|
revokeTunnelArtifacts(activeTunnelId);
|
|
}
|
|
activeTunnelId = null;
|
|
activeTunnelHost = null;
|
|
activeTunnelMode = null;
|
|
activeTunnelPublicUrl = null;
|
|
bootstrapRecord = null;
|
|
};
|
|
|
|
const isBootstrapRecordUsable = (record) => {
|
|
if (!record || record.revokedAt || record.usedAt) {
|
|
return false;
|
|
}
|
|
if (typeof record.expiresAt === 'number' && nowTs() >= record.expiresAt) {
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const issueBootstrapToken = ({ ttlMs }) => {
|
|
if (!activeTunnelId) {
|
|
throw new Error('Tunnel is not active');
|
|
}
|
|
|
|
revokeBootstrapToken();
|
|
|
|
const token = crypto.randomBytes(BOOTSTRAP_TOKEN_COOKIE_SAFE_BYTES).toString('base64url');
|
|
const issuedAt = nowTs();
|
|
const expiresAt = Number.isFinite(ttlMs) && ttlMs > 0 ? issuedAt + ttlMs : null;
|
|
|
|
bootstrapRecord = {
|
|
id: crypto.randomUUID(),
|
|
tunnelId: activeTunnelId,
|
|
tokenHash: hashToken(token),
|
|
issuedAt,
|
|
expiresAt,
|
|
usedAt: null,
|
|
revokedAt: null,
|
|
};
|
|
|
|
return {
|
|
token,
|
|
expiresAt,
|
|
};
|
|
};
|
|
|
|
const getBootstrapStatus = () => {
|
|
if (!isBootstrapRecordUsable(bootstrapRecord)) {
|
|
return {
|
|
hasBootstrapToken: false,
|
|
bootstrapExpiresAt: null,
|
|
};
|
|
}
|
|
|
|
return {
|
|
hasBootstrapToken: true,
|
|
bootstrapExpiresAt: bootstrapRecord.expiresAt,
|
|
};
|
|
};
|
|
|
|
const checkConnectRateLimit = (req) => {
|
|
const key = getRateLimitKey(req);
|
|
const now = nowTs();
|
|
const maxAttempts = rateLimitMaxForKey(key);
|
|
const record = connectRateLimiter.get(key);
|
|
|
|
if (record?.lockedUntil && now < record.lockedUntil) {
|
|
return {
|
|
allowed: false,
|
|
retryAfter: Math.ceil((record.lockedUntil - now) / 1000),
|
|
};
|
|
}
|
|
|
|
if (!record || now - record.lastAttempt > CONNECT_RATE_LIMIT_WINDOW_MS) {
|
|
return { allowed: true, retryAfter: 0 };
|
|
}
|
|
|
|
if (record.count >= maxAttempts) {
|
|
const lockedUntil = now + CONNECT_RATE_LIMIT_LOCK_MS;
|
|
connectRateLimiter.set(key, {
|
|
count: record.count + 1,
|
|
lastAttempt: now,
|
|
lockedUntil,
|
|
});
|
|
return {
|
|
allowed: false,
|
|
retryAfter: Math.ceil(CONNECT_RATE_LIMIT_LOCK_MS / 1000),
|
|
};
|
|
}
|
|
|
|
return { allowed: true, retryAfter: 0 };
|
|
};
|
|
|
|
const recordConnectFailedAttempt = (req) => {
|
|
const key = getRateLimitKey(req);
|
|
const now = nowTs();
|
|
const record = connectRateLimiter.get(key);
|
|
|
|
if (!record || now - record.lastAttempt > CONNECT_RATE_LIMIT_WINDOW_MS) {
|
|
connectRateLimiter.set(key, { count: 1, lastAttempt: now, lockedUntil: null });
|
|
return;
|
|
}
|
|
|
|
connectRateLimiter.set(key, {
|
|
count: record.count + 1,
|
|
lastAttempt: now,
|
|
lockedUntil: record.lockedUntil || null,
|
|
});
|
|
};
|
|
|
|
const clearConnectRateLimit = (req) => {
|
|
const key = getRateLimitKey(req);
|
|
connectRateLimiter.delete(key);
|
|
};
|
|
|
|
const getTunnelSessionFromRequest = (req) => {
|
|
const cookies = parseCookies(req.headers.cookie);
|
|
const token = cookies[TUNNEL_SESSION_COOKIE_NAME];
|
|
if (!token) {
|
|
return null;
|
|
}
|
|
const session = tunnelSessions.get(token);
|
|
if (!session) {
|
|
return null;
|
|
}
|
|
if (session.revokedAt) {
|
|
return null;
|
|
}
|
|
if (session.expiresAt <= nowTs()) {
|
|
if (!session.expiredAt) {
|
|
session.expiredAt = nowTs();
|
|
}
|
|
return null;
|
|
}
|
|
if (session.tunnelId !== activeTunnelId) {
|
|
return null;
|
|
}
|
|
session.lastSeenAt = nowTs();
|
|
return session;
|
|
};
|
|
|
|
const requireTunnelSession = (req, res, next) => {
|
|
const session = getTunnelSessionFromRequest(req);
|
|
if (session) {
|
|
return next();
|
|
}
|
|
|
|
clearTunnelSessionCookie(req, res);
|
|
res.status(401).json({
|
|
error: 'Tunnel authentication required',
|
|
locked: true,
|
|
tunnelLocked: true,
|
|
});
|
|
};
|
|
|
|
const exchangeBootstrapToken = ({ req, res, token, sessionTtlMs }) => {
|
|
const rateLimit = checkConnectRateLimit(req);
|
|
if (!rateLimit.allowed) {
|
|
return {
|
|
ok: false,
|
|
reason: 'rate-limited',
|
|
retryAfter: rateLimit.retryAfter,
|
|
};
|
|
}
|
|
|
|
if (!activeTunnelId || !bootstrapRecord) {
|
|
recordConnectFailedAttempt(req);
|
|
return { ok: false, reason: 'inactive' };
|
|
}
|
|
|
|
if (!token || typeof token !== 'string') {
|
|
recordConnectFailedAttempt(req);
|
|
return { ok: false, reason: 'missing-token' };
|
|
}
|
|
|
|
if (!isBootstrapRecordUsable(bootstrapRecord)) {
|
|
recordConnectFailedAttempt(req);
|
|
return { ok: false, reason: 'expired' };
|
|
}
|
|
|
|
if (bootstrapRecord.tunnelId !== activeTunnelId) {
|
|
recordConnectFailedAttempt(req);
|
|
return { ok: false, reason: 'tunnel-mismatch' };
|
|
}
|
|
|
|
const incomingHash = hashToken(token);
|
|
const expected = bootstrapRecord.tokenHash;
|
|
const validHash = incomingHash.length === expected.length
|
|
&& crypto.timingSafeEqual(Buffer.from(incomingHash), Buffer.from(expected));
|
|
|
|
if (!validHash) {
|
|
recordConnectFailedAttempt(req);
|
|
return { ok: false, reason: 'invalid-token' };
|
|
}
|
|
|
|
bootstrapRecord.usedAt = nowTs();
|
|
clearConnectRateLimit(req);
|
|
|
|
const sessionId = crypto.randomBytes(32).toString('base64url');
|
|
const createdAt = nowTs();
|
|
const expiresAt = createdAt + sessionTtlMs;
|
|
|
|
tunnelSessions.set(sessionId, {
|
|
sessionId,
|
|
tunnelId: activeTunnelId,
|
|
mode: activeTunnelMode,
|
|
publicUrl: activeTunnelPublicUrl,
|
|
createdAt,
|
|
lastSeenAt: createdAt,
|
|
expiresAt,
|
|
revokedAt: null,
|
|
revokedReason: null,
|
|
expiredAt: null,
|
|
});
|
|
|
|
setTunnelSessionCookie(req, res, sessionId, sessionTtlMs);
|
|
|
|
return {
|
|
ok: true,
|
|
sessionExpiresAt: expiresAt,
|
|
};
|
|
};
|
|
|
|
const listTunnelSessions = () => {
|
|
const now = nowTs();
|
|
|
|
const sessions = [];
|
|
for (const record of tunnelSessions.values()) {
|
|
const isExpired = record.expiresAt <= now;
|
|
if (isExpired && !record.expiredAt) {
|
|
record.expiredAt = now;
|
|
}
|
|
|
|
const active = !record.revokedAt && !isExpired && record.tunnelId === activeTunnelId;
|
|
const status = active ? 'active' : 'inactive';
|
|
const inactiveReason = record.revokedAt ? (record.revokedReason || 'revoked') : (isExpired ? 'expired' : 'inactive');
|
|
|
|
sessions.push({
|
|
sessionId: record.sessionId,
|
|
tunnelId: record.tunnelId,
|
|
mode: record.mode,
|
|
publicUrl: record.publicUrl,
|
|
createdAt: record.createdAt,
|
|
lastSeenAt: record.lastSeenAt,
|
|
expiresAt: record.expiresAt,
|
|
revokedAt: record.revokedAt,
|
|
status,
|
|
inactiveReason: status === 'inactive' ? inactiveReason : null,
|
|
});
|
|
}
|
|
|
|
sessions.sort((a, b) => b.createdAt - a.createdAt);
|
|
return sessions;
|
|
};
|
|
|
|
return {
|
|
classifyRequestScope,
|
|
setActiveTunnel,
|
|
clearActiveTunnel,
|
|
revokeTunnelArtifacts,
|
|
issueBootstrapToken,
|
|
getBootstrapStatus,
|
|
requireTunnelSession,
|
|
getTunnelSessionFromRequest,
|
|
exchangeBootstrapToken,
|
|
listTunnelSessions,
|
|
clearTunnelSessionCookie,
|
|
getActiveTunnelId: () => activeTunnelId,
|
|
getActiveTunnelHost: () => activeTunnelHost,
|
|
getActiveTunnelMode: () => activeTunnelMode,
|
|
};
|
|
};
|