-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsecurity.js
More file actions
161 lines (142 loc) · 5.29 KB
/
security.js
File metadata and controls
161 lines (142 loc) · 5.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
/**
* Security utilities for twake-cli
*
* Centralizes token redaction, URL validation, room-ID validation,
* and the custom User-Agent header so every command file can reuse
* the same hardened helpers.
*/
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { timingSafeEqual } from 'crypto';
// ---------- package version (for User-Agent) ----------
const __dirname = dirname(fileURLToPath(import.meta.url));
let pkgVersion = '0.0.0';
try {
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
pkgVersion = pkg.version || pkgVersion;
} catch { /* graceful fallback */ }
/**
* SECURITY: Custom User-Agent sent with every outgoing HTTP request.
* Identifies twake-cli to servers and proxies, which is standard
* practice for API clients and helps with audit logging.
*/
export const USER_AGENT = `twake-cli/${pkgVersion} (Node.js ${process.version})`;
// ---------- token redaction ----------
/**
* SECURITY: Redact tokens / secrets from arbitrary strings before logging.
*
* Matches common token-like patterns (Bearer tokens, long hex/base64
* strings, JWT-shaped values) and replaces them with [REDACTED].
* This prevents accidental credential leakage in console output.
*/
const TOKEN_PATTERNS = [
// Bearer <token> in any casing
/Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi,
// JWT-shaped: three base64url segments separated by dots
/eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
// Long hex strings (32+ chars) that look like tokens
/[0-9a-f]{32,}/gi,
// Long base64-like strings (40+ chars) that look like tokens
/[A-Za-z0-9+/]{40,}={0,3}/g,
// Matrix access tokens (syt_ prefix)
/syt_[A-Za-z0-9_\-/.]+/g,
];
export function redactTokens(str) {
if (typeof str !== 'string') return str;
let redacted = str;
for (const pattern of TOKEN_PATTERNS) {
// Reset lastIndex for global regexes
pattern.lastIndex = 0;
redacted = redacted.replace(pattern, '[REDACTED]');
}
return redacted;
}
/**
* SECURITY: Safe error logging — redacts tokens before writing to stderr.
* All command files should use this instead of raw console.error when
* the message might contain server responses or token values.
*/
export function safeError(msg) {
console.error(redactTokens(String(msg)));
}
// ---------- URL validation ----------
/**
* SECURITY: Validate that a URL is a well-formed HTTPS URL.
*
* Rejects http:// in production to prevent credentials from being sent
* over unencrypted connections. Allows http://localhost for local
* development and the SSO callback server only.
*
* @param {string} urlStr — the URL to validate
* @param {string} label — human-readable label for error messages
* @throws {Error} if the URL is invalid or uses plain HTTP in production
*/
export function validateHttpsUrl(urlStr, label = 'URL') {
if (!urlStr || typeof urlStr !== 'string') {
throw new Error(`${label} is required and must be a non-empty string.`);
}
let parsed;
try {
parsed = new URL(urlStr);
} catch {
throw new Error(`${label} is not a valid URL: ${urlStr}`);
}
// Allow http only for localhost (local dev / SSO callback)
const isLocalhost = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
if (parsed.protocol === 'http:' && !isLocalhost) {
throw new Error(
`${label} must use HTTPS in production (got ${urlStr}). ` +
'Plain HTTP exposes credentials on the network.'
);
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw new Error(`${label} must use HTTPS (got protocol "${parsed.protocol}").`);
}
return parsed;
}
// ---------- Matrix room-ID validation ----------
/**
* SECURITY: Validate that a Matrix room identifier has the expected format.
*
* Room IDs look like !abc123:server.example.com
* Room aliases look like #engineering:server.example.com
* Anything else is rejected to prevent injection or confusion.
*/
export function validateRoomId(room) {
if (!room || typeof room !== 'string') {
throw new Error('Room identifier is required.');
}
// Must start with ! (room ID) or # (room alias)
if (!room.startsWith('!') && !room.startsWith('#')) {
throw new Error(
`Invalid room identifier "${room}". ` +
'Room IDs must start with "!" and aliases must start with "#".'
);
}
// Basic format: prefix + localpart + : + server
const roomPattern = /^[!#][A-Za-z0-9._=\-/]+:[A-Za-z0-9.\-]+(:[0-9]+)?$/;
if (!roomPattern.test(room)) {
throw new Error(
`Invalid room identifier format "${room}". ` +
'Expected format: !localpart:server or #alias:server'
);
}
return room;
}
// ---------- timing-safe string comparison ----------
/**
* SECURITY: Constant-time string comparison to prevent timing attacks.
*
* Used for comparing OAuth state parameters. A naive === comparison
* leaks information about how many leading bytes matched, which an
* attacker can exploit to guess the state token one byte at a time.
*/
export function timingSafeCompare(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
// timingSafeEqual requires buffers of equal length
const bufA = Buffer.from(a, 'utf-8');
const bufB = Buffer.from(b, 'utf-8');
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}