@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 +39 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +77 -0
- package/package.json +2 -2
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.
|
|
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/
|
|
41
|
+
"url": "git+https://github.com/DXJ-LLC/md-embed.git"
|
|
42
42
|
},
|
|
43
43
|
"pnpm": {
|
|
44
44
|
"onlyBuiltDependencies": [
|