Skip to content

Commit 1e613c3

Browse files
Merge pull request #7 from godaddy/fix/security-hardening
fix: security hardening across auth, API, keychain, and error output
2 parents 5279280 + c27dcbd commit 1e613c3

6 files changed

Lines changed: 171 additions & 26 deletions

File tree

src/cli/commands/api.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
parseFieldsEffect,
1010
parseHeadersEffect,
1111
readBodyFromFileEffect,
12+
sanitizeResponseHeaders,
1213
} from "../../core/api";
1314
import { authLoginEffect, getTokenInfoEffect } from "../../core/auth";
1415
import { AuthenticationError, ValidationError } from "../../effect/errors";
@@ -318,7 +319,9 @@ const apiCommand = Command.make(
318319
method,
319320
status: response.status,
320321
status_text: response.statusText,
321-
headers: config.include ? response.headers : undefined,
322+
headers: config.include
323+
? sanitizeResponseHeaders(response.headers)
324+
: undefined,
322325
data: output ?? null,
323326
},
324327
apiRequestActions,

src/core/api.ts

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,60 @@ import { type Environment, envGetEffect, getApiUrl } from "./environment";
1616
// Minimum seconds before expiry to consider token valid for a request
1717
const TOKEN_EXPIRY_BUFFER_SECONDS = 30;
1818

19+
// Header names (lowercased) that must be redacted from debug output and
20+
// the --include envelope to prevent leaking tokens, cookies, or secrets.
21+
const SENSITIVE_HEADER_PARTS = [
22+
"authorization",
23+
"cookie",
24+
"set-cookie",
25+
"token",
26+
"secret",
27+
"api-key",
28+
"apikey",
29+
"x-auth",
30+
] as const;
31+
32+
function isSensitiveHeader(headerName: string): boolean {
33+
const lower = headerName.toLowerCase();
34+
return SENSITIVE_HEADER_PARTS.some((part) => lower.includes(part));
35+
}
36+
37+
/**
38+
* Return a copy of headers with sensitive values replaced by "[REDACTED]".
39+
*/
40+
export { sanitizeHeaders as sanitizeResponseHeaders };
41+
42+
function sanitizeHeaders(
43+
headers: Record<string, string>,
44+
): Record<string, string> {
45+
const sanitized: Record<string, string> = {};
46+
for (const [key, value] of Object.entries(headers)) {
47+
sanitized[key] = isSensitiveHeader(key) ? "[REDACTED]" : value;
48+
}
49+
return sanitized;
50+
}
51+
52+
/**
53+
* Redact values whose keys look like they contain secrets.
54+
*/
55+
function redactSensitiveBodyFields(body: string): string {
56+
try {
57+
const parsed = JSON.parse(body);
58+
if (typeof parsed !== "object" || parsed === null) return body;
59+
const redacted: Record<string, unknown> = {};
60+
for (const [key, value] of Object.entries(parsed)) {
61+
const lower = key.toLowerCase();
62+
const isSensitive = SENSITIVE_HEADER_PARTS.some((part) =>
63+
lower.includes(part),
64+
) || lower.includes("password") || lower.includes("credential");
65+
redacted[key] = isSensitive ? "[REDACTED]" : value;
66+
}
67+
return JSON.stringify(redacted);
68+
} catch {
69+
return body;
70+
}
71+
}
72+
1973
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
2074

2175
export interface ApiRequestOptions {
@@ -265,13 +319,12 @@ export function apiRequestEffect(
265319

266320
if (debug) {
267321
console.error(`> ${method} ${url}`);
268-
for (const [key, value] of Object.entries(requestHeaders)) {
269-
const displayValue =
270-
key.toLowerCase() === "authorization" ? "Bearer [REDACTED]" : value;
271-
console.error(`> ${key}: ${displayValue}`);
322+
const sanitizedRequestHeaders = sanitizeHeaders(requestHeaders);
323+
for (const [key, value] of Object.entries(sanitizedRequestHeaders)) {
324+
console.error(`> ${key}: ${value}`);
272325
}
273326
if (requestBody) {
274-
console.error(`> Body: ${requestBody}`);
327+
console.error(`> Body: ${redactSensitiveBodyFields(requestBody)}`);
275328
}
276329
console.error("");
277330
}
@@ -302,7 +355,8 @@ export function apiRequestEffect(
302355

303356
if (debug) {
304357
console.error(`< ${response.status} ${response.statusText}`);
305-
for (const [key, value] of Object.entries(responseHeaders)) {
358+
const sanitizedResponseHeaders = sanitizeHeaders(responseHeaders);
359+
for (const [key, value] of Object.entries(sanitizedResponseHeaders)) {
306360
console.error(`< ${key}: ${value}`);
307361
}
308362
console.error("");
@@ -340,7 +394,9 @@ export function apiRequestEffect(
340394

341395
// Check for error status codes
342396
if (!response.ok) {
343-
const errorMessage =
397+
// Internal message includes the raw server payload for debugging;
398+
// userMessage is a safe, generic description shown to users/agents.
399+
const internalDetail =
344400
typeof data === "object" && data !== null
345401
? JSON.stringify(data)
346402
: String(data || response.statusText);
@@ -349,7 +405,7 @@ export function apiRequestEffect(
349405
if (response.status === 401) {
350406
return yield* Effect.fail(
351407
new AuthenticationError({
352-
message: `Authentication failed (401): ${errorMessage}`,
408+
message: `Authentication failed (401): ${internalDetail}`,
353409
userMessage:
354410
"Your session has expired or is invalid. Run 'godaddy auth login' to re-authenticate.",
355411
}),
@@ -360,7 +416,7 @@ export function apiRequestEffect(
360416
if (response.status === 403) {
361417
return yield* Effect.fail(
362418
new AuthenticationError({
363-
message: `Access denied (403): ${errorMessage}`,
419+
message: `Access denied (403): ${internalDetail}`,
364420
userMessage:
365421
"You don't have permission to access this resource. Check your account permissions.",
366422
}),
@@ -369,7 +425,7 @@ export function apiRequestEffect(
369425

370426
return yield* Effect.fail(
371427
new NetworkError({
372-
message: `API error (${response.status}): ${errorMessage}`,
428+
message: `API error (${response.status}): ${internalDetail}`,
373429
userMessage: `API request failed with status ${response.status}: ${response.statusText}`,
374430
}),
375431
);

src/core/auth.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,21 @@ import {
2525
} from "./token-store";
2626

2727
const PORT = 7443;
28+
const AUTH_HOST = "127.0.0.1";
29+
const AUTH_TIMEOUT_MS = 120_000;
2830
const DEFAULT_OAUTH_SCOPES = "apps.app-registry:read apps.app-registry:write";
2931

32+
/**
33+
* Escape a string for safe embedding in HTML text content.
34+
*/
35+
function escapeHtml(text: string): string {
36+
return text
37+
.replace(/&/g, "&amp;")
38+
.replace(/</g, "&lt;")
39+
.replace(/>/g, "&gt;")
40+
.replace(/"/g, "&quot;");
41+
}
42+
3043
export interface AuthResult {
3144
success: boolean;
3245
accessToken?: string;
@@ -217,10 +230,11 @@ export function authLoginEffect(options?: {
217230
"Content-Type": "text/html",
218231
});
219232
res.end(
220-
`<html><body><h1>Authentication Failed</h1><p>${errorMessage}</p></body></html>`,
233+
`<html><body><h1>Authentication Failed</h1><p>${escapeHtml(errorMessage)}</p></body></html>`,
221234
);
222235
reject(err);
223236
} finally {
237+
clearTimeout(authTimeout);
224238
if (server) server.close();
225239
}
226240
} else {
@@ -234,7 +248,20 @@ export function authLoginEffect(options?: {
234248
reject(err);
235249
});
236250

237-
server.listen(PORT, () => {
251+
// Auto-close after timeout if the user never completes the flow
252+
const authTimeout = setTimeout(() => {
253+
if (server) {
254+
server.close();
255+
server = null;
256+
}
257+
reject(
258+
new Error(
259+
"Login timed out. The authentication flow was not completed.",
260+
),
261+
);
262+
}, AUTH_TIMEOUT_MS);
263+
264+
server.listen(PORT, AUTH_HOST, () => {
238265
const actualPort = (server?.address() as import("net").AddressInfo)
239266
?.port;
240267
if (!actualPort) {

src/effect/layers/keychain-native.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,9 @@ function createWindowsKeychain(): KeychainService {
288288

289289
return {
290290
async setPassword(service, account, password) {
291+
assertSafePSValue(service, "service");
292+
assertSafePSValue(account, "account");
293+
assertSafePSValue(password, "password");
291294
// Remove existing entry first (PasswordVault throws on duplicate)
292295
const removeScript = `
293296
${vaultInit}
@@ -307,6 +310,8 @@ function createWindowsKeychain(): KeychainService {
307310
},
308311

309312
async getPassword(service, account) {
313+
assertSafePSValue(service, "service");
314+
assertSafePSValue(account, "account");
310315
const script = `
311316
${vaultInit}
312317
try {
@@ -325,6 +330,8 @@ function createWindowsKeychain(): KeychainService {
325330
},
326331

327332
async deletePassword(service, account) {
333+
assertSafePSValue(service, "service");
334+
assertSafePSValue(account, "account");
328335
const script = `
329336
${vaultInit}
330337
try {
@@ -339,6 +346,7 @@ function createWindowsKeychain(): KeychainService {
339346
},
340347

341348
async findCredentials(service) {
349+
assertSafePSValue(service, "service");
342350
const script = `
343351
${vaultInit}
344352
try {
@@ -370,9 +378,33 @@ function createWindowsKeychain(): KeychainService {
370378
};
371379
}
372380

373-
/** Escape single quotes for PowerShell string literals. */
381+
/**
382+
* Escape a value for embedding inside PowerShell single-quoted strings.
383+
*
384+
* PowerShell single-quoted strings only interpret '' as a literal '.
385+
* However, we must also strip NUL bytes (which can truncate strings in
386+
* some contexts) and reject values containing characters that could break
387+
* out of the quoting context if the script template is ever changed to
388+
* use double-quotes or here-strings.
389+
*/
374390
function escapePS(value: string): string {
375-
return value.replace(/'/g, "''");
391+
// Strip NUL bytes that could truncate the string
392+
const stripped = value.replace(/\0/g, "");
393+
// In PowerShell single-quoted strings, only ' needs escaping (as '').
394+
return stripped.replace(/'/g, "''");
395+
}
396+
397+
/**
398+
* Validate that a value is safe for PowerShell interpolation.
399+
* Rejects values containing newlines or control characters that could
400+
* escape the single-line script template.
401+
*/
402+
function assertSafePSValue(value: string, label: string): void {
403+
if (/[\r\n]/.test(value)) {
404+
throw new Error(
405+
`Unsafe ${label} for credential storage: contains newline characters`,
406+
);
407+
}
376408
}
377409

378410
// ---------------------------------------------------------------------------

src/services/applications.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,8 @@ export const releaseInput = type({
175175
});
176176

177177
/**
178-
* Extract a user-friendly error message from a GraphQL ClientError
178+
* Extract the internal error detail from a GraphQL ClientError.
179+
* This may include server-side messages and extension codes.
179180
*/
180181
function extractGraphQLError(err: unknown): string {
181182
if (err instanceof ClientError) {
@@ -189,6 +190,29 @@ function extractGraphQLError(err: unknown): string {
189190
return "An unexpected error occurred";
190191
}
191192

193+
/**
194+
* Return a safe, generic user-facing message for a GraphQL error.
195+
* Avoids leaking internal server details in the CLI JSON envelope.
196+
*/
197+
function safeGraphQLUserMessage(err: unknown): string {
198+
if (err instanceof ClientError) {
199+
const status = err.response.status;
200+
if (status === 401 || status === 403)
201+
return "Authentication failed. Run 'godaddy auth login'.";
202+
if (status === 404)
203+
return "The requested resource was not found.";
204+
if (status && status >= 500)
205+
return "The server encountered an error. Please try again later.";
206+
// For 4xx with GraphQL-level error messages, allow the first message
207+
// through since these are validation-style errors the user can act on.
208+
const graphqlErrors = err.response.errors;
209+
if (graphqlErrors?.length && graphqlErrors[0].message) {
210+
return graphqlErrors[0].message;
211+
}
212+
}
213+
return "An unexpected error occurred";
214+
}
215+
192216
export function createApplicationEffect(
193217
input: typeof applicationInput.infer,
194218
{ accessToken }: { accessToken: string | null },
@@ -225,7 +249,7 @@ export function createApplicationEffect(
225249
catch: (err) =>
226250
new NetworkError({
227251
message: extractGraphQLError(err),
228-
userMessage: extractGraphQLError(err),
252+
userMessage: safeGraphQLUserMessage(err),
229253
}),
230254
});
231255
});
@@ -258,7 +282,7 @@ export function updateApplicationEffect(
258282
catch: (err) =>
259283
new NetworkError({
260284
message: extractGraphQLError(err),
261-
userMessage: extractGraphQLError(err),
285+
userMessage: safeGraphQLUserMessage(err),
262286
}),
263287
});
264288
});
@@ -290,7 +314,7 @@ export function getApplicationEffect(
290314
catch: (err) =>
291315
new NetworkError({
292316
message: extractGraphQLError(err),
293-
userMessage: extractGraphQLError(err),
317+
userMessage: safeGraphQLUserMessage(err),
294318
}),
295319
});
296320
});
@@ -322,7 +346,7 @@ export function getApplicationAndLatestReleaseEffect(
322346
catch: (err) =>
323347
new NetworkError({
324348
message: extractGraphQLError(err),
325-
userMessage: extractGraphQLError(err),
349+
userMessage: safeGraphQLUserMessage(err),
326350
}),
327351
});
328352
});
@@ -370,7 +394,7 @@ export function createReleaseEffect(
370394
catch: (err) =>
371395
new NetworkError({
372396
message: extractGraphQLError(err),
373-
userMessage: extractGraphQLError(err),
397+
userMessage: safeGraphQLUserMessage(err),
374398
}),
375399
});
376400
});
@@ -402,7 +426,7 @@ export function enableApplicationEffect(
402426
catch: (err) =>
403427
new NetworkError({
404428
message: extractGraphQLError(err),
405-
userMessage: extractGraphQLError(err),
429+
userMessage: safeGraphQLUserMessage(err),
406430
}),
407431
});
408432
});
@@ -434,7 +458,7 @@ export function disableApplicationEffect(
434458
catch: (err) =>
435459
new NetworkError({
436460
message: extractGraphQLError(err),
437-
userMessage: extractGraphQLError(err),
461+
userMessage: safeGraphQLUserMessage(err),
438462
}),
439463
});
440464
});
@@ -465,7 +489,7 @@ export function listApplicationsEffect({
465489
catch: (err) =>
466490
new NetworkError({
467491
message: extractGraphQLError(err),
468-
userMessage: extractGraphQLError(err),
492+
userMessage: safeGraphQLUserMessage(err),
469493
}),
470494
});
471495
});
@@ -497,7 +521,7 @@ export function archiveApplicationEffect(
497521
catch: (err) =>
498522
new NetworkError({
499523
message: extractGraphQLError(err),
500-
userMessage: extractGraphQLError(err),
524+
userMessage: safeGraphQLUserMessage(err),
501525
}),
502526
});
503527
});

0 commit comments

Comments
 (0)