@sonordev/contracts 2.10.0 → 2.11.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/CHANGELOG.md +4 -0
- package/README.md +23 -4
- package/dist/public-url-host.d.ts +10 -0
- package/dist/public-url-host.js +34 -0
- package/dist/public-url-host.mjs +32 -0
- package/dist/publication-branding.d.ts +1 -1
- package/package.json +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.11.0
|
|
4
|
+
|
|
5
|
+
- Add `@sonordev/contracts/public-url-host`: the pure `isBlockedPublicUrlHost` predicate shared by Sonor and Signal, including private IPv4 and IPv6 ranges, mapped/compatible IPv4, and local hostname suffixes. It uses the standard URL parser without Node dependencies. Scoped IPv6 addresses are refused without throwing. Callers retain URL validation, DNS resolution/address pinning, and redirect protections.
|
|
6
|
+
|
|
3
7
|
## 2.10.0
|
|
4
8
|
|
|
5
9
|
A minor release: two new functions in `@sonordev/contracts/page-titles`. Every entry is otherwise unchanged.
|
package/README.md
CHANGED
|
@@ -37,6 +37,22 @@ pnpm add @sonordev/contracts
|
|
|
37
37
|
| `@sonordev/contracts/page-titles` | A page's own name: its title without the brand (`titleHead`), a readable label from a slug (`slugLabel`), whether a value is a route placeholder like `:slug` (`isRoutePlaceholder`), whether a value is a page's address and not its name (`isPageAddress`), and whether a stored title is a name when every piece between its pipes is judged (`isPageName`) | Signal's site-architecture writer, the agency site kit's portfolio components |
|
|
38
38
|
| `@sonordev/contracts/broadcast-layouts` | Which Broadcast card layouts are drawn (centered `headline`, low `anchor`), which retired IDs still read and what each draws as (`normalizeArticleCoverLayout`), the Broadcast styles, and the review compositions with the frozen draw that gives each review its own (`reviewCardVariationFromDigest`) | the Sonor API's card renderers and settings, Signal's agent tools, the dashboard's pickers |
|
|
39
39
|
| `@sonordev/contracts/publication-branding` | What a publication's branding may hold and what a change may set (`createPublicationBrandingValidator`), how a change merges, the editorial charter, and safe font stacks | the Sonor API stores and resolves it, Signal checks an agent's change before filing it, the dashboard edits it |
|
|
40
|
+
| `@sonordev/contracts/public-url-host` | Which literal hosts are refused as public fetch destinations (`isBlockedPublicUrlHost`) | the Sonor API and Signal's URL validators and network fetchers |
|
|
41
|
+
|
|
42
|
+
## Public URL hosts
|
|
43
|
+
|
|
44
|
+
`isBlockedPublicUrlHost(host)` shares the APIs' loopback, private IPv4,
|
|
45
|
+
link-local, carrier-grade NAT, multicast/reserved IPv4, private IPv6 and local
|
|
46
|
+
hostname checks. It checks IPv4-mapped and IPv4-compatible IPv6 destinations
|
|
47
|
+
too, with or without brackets. It uses the standard `URL` parser, with no
|
|
48
|
+
Node, DNS or network dependencies. Scoped IPv6 addresses are refused.
|
|
49
|
+
|
|
50
|
+
Pass the **parsed `URL.hostname`**, so alternate IPv4 spellings such as
|
|
51
|
+
`127.1` and `0x7f000001` are normalized first, or pass a DNS result's address.
|
|
52
|
+
This predicate doesn't validate a URL or trim a raw string. A `false` result
|
|
53
|
+
isn't proof a destination is public: the fetcher still validates the protocol,
|
|
54
|
+
credentials and port, checks the DNS addresses it connects to, and applies its
|
|
55
|
+
redirect policy on each hop. Those network protections stay in the APIs.
|
|
40
56
|
|
|
41
57
|
## Schema placeholders
|
|
42
58
|
|
|
@@ -740,9 +756,10 @@ valid, so the validator lives here once.
|
|
|
740
756
|
|
|
741
757
|
```ts
|
|
742
758
|
import { createPublicationBrandingValidator } from '@sonordev/contracts/publication-branding'
|
|
759
|
+
import { isBlockedPublicUrlHost } from '@sonordev/contracts/public-url-host'
|
|
743
760
|
|
|
744
|
-
// Bind it once
|
|
745
|
-
const branding = createPublicationBrandingValidator({ isBlockedHost })
|
|
761
|
+
// Bind it once to the same literal-host policy both APIs use.
|
|
762
|
+
const branding = createPublicationBrandingValidator({ isBlockedHost: isBlockedPublicUrlHost })
|
|
746
763
|
|
|
747
764
|
branding.validatePublicationBrandingPatch({ colors: { primary: '#ABC' } })
|
|
748
765
|
// { colors: { primary: '#aabbcc' } }
|
|
@@ -755,8 +772,10 @@ branding.mergePublicationBrandingPatch(saved, { identity: { logos: { dark: null
|
|
|
755
772
|
- **`createPublicationBrandingValidator({ isBlockedHost })`** returns the
|
|
756
773
|
validator. A URL leaf must be a public HTTPS URL without credentials;
|
|
757
774
|
whether its host is private (loopback, a LAN range, link-local,
|
|
758
|
-
`localhost`)
|
|
759
|
-
`
|
|
775
|
+
`localhost`) uses the injected guard. Both APIs pass the shared
|
|
776
|
+
`public-url-host` predicate, which doesn't need Node. It gets
|
|
777
|
+
`new URL(value).hostname`, so IPv6 keeps its brackets. DNS and connection
|
|
778
|
+
checks remain the responsibility of the fetcher.
|
|
760
779
|
- **`validatePublicationBranding`** reads a whole saved profile.
|
|
761
780
|
**`validatePublicationBrandingPatch`** reads a change: a leaf set to `null`
|
|
762
781
|
clears that override, and a card layout must be one that's drawn today.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared literal-host SSRF policy. Pass URL.hostname (which canonicalizes
|
|
3
|
+
* numeric IPv4 spellings), or an address returned by a DNS lookup. IPv6 may
|
|
4
|
+
* have the brackets URL.hostname retains. This isn't a URL validator and
|
|
5
|
+
* doesn't trim input, resolve DNS, follow redirects or open a connection.
|
|
6
|
+
* Callers still enforce their protocol/port rules and check or pin the DNS
|
|
7
|
+
* addresses used by each connection; a false result alone can't prove a
|
|
8
|
+
* destination is public.
|
|
9
|
+
*/
|
|
10
|
+
export declare function isBlockedPublicUrlHost(rawHost: string): boolean;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/contracts/public-url-host.ts
|
|
4
|
+
function isPrivateIpv4(a, b) {
|
|
5
|
+
return a === 127 || a === 10 || a === 0 || a === 192 && b === 168 || a === 172 && b >= 16 && b <= 31 || a === 169 && b === 254 || a === 100 && b >= 64 && b <= 127 || a >= 224;
|
|
6
|
+
}
|
|
7
|
+
function ipv6Words(host) {
|
|
8
|
+
if (!host.includes(":") || !/^[a-f\d:.]+$/.test(host)) return null;
|
|
9
|
+
let canonical;
|
|
10
|
+
try {
|
|
11
|
+
canonical = new URL(`http://[${host}]/`).hostname.slice(1, -1);
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const sides = canonical.split("::");
|
|
16
|
+
const left = sides[0] ? sides[0].split(":") : [];
|
|
17
|
+
const right = sides.length > 1 && sides[1] ? sides[1].split(":") : [];
|
|
18
|
+
return [...left, ...Array(sides.length > 1 ? 8 - left.length - right.length : 0).fill("0"), ...right].map((word) => parseInt(word, 16));
|
|
19
|
+
}
|
|
20
|
+
function isBlockedPublicUrlHost(rawHost) {
|
|
21
|
+
const host = rawHost.toLowerCase().replace(/^\[|\]$/g, "");
|
|
22
|
+
const blockedName = host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal");
|
|
23
|
+
if (host.includes(":") && host.includes("%")) return true;
|
|
24
|
+
const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
25
|
+
const blockedIp = !!ipv4 && isPrivateIpv4(Number(ipv4[1]), Number(ipv4[2]));
|
|
26
|
+
const words = ipv6Words(host);
|
|
27
|
+
const blockedIpv6 = !!words && // Unspecified/loopback, unique-local, link-local, deprecated site-local
|
|
28
|
+
// and multicast destinations aren't public logo/webhook endpoints.
|
|
29
|
+
(words.every((word) => word === 0) || words.slice(0, 7).every((word) => word === 0) && words[7] === 1 || (words[0] & 65024) === 64512 || (words[0] & 65472) === 65152 || (words[0] & 65472) === 65216 || (words[0] & 65280) === 65280 || // IPv4-mapped and older IPv4-compatible forms carry an IPv4 destination.
|
|
30
|
+
words.slice(0, 5).every((word) => word === 0) && [0, 65535].includes(words[5]) && isPrivateIpv4(words[6] >> 8, words[6] & 255));
|
|
31
|
+
return blockedName || blockedIp || blockedIpv6;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
exports.isBlockedPublicUrlHost = isBlockedPublicUrlHost;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// src/contracts/public-url-host.ts
|
|
2
|
+
function isPrivateIpv4(a, b) {
|
|
3
|
+
return a === 127 || a === 10 || a === 0 || a === 192 && b === 168 || a === 172 && b >= 16 && b <= 31 || a === 169 && b === 254 || a === 100 && b >= 64 && b <= 127 || a >= 224;
|
|
4
|
+
}
|
|
5
|
+
function ipv6Words(host) {
|
|
6
|
+
if (!host.includes(":") || !/^[a-f\d:.]+$/.test(host)) return null;
|
|
7
|
+
let canonical;
|
|
8
|
+
try {
|
|
9
|
+
canonical = new URL(`http://[${host}]/`).hostname.slice(1, -1);
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const sides = canonical.split("::");
|
|
14
|
+
const left = sides[0] ? sides[0].split(":") : [];
|
|
15
|
+
const right = sides.length > 1 && sides[1] ? sides[1].split(":") : [];
|
|
16
|
+
return [...left, ...Array(sides.length > 1 ? 8 - left.length - right.length : 0).fill("0"), ...right].map((word) => parseInt(word, 16));
|
|
17
|
+
}
|
|
18
|
+
function isBlockedPublicUrlHost(rawHost) {
|
|
19
|
+
const host = rawHost.toLowerCase().replace(/^\[|\]$/g, "");
|
|
20
|
+
const blockedName = host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal");
|
|
21
|
+
if (host.includes(":") && host.includes("%")) return true;
|
|
22
|
+
const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
23
|
+
const blockedIp = !!ipv4 && isPrivateIpv4(Number(ipv4[1]), Number(ipv4[2]));
|
|
24
|
+
const words = ipv6Words(host);
|
|
25
|
+
const blockedIpv6 = !!words && // Unspecified/loopback, unique-local, link-local, deprecated site-local
|
|
26
|
+
// and multicast destinations aren't public logo/webhook endpoints.
|
|
27
|
+
(words.every((word) => word === 0) || words.slice(0, 7).every((word) => word === 0) && words[7] === 1 || (words[0] & 65024) === 64512 || (words[0] & 65472) === 65152 || (words[0] & 65472) === 65216 || (words[0] & 65280) === 65280 || // IPv4-mapped and older IPv4-compatible forms carry an IPv4 destination.
|
|
28
|
+
words.slice(0, 5).every((word) => word === 0) && [0, 65535].includes(words[5]) && isPrivateIpv4(words[6] >> 8, words[6] & 255));
|
|
29
|
+
return blockedName || blockedIp || blockedIpv6;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { isBlockedPublicUrlHost };
|
|
@@ -107,7 +107,7 @@ export interface PublicationBrandingValidatorOptions {
|
|
|
107
107
|
/**
|
|
108
108
|
* True when a URL's hostname must never be stored as a logo or publisher
|
|
109
109
|
* URL: loopback, private and link-local ranges, `localhost` and the like.
|
|
110
|
-
* Each API passes
|
|
110
|
+
* Each API passes the shared `public-url-host` policy. It receives
|
|
111
111
|
* `new URL(value).hostname`, so IPv6 keeps its brackets.
|
|
112
112
|
*/
|
|
113
113
|
isBlockedHost: (hostname: string) => boolean;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sonordev/contracts",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot content and signing, site cache tags, portfolio metrics, the house voice's contraction rule, how a proposal's site plan counts its pages, what separates an error page from a page's content, which stored JSON-LD is a template's unfilled placeholder, where prose splits into sentences, which Sonor records Signal reads into a project's knowledge and the address each one is stored under, what outbound email and text may claim about a business and whether a sent email arrived intact, what a segment of outreach is and who may turn one on, a page's own name taken from its title or its slug, which Broadcast card layouts and review compositions are drawn and what a retired one draws as,
|
|
3
|
+
"version": "2.11.0",
|
|
4
|
+
"description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot content and signing, site cache tags, portfolio metrics, the house voice's contraction rule, how a proposal's site plan counts its pages, what separates an error page from a page's content, which stored JSON-LD is a template's unfilled placeholder, where prose splits into sentences, which Sonor records Signal reads into a project's knowledge and the address each one is stored under, what outbound email and text may claim about a business and whether a sent email arrived intact, what a segment of outreach is and who may turn one on, a page's own name taken from its title or its slug, which Broadcast card layouts and review compositions are drawn and what a retired one draws as, what a publication's branding may hold, and which literal hosts are refused as public fetch destinations. Pure functions, no dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -141,6 +141,11 @@
|
|
|
141
141
|
"types": "./dist/publication-branding.d.ts",
|
|
142
142
|
"import": "./dist/publication-branding.mjs",
|
|
143
143
|
"require": "./dist/publication-branding.js"
|
|
144
|
+
},
|
|
145
|
+
"./public-url-host": {
|
|
146
|
+
"types": "./dist/public-url-host.d.ts",
|
|
147
|
+
"import": "./dist/public-url-host.mjs",
|
|
148
|
+
"require": "./dist/public-url-host.js"
|
|
144
149
|
}
|
|
145
150
|
},
|
|
146
151
|
"typesVersions": {
|