daisy-hook: accept a full "Name: value" header setting, redact the key from logs, retry once; start: brand icons on link and service tiles (trimmed simple-icons + saved favicons, monogram fallback)
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { daisyHookConfig, notifyDaisy } from './daisy-hook';
|
||||
import { daisyHookConfig, notifyDaisy, redact } from './daisy-hook';
|
||||
|
||||
describe('daisy task webhook', () => {
|
||||
it('is off until both URL and key are set', () => {
|
||||
@@ -49,3 +49,35 @@ describe('daisy task webhook', () => {
|
||||
expect(bad?.headers.Authorization).toBe('Bearer k1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('daisy task webhook hardening', () => {
|
||||
it('accepts a full "Authorization: Bearer <key>" line as the header setting', () => {
|
||||
const config = daisyHookConfig({
|
||||
DAISY_TASK_WEBHOOK_URL: 'https://x/hook',
|
||||
DAISY_TASK_WEBHOOK_KEY: 'k9',
|
||||
DAISY_TASK_WEBHOOK_HEADER: 'Authorization: Bearer k9'
|
||||
});
|
||||
expect(Object.keys(config?.headers ?? {})).toEqual(['content-type', 'Authorization']);
|
||||
expect(config?.headers.Authorization).toBe('Bearer k9');
|
||||
});
|
||||
|
||||
it('never logs the key', () => {
|
||||
expect(redact("Invalid header 'Authorization: Bearer crsr_abc123'", ['k'])).not.toContain(
|
||||
'crsr_abc123'
|
||||
);
|
||||
expect(redact('failed for supersecret', ['supersecret'])).toBe('failed for [redacted]');
|
||||
});
|
||||
|
||||
it('retries once after a failure', async () => {
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 502 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
const config = daisyHookConfig({
|
||||
DAISY_TASK_WEBHOOK_URL: 'https://x',
|
||||
DAISY_TASK_WEBHOOK_KEY: 'k'
|
||||
});
|
||||
notifyDaisy({ id: 1, title: 't' }, config, fetcher as unknown as typeof fetch, 0);
|
||||
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,45 +3,76 @@
|
||||
// DAISY_TASK_WEBHOOK_URL where to POST {task_id, title}
|
||||
// DAISY_TASK_WEBHOOK_KEY secret sent with it
|
||||
// DAISY_TASK_WEBHOOK_HEADER header name (default Authorization, sent as "Bearer <key>";
|
||||
// any other header gets the bare key)
|
||||
// any other header gets the bare key). A full "Name: value"
|
||||
// line is also accepted (only the name is used).
|
||||
// Unset URL or key: nothing is sent and the task is still saved.
|
||||
|
||||
export type DaisyHookConfig = { url: string; headers: Record<string, string> };
|
||||
export type DaisyHookConfig = { url: string; headers: Record<string, string>; secrets: string[] };
|
||||
|
||||
const HEADER_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
|
||||
|
||||
export function daisyHookConfig(env: Record<string, string | undefined> = process.env) {
|
||||
const url = env.DAISY_TASK_WEBHOOK_URL?.trim();
|
||||
const key = env.DAISY_TASK_WEBHOOK_KEY?.trim();
|
||||
if (!url || !key) return null;
|
||||
// Header name only. Tolerate a full "Name: value" line (the value part is ignored; the key
|
||||
// comes from DAISY_TASK_WEBHOOK_KEY) and fall back to Authorization for anything invalid.
|
||||
const rawHeader = env.DAISY_TASK_WEBHOOK_HEADER?.split(':')[0].trim() ?? '';
|
||||
const header = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(rawHeader) ? rawHeader : 'Authorization';
|
||||
// The header once got set to a whole "Authorization: Bearer <key>" line: keep only the name.
|
||||
let header = (env.DAISY_TASK_WEBHOOK_HEADER ?? '').split(':')[0].trim() || 'Authorization';
|
||||
if (!HEADER_NAME.test(header)) {
|
||||
console.error(
|
||||
'[daisy-hook] DAISY_TASK_WEBHOOK_HEADER is not a valid header name, using Authorization'
|
||||
);
|
||||
header = 'Authorization';
|
||||
}
|
||||
const value = header.toLowerCase() === 'authorization' ? `Bearer ${key}` : key;
|
||||
return { url, headers: { 'content-type': 'application/json', [header]: value } };
|
||||
return {
|
||||
url,
|
||||
headers: { 'content-type': 'application/json', [header]: value },
|
||||
secrets: [key, url]
|
||||
} satisfies DaisyHookConfig;
|
||||
}
|
||||
|
||||
/** Fire and forget: never awaited by the request, 3 s timeout, failures only logged. */
|
||||
/** Strip the key and URL (it may carry a token) from anything that gets logged. */
|
||||
export function redact(message: string, secrets: string[] = []) {
|
||||
let out = message;
|
||||
for (const s of secrets) if (s) out = out.split(s).join('[redacted]');
|
||||
return out
|
||||
.replace(/crsr_[A-Za-z0-9]+/g, '[redacted]')
|
||||
.replace(/Bearer\s+[^\s'"]+/gi, 'Bearer [redacted]');
|
||||
}
|
||||
|
||||
async function post(config: DaisyHookConfig, body: string, fetcher: typeof fetch) {
|
||||
try {
|
||||
const res = await fetcher(config.url, {
|
||||
method: 'POST',
|
||||
headers: config.headers,
|
||||
body,
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
return res.ok ? null : `HTTP ${res.status}`;
|
||||
} catch (err: unknown) {
|
||||
// Name/code only: some fetch errors echo request headers (and so the key).
|
||||
const e = err as { name?: string; cause?: { code?: string } };
|
||||
return `${e?.name ?? 'Error'} ${e?.cause?.code ?? ''}`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire and forget: never awaited by the request; one retry after 2 s; failures logged redacted. */
|
||||
export function notifyDaisy(
|
||||
task: { id: number; title: string },
|
||||
config: DaisyHookConfig | null = daisyHookConfig(),
|
||||
fetcher: typeof fetch = fetch
|
||||
fetcher: typeof fetch = fetch,
|
||||
retryDelayMs = 2000
|
||||
): 'sent' | 'not-configured' {
|
||||
if (!config) return 'not-configured';
|
||||
fetcher(config.url, {
|
||||
method: 'POST',
|
||||
headers: config.headers,
|
||||
body: JSON.stringify({ task_id: task.id, title: task.title }),
|
||||
signal: AbortSignal.timeout(3000)
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) console.error(`[daisy-hook] task ${task.id}: HTTP ${res.status}`);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// Name/code only: some fetch errors echo request headers (and so the key).
|
||||
const e = err as { name?: string; cause?: { code?: string } };
|
||||
console.error(
|
||||
`[daisy-hook] task ${task.id}: ${e?.name ?? 'Error'} ${e?.cause?.code ?? ''}`.trim()
|
||||
);
|
||||
});
|
||||
const body = JSON.stringify({ task_id: task.id, title: task.title });
|
||||
void (async () => {
|
||||
let failure = await post(config, body, fetcher);
|
||||
if (failure) {
|
||||
await new Promise((r) => setTimeout(r, retryDelayMs));
|
||||
failure = await post(config, body, fetcher);
|
||||
}
|
||||
if (failure) console.error(`[daisy-hook] task ${task.id}: ${redact(failure, config.secrets)}`);
|
||||
else console.log(`[daisy-hook] task ${task.id}: delivered`);
|
||||
})();
|
||||
return 'sent';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user