@mulmoclaude/common 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/dist/envScan.d.ts +11 -0
- package/dist/envScan.js +62 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +57 -0
- package/dist/logger.d.ts +14 -0
- package/dist/logger.js +6 -0
- package/dist/meta-webhook.d.ts +16 -0
- package/dist/meta-webhook.js +58 -0
- package/dist/ssrf.d.ts +28 -0
- package/dist/ssrf.js +139 -0
- package/package.json +28 -1
package/README.md
CHANGED
|
@@ -29,5 +29,46 @@ duplicated," so they live here once.
|
|
|
29
29
|
| `hasStringProp(v, k)` | `Record<k, string>` | key present with a string value |
|
|
30
30
|
| `hasNumberProp(v, k)` | `Record<k, number>` | key present with a number value |
|
|
31
31
|
|
|
32
|
+
Plus CSV/env helpers `parseCsvList(raw, { lowercase? })` and
|
|
33
|
+
`parseCsvSet(raw, { lowercase? })` (empty set = "allow all" sentinel), and the
|
|
34
|
+
helpers below.
|
|
35
|
+
|
|
36
|
+
| Helper | Returns | Notes |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `errorMessage(v, fallback?)` | `string` | unknown caught value → human-readable string; **isomorphic**, so Vue/browser surfaces use it too |
|
|
39
|
+
| `toUtcIsoDate(timestamp)` | `string` | `Date` → `YYYY-MM-DD` in UTC — for dates that must not shift with the host's local timezone |
|
|
40
|
+
|
|
41
|
+
`errorMessage` surfaces a non-empty string `details` (gRPC convention) or
|
|
42
|
+
`message` field of a non-Error object (`details` wins) instead of
|
|
43
|
+
`[object Object]`; `fallback` covers a thrown non-Error at an error boundary.
|
|
44
|
+
This is the single home the #2217 consolidation could not reach, because
|
|
45
|
+
`@mulmoclaude/core/utils` is server-only — core now re-exports this one.
|
|
46
|
+
|
|
32
47
|
`isRecord` vs `isObj`: use `isRecord` whenever you go on to index string keys —
|
|
33
48
|
`isObj` lets arrays through, which is rarely what you want for a JSON payload.
|
|
49
|
+
|
|
50
|
+
## Shared types
|
|
51
|
+
|
|
52
|
+
`src/logger.ts` (re-exported from the root entry) holds the canonical logger
|
|
53
|
+
interface family:
|
|
54
|
+
|
|
55
|
+
| Type | Shape | Notes |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `StructuredLogger` | `error`/`warn`/`info`/`debug` `(prefix, message, data?)` | The host logger, and every engine/plugin logger injected from it |
|
|
58
|
+
| `MinimalLogger` | `info`/`warn`/`error` `(message, data?)` | For a package that already namespaces its own entries |
|
|
59
|
+
|
|
60
|
+
They live here because the shape spans all three tiers — host `server/`, the
|
|
61
|
+
plugins, and `@mulmoclaude/core` — so no tier above the leaf can own the
|
|
62
|
+
declaration without becoming an uphill import for the others (#2486). Domains
|
|
63
|
+
keep their own exported name by **aliasing** rather than re-declaring
|
|
64
|
+
(`export type FeedsLogger = StructuredLogger`, or a
|
|
65
|
+
`Pick<MinimalLogger, "warn" | "error">` subset): TypeScript is structurally
|
|
66
|
+
typed, so the alias preserves the public name and the emitted `.d.ts` exactly.
|
|
67
|
+
|
|
68
|
+
## Related projects
|
|
69
|
+
|
|
70
|
+
Published from the MulmoClaude monorepo by [Receptron](https://github.com/receptron).
|
|
71
|
+
|
|
72
|
+
- **[MulmoClaude](https://github.com/receptron/mulmoclaude)** — an open-source AI assistant platform that runs on your own computer. Claude Code as the engine, a personal wiki for long-term memory, schema-driven collections for your data, and chat that summons the right GUI (markdown, charts, forms, spreadsheets, wikis) for each task.
|
|
73
|
+
- **[MulmoTerminal](https://github.com/receptron/mulmoterminal)** — a terminal-first cockpit for running many AI coding agents in parallel. One roster showing every session's summary and PR status, tmux-backed session persistence, git-worktree isolation, one-click PRs, and mobile push with remote reply.
|
|
74
|
+
- **[MulmoTerminal manual](https://receptron.github.io/mulmoterminal/)** — setup, workflows, feature reference, configuration, mobile notifications, and alternative / local model providers. Available in English and Japanese.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ScanEnvOptionsConfig {
|
|
2
|
+
/** Ordered LOW → HIGH precedence — on a key clash the later prefix wins. */
|
|
3
|
+
prefixes: readonly string[];
|
|
4
|
+
/** Closed set of emitted keys (lowerCamel). Absent = emit every key. */
|
|
5
|
+
allowKeys?: ReadonlySet<string>;
|
|
6
|
+
}
|
|
7
|
+
/** Convert `UPPER_SNAKE_CASE` → `lowerCamelCase`. Adjacent underscores
|
|
8
|
+
* collapse to a single word break; empty / all-underscore input → `""`. */
|
|
9
|
+
export declare function snakeToLowerCamel(snake: string): string;
|
|
10
|
+
/** Scrape prefixed env vars into a lowerCamel-keyed string bag. */
|
|
11
|
+
export declare function scanEnvOptions(env: Readonly<Record<string, string | undefined>>, config: ScanEnvOptionsConfig): Record<string, string>;
|
package/dist/envScan.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The one env-scrape algorithm (#2487) behind the prefixed options bags:
|
|
2
|
+
// `readBridgeEnvOptions` (@mulmobridge/client) and the host's
|
|
3
|
+
// `resolveRelayBridgeOptions` are thin wrappers over `scanEnvOptions`.
|
|
4
|
+
//
|
|
5
|
+
// Semantics both wrappers rely on:
|
|
6
|
+
// - `prefixes` are ordered LOW → HIGH precedence: when two vars yield the
|
|
7
|
+
// same key, the value from the later prefix wins.
|
|
8
|
+
// - The highest-precedence prefix that matches a name CLAIMS it. A claimed
|
|
9
|
+
// name with an empty tail is dropped outright — never retried against a
|
|
10
|
+
// lower-precedence prefix.
|
|
11
|
+
// - Empty-string values are dropped so a stray `FOO=""` doesn't shadow
|
|
12
|
+
// another var's match.
|
|
13
|
+
// - Tails convert `UPPER_SNAKE` → `lowerCamel`; an all-underscore tail
|
|
14
|
+
// camelises to `""` and is dropped.
|
|
15
|
+
// - `allowKeys` (lowerCamel form), when present, drops every other key.
|
|
16
|
+
// This is how the relay wrapper keeps `RELAY_TOKEN` / `RELAY_URL`
|
|
17
|
+
// infrastructure secrets out of a bag that is forwarded to the agent
|
|
18
|
+
// and may be logged — treat the filter as a security boundary.
|
|
19
|
+
/** Convert `UPPER_SNAKE_CASE` → `lowerCamelCase`. Adjacent underscores
|
|
20
|
+
* collapse to a single word break; empty / all-underscore input → `""`. */
|
|
21
|
+
export function snakeToLowerCamel(snake) {
|
|
22
|
+
const parts = snake
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.split("_")
|
|
25
|
+
.filter((segment) => segment.length > 0);
|
|
26
|
+
if (parts.length === 0)
|
|
27
|
+
return "";
|
|
28
|
+
const [head, ...rest] = parts;
|
|
29
|
+
return head + rest.map((part) => part[0].toUpperCase() + part.slice(1)).join("");
|
|
30
|
+
}
|
|
31
|
+
function claimByHighestPrefix(name, prefixes) {
|
|
32
|
+
const claimed = prefixes
|
|
33
|
+
.map((prefix, precedence) => ({ prefix, precedence }))
|
|
34
|
+
.reverse()
|
|
35
|
+
.find(({ prefix }) => name.startsWith(prefix));
|
|
36
|
+
if (claimed === undefined)
|
|
37
|
+
return null;
|
|
38
|
+
return { precedence: claimed.precedence, tail: name.slice(claimed.prefix.length) };
|
|
39
|
+
}
|
|
40
|
+
function scanEnvEntry(name, value, config) {
|
|
41
|
+
if (typeof value !== "string" || value.length === 0)
|
|
42
|
+
return null;
|
|
43
|
+
const claim = claimByHighestPrefix(name, config.prefixes);
|
|
44
|
+
if (claim === null || claim.tail.length === 0)
|
|
45
|
+
return null;
|
|
46
|
+
const key = snakeToLowerCamel(claim.tail);
|
|
47
|
+
if (key.length === 0)
|
|
48
|
+
return null;
|
|
49
|
+
if (config.allowKeys !== undefined && !config.allowKeys.has(key))
|
|
50
|
+
return null;
|
|
51
|
+
return { precedence: claim.precedence, key, value };
|
|
52
|
+
}
|
|
53
|
+
/** Scrape prefixed env vars into a lowerCamel-keyed string bag. */
|
|
54
|
+
export function scanEnvOptions(env, config) {
|
|
55
|
+
return Object.entries(env)
|
|
56
|
+
.flatMap(([name, value]) => {
|
|
57
|
+
const scanned = scanEnvEntry(name, value, config);
|
|
58
|
+
return scanned === null ? [] : [scanned];
|
|
59
|
+
})
|
|
60
|
+
.sort((left, right) => left.precedence - right.precedence)
|
|
61
|
+
.reduce((bag, { key, value }) => ({ ...bag, [key]: value }), {});
|
|
62
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -33,3 +33,34 @@ export declare function parseCsvList(raw: string | undefined, opts?: {
|
|
|
33
33
|
export declare function parseCsvSet(raw: string | undefined, opts?: {
|
|
34
34
|
lowercase?: boolean;
|
|
35
35
|
}): Set<string>;
|
|
36
|
+
/** Normalise an unknown thrown value into a human-readable string. Isomorphic
|
|
37
|
+
* (host, bridges, plugins, Vue) — this is the single home for the helper that
|
|
38
|
+
* #2217 could only consolidate for server code, since `@mulmoclaude/core/utils`
|
|
39
|
+
* is server-only.
|
|
40
|
+
*
|
|
41
|
+
* A non-Error object with a non-empty string `details` (gRPC convention) or
|
|
42
|
+
* `message` field surfaces that field — `details` wins — instead of the
|
|
43
|
+
* `[object Object]` a bare `String(err)` would print; an empty-string field
|
|
44
|
+
* falls through. `fallback` covers the error-boundary idiom where a thrown
|
|
45
|
+
* non-Error should read as a descriptive message rather than `String(err)`
|
|
46
|
+
* noise; omit it in logging contexts where `String(err)` is fine. */
|
|
47
|
+
export declare function errorMessage(err: unknown, fallback?: string): string;
|
|
48
|
+
/** `Date` → `YYYY-MM-DD` in UTC — for dates that must not shift with the
|
|
49
|
+
* host's local timezone (tool-trace search dirs, API date keys). Isomorphic
|
|
50
|
+
* single source (#2480): the host re-exports it from `server/utils/date.ts`,
|
|
51
|
+
* x-plugin imports it directly. The `@receptron/task-scheduler` copy stays
|
|
52
|
+
* local on purpose — that leaf package is published independently and kept
|
|
53
|
+
* dependency-free. Wall-clock questions use the host's `toLocalIsoDate`. */
|
|
54
|
+
export declare function toUtcIsoDate(timestamp: Date): string;
|
|
55
|
+
/** HTML-escape text destined for markup or an attribute value — the fixed
|
|
56
|
+
* five-character map, nothing more. Lives here rather than in
|
|
57
|
+
* `@mulmoclaude/core/wiki` (#2483) because `@mulmoclaude/markdown-utils` is a
|
|
58
|
+
* leaf that core depends on and so cannot import back up; core/wiki
|
|
59
|
+
* re-exports this, keeping its consumers' import path unchanged.
|
|
60
|
+
*
|
|
61
|
+
* Escaping `&` first is what makes a single pass safe — the entities this
|
|
62
|
+
* introduces contain none of the other four characters, so nothing is
|
|
63
|
+
* double-escaped. Not a sanitiser: it neither strips tags nor validates URLs. */
|
|
64
|
+
export declare function escapeHtml(value: string): string;
|
|
65
|
+
export { scanEnvOptions, snakeToLowerCamel, type ScanEnvOptionsConfig } from "./envScan.js";
|
|
66
|
+
export type { MinimalLogger, StructuredLogger } from "./logger.js";
|
package/dist/index.js
CHANGED
|
@@ -60,3 +60,60 @@ export function parseCsvList(raw, opts) {
|
|
|
60
60
|
export function parseCsvSet(raw, opts) {
|
|
61
61
|
return new Set(parseCsvList(raw, opts));
|
|
62
62
|
}
|
|
63
|
+
/** Normalise an unknown thrown value into a human-readable string. Isomorphic
|
|
64
|
+
* (host, bridges, plugins, Vue) — this is the single home for the helper that
|
|
65
|
+
* #2217 could only consolidate for server code, since `@mulmoclaude/core/utils`
|
|
66
|
+
* is server-only.
|
|
67
|
+
*
|
|
68
|
+
* A non-Error object with a non-empty string `details` (gRPC convention) or
|
|
69
|
+
* `message` field surfaces that field — `details` wins — instead of the
|
|
70
|
+
* `[object Object]` a bare `String(err)` would print; an empty-string field
|
|
71
|
+
* falls through. `fallback` covers the error-boundary idiom where a thrown
|
|
72
|
+
* non-Error should read as a descriptive message rather than `String(err)`
|
|
73
|
+
* noise; omit it in logging contexts where `String(err)` is fine. */
|
|
74
|
+
export function errorMessage(err, fallback) {
|
|
75
|
+
if (err instanceof Error)
|
|
76
|
+
return err.message;
|
|
77
|
+
if (hasStringProp(err, "details") && err.details)
|
|
78
|
+
return err.details;
|
|
79
|
+
if (hasStringProp(err, "message") && err.message)
|
|
80
|
+
return err.message;
|
|
81
|
+
if (fallback !== undefined)
|
|
82
|
+
return fallback;
|
|
83
|
+
return String(err);
|
|
84
|
+
}
|
|
85
|
+
/** `Date` → `YYYY-MM-DD` in UTC — for dates that must not shift with the
|
|
86
|
+
* host's local timezone (tool-trace search dirs, API date keys). Isomorphic
|
|
87
|
+
* single source (#2480): the host re-exports it from `server/utils/date.ts`,
|
|
88
|
+
* x-plugin imports it directly. The `@receptron/task-scheduler` copy stays
|
|
89
|
+
* local on purpose — that leaf package is published independently and kept
|
|
90
|
+
* dependency-free. Wall-clock questions use the host's `toLocalIsoDate`. */
|
|
91
|
+
export function toUtcIsoDate(timestamp) {
|
|
92
|
+
const year = timestamp.getUTCFullYear();
|
|
93
|
+
const month = String(timestamp.getUTCMonth() + 1).padStart(2, "0");
|
|
94
|
+
const day = String(timestamp.getUTCDate()).padStart(2, "0");
|
|
95
|
+
return `${year}-${month}-${day}`;
|
|
96
|
+
}
|
|
97
|
+
// A Map, not an object literal: `{}[char]` reads through the prototype chain,
|
|
98
|
+
// so a future caller widening the regex would silently get `[object Object]`
|
|
99
|
+
// for keys like `constructor`.
|
|
100
|
+
const HTML_ESCAPES = new Map([
|
|
101
|
+
["&", "&"],
|
|
102
|
+
["<", "<"],
|
|
103
|
+
[">", ">"],
|
|
104
|
+
['"', """],
|
|
105
|
+
["'", "'"],
|
|
106
|
+
]);
|
|
107
|
+
/** HTML-escape text destined for markup or an attribute value — the fixed
|
|
108
|
+
* five-character map, nothing more. Lives here rather than in
|
|
109
|
+
* `@mulmoclaude/core/wiki` (#2483) because `@mulmoclaude/markdown-utils` is a
|
|
110
|
+
* leaf that core depends on and so cannot import back up; core/wiki
|
|
111
|
+
* re-exports this, keeping its consumers' import path unchanged.
|
|
112
|
+
*
|
|
113
|
+
* Escaping `&` first is what makes a single pass safe — the entities this
|
|
114
|
+
* introduces contain none of the other four characters, so nothing is
|
|
115
|
+
* double-escaped. Not a sanitiser: it neither strips tags nor validates URLs. */
|
|
116
|
+
export function escapeHtml(value) {
|
|
117
|
+
return value.replace(/[&<>"']/g, (char) => HTML_ESCAPES.get(char) ?? char);
|
|
118
|
+
}
|
|
119
|
+
export { scanEnvOptions, snakeToLowerCamel } from "./envScan.js";
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** 4-method host-logger shape — `(prefix, message, data?)`. */
|
|
2
|
+
export interface StructuredLogger {
|
|
3
|
+
error: (prefix: string, message: string, data?: Record<string, unknown>) => void;
|
|
4
|
+
warn: (prefix: string, message: string, data?: Record<string, unknown>) => void;
|
|
5
|
+
info: (prefix: string, message: string, data?: Record<string, unknown>) => void;
|
|
6
|
+
debug: (prefix: string, message: string, data?: Record<string, unknown>) => void;
|
|
7
|
+
}
|
|
8
|
+
/** 3-method pre-namespaced logger — `(message, data?)`; the logging package
|
|
9
|
+
* binds its own prefix, so hosts only supply the transport. */
|
|
10
|
+
export interface MinimalLogger {
|
|
11
|
+
info: (message: string, data?: Record<string, unknown>) => void;
|
|
12
|
+
warn: (message: string, data?: Record<string, unknown>) => void;
|
|
13
|
+
error: (message: string, data?: Record<string, unknown>) => void;
|
|
14
|
+
}
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Canonical logger interface family. Lives in this zero-dep leaf — the only
|
|
2
|
+
// tier the host, @mulmoclaude/core domains, and plugins can all reach — so each
|
|
3
|
+
// consumer aliases ONE declaration (`export type CollectionLogger =
|
|
4
|
+
// StructuredLogger`) instead of re-declaring the shape: structural typing keeps
|
|
5
|
+
// every alias's public name and d.ts surface identical to a re-declaration.
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface MessengerTextMessage {
|
|
2
|
+
senderId: string;
|
|
3
|
+
text: string;
|
|
4
|
+
}
|
|
5
|
+
/** Every text message across all `entry[].messaging[]` events in a Messenger
|
|
6
|
+
* webhook body, skipping non-text / empty / malformed events. */
|
|
7
|
+
export declare function extractMessengerMessages(body: unknown): MessengerTextMessage[];
|
|
8
|
+
export interface WhatsAppTextMessage {
|
|
9
|
+
from: string;
|
|
10
|
+
text: {
|
|
11
|
+
body: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/** Every inbound text message across all `entry[].changes[].value.messages[]`
|
|
15
|
+
* in a WhatsApp Cloud API webhook body, skipping non-text / malformed. */
|
|
16
|
+
export declare function extractWhatsAppMessages(body: unknown): WhatsAppTextMessage[];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Pure inbound-payload parsers for the Meta messaging platforms (Facebook
|
|
2
|
+
// Messenger, WhatsApp Cloud API). Both the Node/Express bridges and the
|
|
3
|
+
// Cloudflare Workers relay receive the same webhook JSON shapes and used to
|
|
4
|
+
// carry byte-identical copies of these extractors. They live here — a
|
|
5
|
+
// zero-dependency, no-node leaf — because that is the only tier both runtimes
|
|
6
|
+
// can import: no crypto, no fetch, no Node built-ins, just `isRecord`.
|
|
7
|
+
//
|
|
8
|
+
// Signature verification is deliberately NOT here: the bridges use node:crypto
|
|
9
|
+
// and the relay uses Web Crypto (`crypto.subtle`), so it cannot be shared.
|
|
10
|
+
import { isRecord, isUnknownArray } from "./index.js";
|
|
11
|
+
function parseMessengerEvent(event) {
|
|
12
|
+
if (!isRecord(event) || !isRecord(event.sender) || typeof event.sender.id !== "string")
|
|
13
|
+
return null;
|
|
14
|
+
if (!isRecord(event.message) || typeof event.message.text !== "string")
|
|
15
|
+
return null;
|
|
16
|
+
const text = event.message.text.trim();
|
|
17
|
+
if (!text)
|
|
18
|
+
return null;
|
|
19
|
+
return { senderId: event.sender.id, text };
|
|
20
|
+
}
|
|
21
|
+
function messengerEventsOf(entry) {
|
|
22
|
+
return isRecord(entry) && isUnknownArray(entry.messaging) ? entry.messaging : [];
|
|
23
|
+
}
|
|
24
|
+
/** Every text message across all `entry[].messaging[]` events in a Messenger
|
|
25
|
+
* webhook body, skipping non-text / empty / malformed events. */
|
|
26
|
+
export function extractMessengerMessages(body) {
|
|
27
|
+
if (!isRecord(body) || !isUnknownArray(body.entry))
|
|
28
|
+
return [];
|
|
29
|
+
return body.entry
|
|
30
|
+
.flatMap(messengerEventsOf)
|
|
31
|
+
.map(parseMessengerEvent)
|
|
32
|
+
.filter((msg) => msg !== null);
|
|
33
|
+
}
|
|
34
|
+
function parseWhatsAppMessage(msg) {
|
|
35
|
+
if (!isRecord(msg) || msg.type !== "text" || typeof msg.from !== "string")
|
|
36
|
+
return null;
|
|
37
|
+
if (!isRecord(msg.text) || typeof msg.text.body !== "string" || !msg.text.body.trim())
|
|
38
|
+
return null;
|
|
39
|
+
return { from: msg.from, text: { body: msg.text.body } };
|
|
40
|
+
}
|
|
41
|
+
function whatsAppMessagesOf(change) {
|
|
42
|
+
if (!isRecord(change) || !isRecord(change.value) || !isUnknownArray(change.value.messages))
|
|
43
|
+
return [];
|
|
44
|
+
return change.value.messages;
|
|
45
|
+
}
|
|
46
|
+
function whatsAppRawMessagesOf(entry) {
|
|
47
|
+
return isRecord(entry) && isUnknownArray(entry.changes) ? entry.changes.flatMap(whatsAppMessagesOf) : [];
|
|
48
|
+
}
|
|
49
|
+
/** Every inbound text message across all `entry[].changes[].value.messages[]`
|
|
50
|
+
* in a WhatsApp Cloud API webhook body, skipping non-text / malformed. */
|
|
51
|
+
export function extractWhatsAppMessages(body) {
|
|
52
|
+
if (!isRecord(body) || !isUnknownArray(body.entry))
|
|
53
|
+
return [];
|
|
54
|
+
return body.entry
|
|
55
|
+
.flatMap(whatsAppRawMessagesOf)
|
|
56
|
+
.map(parseWhatsAppMessage)
|
|
57
|
+
.filter((msg) => msg !== null);
|
|
58
|
+
}
|
package/dist/ssrf.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** IPv4 ranges that must never be fetched, as [firstAddress, prefixLength].
|
|
2
|
+
* The union of the pre-#2459 per-guard tables — removing an entry weakens
|
|
3
|
+
* every guard in the repo at once, so treat edits as security changes. */
|
|
4
|
+
export declare const BLOCKED_IPV4_RANGES: readonly (readonly [string, number])[];
|
|
5
|
+
/** Dotted-decimal IPv4 → unsigned 32-bit value; null for anything else
|
|
6
|
+
* (hex / octal octets, whitespace, wrong part count). */
|
|
7
|
+
export declare function ipv4ToInt(address: string): number | null;
|
|
8
|
+
/** True when this parses as IPv4 AND falls inside a blocked range. Non-IPv4
|
|
9
|
+
* input is false — "not a blocked literal", not "safe"; a caller that must
|
|
10
|
+
* reject non-IP input entirely does so at its own boundary. */
|
|
11
|
+
export declare function isBlockedIpv4(address: string): boolean;
|
|
12
|
+
/** `URL.hostname` keeps the brackets on an IPv6 literal (`[::1]`); strip them
|
|
13
|
+
* so the address checks see the bare address. */
|
|
14
|
+
export declare function stripIpv6Brackets(hostname: string): string;
|
|
15
|
+
/** Blocks loopback/unspecified, fc00::/7 unique-local, fe80::/10 link-local,
|
|
16
|
+
* and IPv4-mapped forms of blocked v4 addresses. Mask-based on the leading
|
|
17
|
+
* group — prefix string matching would miss e.g. fe9a:: (inside fe80::/10). */
|
|
18
|
+
export declare function isBlockedIpv6(address: string): boolean;
|
|
19
|
+
/** True when this literal address must never be fetched. Non-IP input is
|
|
20
|
+
* false — see `isBlockedIpv4`. */
|
|
21
|
+
export declare function isBlockedIp(address: string): boolean;
|
|
22
|
+
/** Names that are internal by convention (localhost, *.local, *.internal…),
|
|
23
|
+
* rejected without waiting for DNS to prove it. */
|
|
24
|
+
export declare function isBlockedHostname(hostname: string): boolean;
|
|
25
|
+
/** Parse and apply every check that doesn't need the network: shape, scheme,
|
|
26
|
+
* obviously-internal hostname, and literal addresses. Returns null on reject.
|
|
27
|
+
* Callers must still vet what non-literal hostnames resolve to. */
|
|
28
|
+
export declare function parseSafeUrlShape(raw: string): URL | null;
|
package/dist/ssrf.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// The single SSRF deny-list for the monorepo: address classification and
|
|
2
|
+
// URL-shape checks shared by every guard that fetches model-authored /
|
|
3
|
+
// remote-user-supplied URLs (mastodon bridge urlGuard, core feeds httpClient).
|
|
4
|
+
// Before #2459 the CIDR table was hand-copied per guard and had drifted.
|
|
5
|
+
//
|
|
6
|
+
// Everything here is pure and isomorphic — no `node:` builtins. The parts that
|
|
7
|
+
// need the network (DNS resolution, per-redirect re-checks) stay in each
|
|
8
|
+
// consumer's thin wrapper.
|
|
9
|
+
/* eslint-disable sonarjs/no-hardcoded-ip -- the blocked ranges ARE this module's
|
|
10
|
+
specification; writing them as literals is the point, not an oversight. */
|
|
11
|
+
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
|
|
12
|
+
const BLOCKED_HOSTNAMES = new Set(["localhost", "ip6-localhost", "ip6-loopback"]);
|
|
13
|
+
const BLOCKED_HOSTNAME_SUFFIXES = [".localhost", ".local", ".internal"];
|
|
14
|
+
/** IPv4 ranges that must never be fetched, as [firstAddress, prefixLength].
|
|
15
|
+
* The union of the pre-#2459 per-guard tables — removing an entry weakens
|
|
16
|
+
* every guard in the repo at once, so treat edits as security changes. */
|
|
17
|
+
export const BLOCKED_IPV4_RANGES = [
|
|
18
|
+
["0.0.0.0", 8], // "this network"
|
|
19
|
+
["10.0.0.0", 8], // RFC1918
|
|
20
|
+
["100.64.0.0", 10], // CGNAT
|
|
21
|
+
["127.0.0.0", 8], // loopback
|
|
22
|
+
["169.254.0.0", 16], // link-local — includes the 169.254.169.254 metadata endpoint
|
|
23
|
+
["172.16.0.0", 12], // RFC1918
|
|
24
|
+
["192.0.0.0", 24], // IETF protocol assignments
|
|
25
|
+
["192.168.0.0", 16], // RFC1918
|
|
26
|
+
["198.18.0.0", 15], // benchmarking
|
|
27
|
+
["224.0.0.0", 4], // multicast
|
|
28
|
+
["240.0.0.0", 4], // reserved / broadcast
|
|
29
|
+
];
|
|
30
|
+
/** Dotted-decimal IPv4 → unsigned 32-bit value; null for anything else
|
|
31
|
+
* (hex / octal octets, whitespace, wrong part count). */
|
|
32
|
+
export function ipv4ToInt(address) {
|
|
33
|
+
const parts = address.split(".");
|
|
34
|
+
if (parts.length !== 4)
|
|
35
|
+
return null;
|
|
36
|
+
const octets = parts.map((part) => (/^\d{1,3}$/.test(part) ? Number(part) : Number.NaN));
|
|
37
|
+
if (octets.some((octet) => Number.isNaN(octet) || octet > 255))
|
|
38
|
+
return null;
|
|
39
|
+
return octets.reduce((value, octet) => value * 256 + octet, 0);
|
|
40
|
+
}
|
|
41
|
+
function isBlockedIpv4Value(value) {
|
|
42
|
+
return BLOCKED_IPV4_RANGES.some(([base, prefix]) => {
|
|
43
|
+
const baseValue = ipv4ToInt(base);
|
|
44
|
+
if (baseValue === null)
|
|
45
|
+
return false;
|
|
46
|
+
// `>>> 0` keeps the mask unsigned; a /0 shift would be a no-op anyway.
|
|
47
|
+
const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
|
|
48
|
+
return (value & mask) === (baseValue & mask);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** True when this parses as IPv4 AND falls inside a blocked range. Non-IPv4
|
|
52
|
+
* input is false — "not a blocked literal", not "safe"; a caller that must
|
|
53
|
+
* reject non-IP input entirely does so at its own boundary. */
|
|
54
|
+
export function isBlockedIpv4(address) {
|
|
55
|
+
const value = ipv4ToInt(address);
|
|
56
|
+
if (value === null)
|
|
57
|
+
return false;
|
|
58
|
+
return isBlockedIpv4Value(value);
|
|
59
|
+
}
|
|
60
|
+
/** `URL.hostname` keeps the brackets on an IPv6 literal (`[::1]`); strip them
|
|
61
|
+
* so the address checks see the bare address. */
|
|
62
|
+
export function stripIpv6Brackets(hostname) {
|
|
63
|
+
return hostname.replace(/^\[|\]$/g, "");
|
|
64
|
+
}
|
|
65
|
+
/** The 32-bit value inside an IPv4-mapped IPv6 literal, or null when the
|
|
66
|
+
* address is not v4-mapped. The hex spelling matters: WHATWG URL serializes
|
|
67
|
+
* `[::ffff:127.0.0.1]` as `::ffff:7f00:1`, so matching only the dotted form
|
|
68
|
+
* would wave the mapped loopback through any URL-sourced check. */
|
|
69
|
+
function mappedIpv4Value(lower) {
|
|
70
|
+
const dotted = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
|
|
71
|
+
if (dotted)
|
|
72
|
+
return ipv4ToInt(dotted[1]);
|
|
73
|
+
const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(lower);
|
|
74
|
+
if (hex)
|
|
75
|
+
return Number.parseInt(hex[1], 16) * 0x10000 + Number.parseInt(hex[2], 16);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/** Blocks loopback/unspecified, fc00::/7 unique-local, fe80::/10 link-local,
|
|
79
|
+
* and IPv4-mapped forms of blocked v4 addresses. Mask-based on the leading
|
|
80
|
+
* group — prefix string matching would miss e.g. fe9a:: (inside fe80::/10). */
|
|
81
|
+
export function isBlockedIpv6(address) {
|
|
82
|
+
const lower = stripIpv6Brackets(address.toLowerCase());
|
|
83
|
+
if (lower === "::1" || lower === "::")
|
|
84
|
+
return true;
|
|
85
|
+
// IPv4-mapped re-enters the v4 rules.
|
|
86
|
+
const mapped = mappedIpv4Value(lower);
|
|
87
|
+
if (mapped !== null)
|
|
88
|
+
return isBlockedIpv4Value(mapped);
|
|
89
|
+
const [head] = lower.split(":");
|
|
90
|
+
if (head.length === 0)
|
|
91
|
+
return false;
|
|
92
|
+
const leading = Number.parseInt(head, 16);
|
|
93
|
+
if (Number.isNaN(leading))
|
|
94
|
+
return false;
|
|
95
|
+
if ((leading & 0xfe00) === 0xfc00)
|
|
96
|
+
return true; // fc00::/7 unique-local
|
|
97
|
+
if ((leading & 0xffc0) === 0xfe80)
|
|
98
|
+
return true; // fe80::/10 link-local
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
/** True when this literal address must never be fetched. Non-IP input is
|
|
102
|
+
* false — see `isBlockedIpv4`. */
|
|
103
|
+
export function isBlockedIp(address) {
|
|
104
|
+
return address.includes(":") ? isBlockedIpv6(address) : isBlockedIpv4(address);
|
|
105
|
+
}
|
|
106
|
+
/** Names that are internal by convention (localhost, *.local, *.internal…),
|
|
107
|
+
* rejected without waiting for DNS to prove it. */
|
|
108
|
+
export function isBlockedHostname(hostname) {
|
|
109
|
+
const lower = hostname.toLowerCase().replace(/\.$/, "");
|
|
110
|
+
if (BLOCKED_HOSTNAMES.has(lower))
|
|
111
|
+
return true;
|
|
112
|
+
return BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => lower.endsWith(suffix));
|
|
113
|
+
}
|
|
114
|
+
function parseUrl(raw) {
|
|
115
|
+
try {
|
|
116
|
+
return new URL(raw);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Parse and apply every check that doesn't need the network: shape, scheme,
|
|
123
|
+
* obviously-internal hostname, and literal addresses. Returns null on reject.
|
|
124
|
+
* Callers must still vet what non-literal hostnames resolve to. */
|
|
125
|
+
export function parseSafeUrlShape(raw) {
|
|
126
|
+
const url = parseUrl(raw);
|
|
127
|
+
if (url === null)
|
|
128
|
+
return null;
|
|
129
|
+
if (!ALLOWED_PROTOCOLS.has(url.protocol))
|
|
130
|
+
return null;
|
|
131
|
+
const hostname = stripIpv6Brackets(url.hostname);
|
|
132
|
+
if (hostname.length === 0)
|
|
133
|
+
return null;
|
|
134
|
+
if (isBlockedHostname(hostname))
|
|
135
|
+
return null;
|
|
136
|
+
if (isBlockedIp(hostname))
|
|
137
|
+
return null;
|
|
138
|
+
return url;
|
|
139
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mulmoclaude/common",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "General-purpose pure utilities (type guards, etc.) shared across the MulmoClaude host, bridges, and plugins",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -11,6 +11,18 @@
|
|
|
11
11
|
"import": "./dist/index.js",
|
|
12
12
|
"require": "./dist/index.js",
|
|
13
13
|
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./meta-webhook": {
|
|
16
|
+
"types": "./dist/meta-webhook.d.ts",
|
|
17
|
+
"import": "./dist/meta-webhook.js",
|
|
18
|
+
"require": "./dist/meta-webhook.js",
|
|
19
|
+
"default": "./dist/meta-webhook.js"
|
|
20
|
+
},
|
|
21
|
+
"./ssrf": {
|
|
22
|
+
"types": "./dist/ssrf.d.ts",
|
|
23
|
+
"import": "./dist/ssrf.js",
|
|
24
|
+
"require": "./dist/ssrf.js",
|
|
25
|
+
"default": "./dist/ssrf.js"
|
|
14
26
|
}
|
|
15
27
|
},
|
|
16
28
|
"files": [
|
|
@@ -28,5 +40,20 @@
|
|
|
28
40
|
"author": "Receptron Team",
|
|
29
41
|
"devDependencies": {
|
|
30
42
|
"typescript": "^6.0.3"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/receptron/mulmoclaude/tree/main/packages/common#readme",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/receptron/mulmoclaude.git",
|
|
48
|
+
"directory": "packages/common"
|
|
49
|
+
},
|
|
50
|
+
"keywords": [
|
|
51
|
+
"mulmoclaude",
|
|
52
|
+
"utilities",
|
|
53
|
+
"type-guards",
|
|
54
|
+
"isomorphic"
|
|
55
|
+
],
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://github.com/receptron/mulmoclaude/issues"
|
|
31
58
|
}
|
|
32
59
|
}
|