@dxj.jp/md-embed 0.1.0 → 0.2.0

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 CHANGED
@@ -45,10 +45,49 @@ Any `text/html` response passing through the middleware gets its `<md-embed>` el
45
45
  ```ts
46
46
  render(markdown, options?) // Markdown → HTML string (wrapped in <article class="prose">)
47
47
  r2Resolver(bucket, { prefix? }) // MarkdownResolver backed by an R2 bucket
48
+ urlResolver({ allow, ... }) // opt-in: fetch Markdown from allowlisted external URLs
49
+ combineResolvers(...resolvers) // try resolvers in order; first non-null wins
48
50
  transform(response, resolver, opts?) // stream-resolve <md-embed> in a Response
49
51
  mdEmbed(env => resolver, opts?) // Hono-compatible middleware (no hono dependency)
50
52
  ```
51
53
 
54
+ ### Embedding from other sites (opt-in)
55
+
56
+ By default `src` is resolved **locally only** (an R2 key) — external URLs are never
57
+ fetched. To allow `<md-embed src="https://…">`, opt in with `urlResolver`, which is
58
+ **default-deny**: it fetches nothing until you supply an `allow` rule, and only ever
59
+ touches absolute URLs, so it composes with `r2Resolver`:
60
+
61
+ ```ts
62
+ app.use(mdEmbed<Env>((env) => combineResolvers(
63
+ urlResolver({ allow: "https://raw.githubusercontent.com/DXJ-LLC/" }),
64
+ r2Resolver(env.CONTENT),
65
+ )));
66
+ ```
67
+
68
+ Now `<md-embed src="https://raw.githubusercontent.com/DXJ-LLC/md-embed/main/README.md">`
69
+ renders, while a URL outside the allowlist falls through to R2 and ends up `not-found`.
70
+
71
+ `allow` accepts a string (matched by **origin + path prefix**, so `"https://host"` and
72
+ `"https://host/dir/"` are both host-boundary-safe — a bare prefix can't be spoofed by
73
+ `https://host.evil.com`), a `RegExp` (tested against `href` — anchor it), a predicate
74
+ `(url: URL) => boolean`, or an array of these. Only `https:` is fetched by default
75
+ (`protocols` to change), and `init` is forwarded to `fetch`.
76
+
77
+ **Two independent gates** guard resolution: the element-level `allowSrc` option runs
78
+ first and shows a visible `data-md-error="not-allowed"`; the fetch-level `urlResolver`
79
+ `allow` silently falls through (so a rejected URL ends up `not-found` via the next
80
+ resolver, revealing nothing about the allowlist).
81
+
82
+ > **Security.** External Markdown is untrusted, unsanitized third-party input — an
83
+ > allowlisted host that gets compromised can inject HTML into your page. `urlResolver`
84
+ > is default-deny and does not follow redirects by default (`redirect: "manual"`, so
85
+ > a 3xx resolves to nothing) so a host can't 302 off-allowlist; if you opt back in,
86
+ > the landing URL is re-checked. Keep
87
+ > `allow` tight and prefer hosts you control. On Workers the edge has no private
88
+ > network, so this is an XSS/abuse surface rather than a classic internal-SSRF one —
89
+ > but treat it as a real trust boundary regardless.
90
+
52
91
  ### Options
53
92
 
54
93
  | Option | Default | Meaning |
package/dist/index.d.ts CHANGED
@@ -24,6 +24,36 @@ export declare function render(markdown: string, options?: MdEmbedOptions): stri
24
24
  export declare function r2Resolver(bucket: R2Bucket, opts?: {
25
25
  prefix?: string;
26
26
  }): MarkdownResolver;
27
+ /** A rule deciding whether a URL may be fetched: a prefix string, a RegExp (tested against href), or a predicate. */
28
+ export type UrlAllowRule = string | RegExp | ((url: URL) => boolean);
29
+ export interface UrlResolverOptions {
30
+ /**
31
+ * Allowlist of fetchable URLs — REQUIRED to fetch anything. With no rule the
32
+ * resolver fetches nothing (default-deny), so external embedding is strictly opt-in.
33
+ */
34
+ allow?: UrlAllowRule | UrlAllowRule[];
35
+ /** Permitted URL protocols. Default `["https:"]` (no plaintext `http:`). */
36
+ protocols?: string[];
37
+ /** Extra options for the outbound `fetch` (headers, cf options, `redirect`, …). */
38
+ init?: RequestInit;
39
+ }
40
+ /**
41
+ * Opt-in resolver that fetches Markdown from **absolute URLs** on other sites.
42
+ * Off by default and default-deny: it only fetches URLs matching `allow`, and
43
+ * no-ops (returns null) for relative `src` values so it composes cleanly with
44
+ * {@link r2Resolver} via {@link combineResolvers}.
45
+ *
46
+ * urlResolver({ allow: "https://raw.githubusercontent.com/DXJ-LLC/" })
47
+ *
48
+ * Security: fetched Markdown is third-party, unsanitized input — only allowlist
49
+ * hosts you trust. Redirects are not followed by default (`redirect: "manual"`,
50
+ * so a 3xx becomes a non-ok response that resolves to nothing) — an allowlisted
51
+ * host therefore can't 302 off-allowlist. If you opt into following, the final
52
+ * URL is re-checked against `allow`.
53
+ */
54
+ export declare function urlResolver(opts?: UrlResolverOptions): MarkdownResolver;
55
+ /** Try resolvers in order; the first non-null result wins. Returns null if all miss. */
56
+ export declare function combineResolvers(...resolvers: MarkdownResolver[]): MarkdownResolver;
27
57
  /**
28
58
  * Resolve every `<md-embed>` in an HTML response via HTMLRewriter (streaming —
29
59
  * the page is never fully buffered). Non-HTML responses pass through untouched.
package/dist/index.js CHANGED
@@ -40,6 +40,83 @@ export function r2Resolver(bucket, opts = {}) {
40
40
  return { markdown: await object.text(), etag: object.httpEtag };
41
41
  };
42
42
  }
43
+ const compileAllow = (allow) => {
44
+ const rules = allow == null ? [] : Array.isArray(allow) ? allow : [allow];
45
+ if (rules.length === 0)
46
+ return () => false; // default-deny
47
+ const tests = rules.map((rule) => {
48
+ if (typeof rule === "string") {
49
+ // Match on parsed origin + path prefix — never a raw `href.startsWith`,
50
+ // which would let "https://ok.example" match "https://ok.example.evil.com"
51
+ // or "https://ok.example@evil.example/". Throws now if `rule` isn't a URL.
52
+ const base = new URL(rule);
53
+ return (url) => url.origin === base.origin && url.pathname.startsWith(base.pathname);
54
+ }
55
+ if (rule instanceof RegExp)
56
+ return (url) => rule.test(url.href);
57
+ return rule;
58
+ });
59
+ return (url) => tests.some((t) => t(url));
60
+ };
61
+ /**
62
+ * Opt-in resolver that fetches Markdown from **absolute URLs** on other sites.
63
+ * Off by default and default-deny: it only fetches URLs matching `allow`, and
64
+ * no-ops (returns null) for relative `src` values so it composes cleanly with
65
+ * {@link r2Resolver} via {@link combineResolvers}.
66
+ *
67
+ * urlResolver({ allow: "https://raw.githubusercontent.com/DXJ-LLC/" })
68
+ *
69
+ * Security: fetched Markdown is third-party, unsanitized input — only allowlist
70
+ * hosts you trust. Redirects are not followed by default (`redirect: "manual"`,
71
+ * so a 3xx becomes a non-ok response that resolves to nothing) — an allowlisted
72
+ * host therefore can't 302 off-allowlist. If you opt into following, the final
73
+ * URL is re-checked against `allow`.
74
+ */
75
+ export function urlResolver(opts = {}) {
76
+ const allow = compileAllow(opts.allow);
77
+ const protocols = opts.protocols ?? ["https:"];
78
+ // Don't follow redirects: one could leave the allowlisted host. "manual" (not
79
+ // "error", which Workers' fetch rejects) surfaces a 3xx as a non-ok response.
80
+ const init = { redirect: "manual", ...opts.init };
81
+ return async (src) => {
82
+ let url;
83
+ try {
84
+ url = new URL(src);
85
+ }
86
+ catch {
87
+ return null; // relative src → not a URL this resolver handles
88
+ }
89
+ if (!protocols.includes(url.protocol) || !allow(url))
90
+ return null;
91
+ const res = await fetch(url.href, init);
92
+ if (!res.ok)
93
+ return null;
94
+ // If the caller opted into following redirects, the landing URL must still pass.
95
+ if (res.redirected) {
96
+ let final;
97
+ try {
98
+ final = new URL(res.url);
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ if (!protocols.includes(final.protocol) || !allow(final))
104
+ return null;
105
+ }
106
+ return { markdown: await res.text(), etag: res.headers.get("etag") ?? undefined };
107
+ };
108
+ }
109
+ /** Try resolvers in order; the first non-null result wins. Returns null if all miss. */
110
+ export function combineResolvers(...resolvers) {
111
+ return async (src) => {
112
+ for (const resolve of resolvers) {
113
+ const out = await resolve(src);
114
+ if (out)
115
+ return out;
116
+ }
117
+ return null;
118
+ };
119
+ }
43
120
  class MdEmbedHandler {
44
121
  resolve;
45
122
  options;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxj.jp/md-embed",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -38,7 +38,7 @@
38
38
  "license": "MIT",
39
39
  "repository": {
40
40
  "type": "git",
41
- "url": "git+https://github.com/kyutarou/md-embed.git"
41
+ "url": "git+https://github.com/DXJ-LLC/md-embed.git"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [