summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/core/src')
-rw-r--r--packages/core/src/notifications/ntfy.ts56
1 files changed, 44 insertions, 12 deletions
diff --git a/packages/core/src/notifications/ntfy.ts b/packages/core/src/notifications/ntfy.ts
index 07ce33b..1f5101c 100644
--- a/packages/core/src/notifications/ntfy.ts
+++ b/packages/core/src/notifications/ntfy.ts
@@ -28,9 +28,17 @@ export type FetchLike = (
) => Promise<{ ok: boolean; status: number; statusText?: string; text(): Promise<string> }>;
/**
- * Validate a ntfy topic URL. Accepts only `http(s)://host/topic` with a
- * non-empty topic path. Returns `null` on success, a human-readable error
- * string on failure.
+ * ntfy topic-name rules: 1–64 chars, ASCII alphanumerics + `-` and `_`. Sourced
+ * from the ntfy server (cf. binwiederhier/ntfy issue #1451 — longer names
+ * silently 404). Matching this client-side keeps users from saving topic URLs
+ * that look fine but only fail at publish time.
+ */
+const NTFY_TOPIC_RE = /^[A-Za-z0-9_-]{1,64}$/;
+
+/**
+ * Validate a ntfy topic URL. Accepts only `http(s)://host/topic` where
+ * `topic` is a single path segment of 1–64 chars matching `[A-Za-z0-9_-]`.
+ * Returns `null` on success, a human-readable error string on failure.
*/
export function validateTopicUrl(topicUrl: string): string | null {
const trimmed = topicUrl.trim();
@@ -44,9 +52,15 @@ export function validateTopicUrl(topicUrl: string): string | null {
if (url.protocol !== "http:" && url.protocol !== "https:") {
return "Topic URL must use http:// or https://";
}
- // Path must be a non-empty topic (more than just "/")
+ // Path must be exactly one topic segment.
const topic = url.pathname.replace(/^\/+|\/+$/g, "");
if (!topic) return "Topic URL must include a topic name (e.g. https://ntfy.sh/my-topic)";
+ if (topic.includes("/")) {
+ return "Topic URL must point at a single topic (no extra path segments)";
+ }
+ if (!NTFY_TOPIC_RE.test(topic)) {
+ return "Topic name must be 1–64 characters, letters/numbers/underscore/hyphen only";
+ }
return null;
}
@@ -79,17 +93,17 @@ export async function sendNtfy(
}
const headers: Record<string, string> = {
- // ntfy treats the title/priority/tags/click headers as ASCII-only. Strip
- // control chars from the title; the body is sent UTF-8 verbatim.
+ // ntfy is tolerant of non-ASCII in the Title header but many proxies
+ // aren't — sanitizeHeader strips CR/LF/control chars (injection guard)
+ // and leaves UTF-8 in place. Body is sent verbatim as UTF-8.
Title: sanitizeHeader(event.title),
Priority: String(priority),
"Content-Type": "text/plain; charset=utf-8",
};
if (tags.length > 0) headers.Tags = tags.map(sanitizeHeader).join(",");
- if (event.clickUrl) headers.Click = event.clickUrl;
- if (config.authToken && config.authToken.trim() !== "") {
- headers.Authorization = `Bearer ${config.authToken.trim()}`;
- }
+ if (event.clickUrl) headers.Click = sanitizeHeader(event.clickUrl);
+ const authValue = buildAuthHeaderValue(config.authToken);
+ if (authValue) headers.Authorization = authValue;
// Per-request abort so a hung server doesn't pin a Bun worker forever.
const controller = new AbortController();
@@ -119,9 +133,27 @@ export async function sendNtfy(
}
}
+/**
+ * Build the `Authorization` header value from the user-configured token.
+ *
+ * - Empty/blank ⇒ no header.
+ * - Already starts with `Bearer ` / `Basic ` / etc. (RFC 7235 scheme + space)
+ * ⇒ used verbatim. Lets users paste a complete header for Basic auth or
+ * any other scheme their private ntfy server supports.
+ * - Otherwise ⇒ prefixed with `Bearer ` (the common case).
+ */
+function buildAuthHeaderValue(rawToken: string): string | null {
+ const trimmed = (rawToken ?? "").trim();
+ if (!trimmed) return null;
+ if (/^[A-Za-z][A-Za-z0-9._~+/-]*\s+\S/.test(trimmed)) {
+ // Already includes a scheme token (e.g. "Bearer xyz", "Basic dXNlcjpw").
+ return sanitizeHeader(trimmed);
+ }
+ return `Bearer ${sanitizeHeader(trimmed)}`;
+}
+
function sanitizeHeader(value: string): string {
- // Strip CR/LF (header injection guard) and trim. ntfy is tolerant of
- // non-ASCII in titles, but we still drop control chars.
+ // Strip CR/LF (header injection guard) and other control chars, then trim.
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional
return value.replace(/[\r\n\u0000-\u001f]+/g, " ").trim();
}