@k2b/cloud 0.34.0 → 0.35.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/package.json +1 -1
- package/src/ai/capabilities.ts +122 -11
- package/src/ai/firecrawl-tools.ts +3 -30
- package/src/services/network-security.ts +33 -9
- package/src/shared/network-address.ts +46 -45
- package/src/ssr/LayoutHelp.tsx +2 -1
- package/src/styles/global.css +20 -11
package/package.json
CHANGED
package/src/ai/capabilities.ts
CHANGED
|
@@ -425,6 +425,7 @@ const SCHEMA_KEYS = new Set([
|
|
|
425
425
|
"required",
|
|
426
426
|
"items",
|
|
427
427
|
"prefixItems",
|
|
428
|
+
"additionalItems",
|
|
428
429
|
"additionalProperties",
|
|
429
430
|
"anyOf",
|
|
430
431
|
"oneOf",
|
|
@@ -435,29 +436,139 @@ const SCHEMA_KEYS = new Set([
|
|
|
435
436
|
"definitions",
|
|
436
437
|
]);
|
|
437
438
|
|
|
438
|
-
|
|
439
|
-
|
|
439
|
+
type SchemaReductionContext = {
|
|
440
|
+
root: Record<string, unknown>;
|
|
441
|
+
mirrorNames: Map<string, string>;
|
|
442
|
+
mirrors: Record<string, Record<string, unknown>>;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
/** A local reference into the input schema: the root (`#`) or one of its `$defs` or `definitions`. */
|
|
446
|
+
const localReference = (
|
|
447
|
+
reference: unknown,
|
|
448
|
+
root: Record<string, unknown>,
|
|
449
|
+
): { identity: string; keyword: string; name: string; schema: unknown } | null => {
|
|
450
|
+
if (reference === "#") {
|
|
451
|
+
const { $defs: _defs, definitions: _definitions, ...schema } = root;
|
|
452
|
+
return { identity: "#", keyword: "$defs", name: "root", schema };
|
|
453
|
+
}
|
|
454
|
+
const match = typeof reference === "string" ? /^#\/(\$defs|definitions)\/([^/]+)$/.exec(reference) : null;
|
|
455
|
+
if (!match) return null;
|
|
456
|
+
const [, keyword, encodedName] = match;
|
|
457
|
+
const name = encodedName!.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
458
|
+
const definitions = root[keyword!];
|
|
459
|
+
if (!definitions || typeof definitions !== "object" || !Object.hasOwn(definitions, name)) return null;
|
|
460
|
+
return { identity: `${keyword}/${name}`, keyword: keyword!, name, schema: Reflect.get(definitions, name) };
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
/** Whether a raw schema reaches a tuple; `visited` keeps each definition to one visit per question. */
|
|
464
|
+
const containsTuple = (value: unknown, context: SchemaReductionContext, key?: string, visited = new Set<string>()): boolean => {
|
|
465
|
+
if (key === "const" || key === "enum" || key === "default" || key === "required" || key === "type") return false;
|
|
466
|
+
if (Array.isArray(value)) return value.some((item) => containsTuple(item, context, undefined, visited));
|
|
467
|
+
if (!value || typeof value !== "object") return false;
|
|
468
|
+
if (key === "properties" || key === "$defs" || key === "definitions") {
|
|
469
|
+
return Object.values(value).some((schema) => containsTuple(schema, context, undefined, visited));
|
|
470
|
+
}
|
|
471
|
+
if (Object.hasOwn(value, "prefixItems") || Array.isArray(Reflect.get(value, "items"))) return true;
|
|
472
|
+
const reference = localReference(Reflect.get(value, "$ref"), context.root);
|
|
473
|
+
if (reference && !visited.has(reference.identity)) {
|
|
474
|
+
visited.add(reference.identity);
|
|
475
|
+
if (containsTuple(reference.schema, context, undefined, visited)) return true;
|
|
476
|
+
}
|
|
477
|
+
return Object.entries(value).some(([childKey, childValue]) => containsTuple(childValue, context, childKey, visited));
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const reduceSchemaValue = (value: unknown, context: SchemaReductionContext, key?: string, merged = false): unknown => {
|
|
481
|
+
if (key === "const" || key === "default") return structuredClone(value);
|
|
440
482
|
if (Array.isArray(value)) {
|
|
441
483
|
if (key === "required" || key === "enum" || key === "type") return structuredClone(value);
|
|
442
|
-
return value.map((item) => reduceSchemaValue(item));
|
|
484
|
+
return value.map((item) => reduceSchemaValue(item, context, undefined, merged));
|
|
443
485
|
}
|
|
444
486
|
if (!value || typeof value !== "object") return value;
|
|
487
|
+
if (key === "properties" || key === "$defs" || key === "definitions") {
|
|
488
|
+
return Object.fromEntries(Object.entries(value).map(([name, schema]) => [name, reduceSchemaValue(schema, context, undefined, merged)]));
|
|
489
|
+
}
|
|
445
490
|
|
|
446
491
|
const output: Record<string, unknown> = {};
|
|
447
492
|
for (const [childKey, childValue] of Object.entries(value)) {
|
|
448
|
-
if (
|
|
449
|
-
|
|
450
|
-
|
|
493
|
+
if (!SCHEMA_KEYS.has(childKey) || (merged && childKey === "default")) continue;
|
|
494
|
+
// Tuple widening can make surrounding union branches overlap, so their defaults belong to the app.
|
|
495
|
+
const unionMerged =
|
|
496
|
+
merged ||
|
|
497
|
+
((childKey === "anyOf" || childKey === "oneOf") &&
|
|
498
|
+
Array.isArray(childValue) &&
|
|
499
|
+
childValue.some((branch) => containsTuple(branch, context)));
|
|
500
|
+
output[childKey] = reduceSchemaValue(childValue, context, childKey, unionMerged);
|
|
501
|
+
}
|
|
502
|
+
if (output.oneOf !== undefined) {
|
|
503
|
+
if (output.anyOf === undefined) output.anyOf = output.oneOf;
|
|
504
|
+
else {
|
|
505
|
+
output.allOf = [...(Array.isArray(output.allOf) ? output.allOf : []), { anyOf: output.anyOf }, { anyOf: output.oneOf }];
|
|
506
|
+
delete output.anyOf;
|
|
451
507
|
}
|
|
452
|
-
|
|
453
|
-
|
|
508
|
+
delete output.oneOf;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const positions = Array.isArray(output.prefixItems) ? output.prefixItems : Array.isArray(output.items) ? output.items : null;
|
|
512
|
+
if (positions) {
|
|
513
|
+
const rest = Array.isArray(output.prefixItems) ? output.items : output.additionalItems;
|
|
514
|
+
// Extra items of an open tuple may be anything, so the AI side must not narrow them to the positions.
|
|
515
|
+
const schemas = rest === undefined || rest === true ? [] : rest === false ? positions : [...positions, rest];
|
|
516
|
+
// Position-erased branches must leave defaults to the app's authoritative tuple schema.
|
|
517
|
+
const mergedSchemas = schemas.some((schema) => JSON.stringify(schema) !== JSON.stringify(schemas[0]))
|
|
518
|
+
? schemas.map((schema) => reduceSchemaValue(schema, context, undefined, true))
|
|
519
|
+
: schemas;
|
|
520
|
+
const distinct = mergedSchemas.filter(
|
|
521
|
+
(schema, index) => mergedSchemas.findIndex((candidate) => JSON.stringify(candidate) === JSON.stringify(schema)) === index,
|
|
522
|
+
);
|
|
523
|
+
output.items = distinct.length === 0 ? {} : distinct.length === 1 ? distinct[0] : { anyOf: distinct };
|
|
524
|
+
if (rest === false)
|
|
525
|
+
output.maxItems = Math.min(typeof output.maxItems === "number" ? output.maxItems : positions.length, positions.length);
|
|
526
|
+
}
|
|
527
|
+
delete output.prefixItems;
|
|
528
|
+
delete output.additionalItems;
|
|
529
|
+
if (output.items === false) output.maxItems = 0;
|
|
530
|
+
// Zod drops array bounds without an item schema, so "any item" stays an empty schema.
|
|
531
|
+
if (typeof output.items === "boolean") output.items = {};
|
|
532
|
+
const reference = merged ? localReference(output.$ref, context.root) : null;
|
|
533
|
+
if (reference) {
|
|
534
|
+
const { identity, keyword, name } = reference;
|
|
535
|
+
const definitions = context.root[keyword];
|
|
536
|
+
const mirrors = context.mirrors[keyword] ?? {};
|
|
537
|
+
context.mirrors[keyword] = mirrors;
|
|
538
|
+
let mirrorName = context.mirrorNames.get(identity);
|
|
539
|
+
if (mirrorName === undefined) {
|
|
540
|
+
mirrorName = `${name}.ai`;
|
|
541
|
+
for (
|
|
542
|
+
let suffix = 1;
|
|
543
|
+
(definitions && typeof definitions === "object" && Object.hasOwn(definitions, mirrorName)) || Object.hasOwn(mirrors, mirrorName);
|
|
544
|
+
suffix++
|
|
545
|
+
) {
|
|
546
|
+
mirrorName = `${name}.ai${suffix}`;
|
|
547
|
+
}
|
|
548
|
+
// Reserve the mirror before descending so recursive refs never reach default-bearing originals.
|
|
549
|
+
context.mirrorNames.set(identity, mirrorName);
|
|
550
|
+
mirrors[mirrorName] = {};
|
|
551
|
+
mirrors[mirrorName] = reduceSchemaValue(reference.schema, context, undefined, true);
|
|
552
|
+
}
|
|
553
|
+
output.$ref = `#/${keyword}/${mirrorName.replace(/~/g, "~0").replace(/\//g, "~1")}`;
|
|
454
554
|
}
|
|
455
555
|
return output;
|
|
456
556
|
};
|
|
457
557
|
|
|
458
|
-
/**
|
|
459
|
-
|
|
460
|
-
|
|
558
|
+
/**
|
|
559
|
+
* Keep the provider-useful shape while leaving authoritative validation in the target app.
|
|
560
|
+
* Tuples reach the model as bounded arrays of their item schemas and `oneOf` as `anyOf`, because loosened branches may
|
|
561
|
+
* overlap; the app still validates exact tuple positions and exclusive unions.
|
|
562
|
+
*/
|
|
563
|
+
export const reduceAiCapabilityInputSchema = (schema: Record<string, unknown>): Record<string, unknown> => {
|
|
564
|
+
const context: SchemaReductionContext = { root: schema, mirrorNames: new Map(), mirrors: {} };
|
|
565
|
+
const reduced = reduceSchemaValue(schema, context) as Record<string, unknown>;
|
|
566
|
+
for (const [keyword, mirrors] of Object.entries(context.mirrors)) {
|
|
567
|
+
const definitions = reduced[keyword];
|
|
568
|
+
reduced[keyword] = { ...(definitions && typeof definitions === "object" ? definitions : {}), ...mirrors };
|
|
569
|
+
}
|
|
570
|
+
return reduced;
|
|
571
|
+
};
|
|
461
572
|
|
|
462
573
|
export const aiCapabilityInputSchema = (schema: Record<string, unknown>): z.ZodType =>
|
|
463
574
|
z.fromJSONSchema(reduceAiCapabilityInputSchema(schema));
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { isIP } from "node:net";
|
|
2
1
|
import { type ToolContext, truncateMiddle } from "@k2b/nessi";
|
|
3
2
|
import { z } from "zod";
|
|
4
3
|
import type { RequestActor } from "../server";
|
|
5
4
|
import { coreSettings } from "../services";
|
|
5
|
+
import { isUnsafeNetworkAddress, isUnsafeNetworkHostname, parseNetworkAddress } from "../shared/network-address";
|
|
6
6
|
import { AI_WEBSITE_APPROVAL_TOOL, aiTurnAllowsWebsiteApprovals, aiWebsiteApprovalScope, findRememberedAiToolApproval } from "./approvals";
|
|
7
7
|
import { aiConversations } from "./store";
|
|
8
8
|
import { defineAiTool } from "./tools";
|
|
@@ -141,31 +141,6 @@ const truncate = (text: string, maxChars: number): { text: string; truncated: bo
|
|
|
141
141
|
return { text: text.slice(0, Math.max(0, maxChars)).trimEnd(), truncated: true };
|
|
142
142
|
};
|
|
143
143
|
|
|
144
|
-
const isPrivateIpv4 = (hostname: string): boolean => {
|
|
145
|
-
const parts = hostname.split(".").map((part) => Number(part));
|
|
146
|
-
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
|
|
147
|
-
const [a, b] = parts as [number, number, number, number];
|
|
148
|
-
if (a === 0 || a === 10 || a === 127) return true;
|
|
149
|
-
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
150
|
-
if (a === 169 && b === 254) return true;
|
|
151
|
-
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
152
|
-
if (a === 192 && (b === 0 || b === 168)) return true;
|
|
153
|
-
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
154
|
-
return a >= 224;
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
const isPrivateIpv6 = (hostname: string): boolean => {
|
|
158
|
-
const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
159
|
-
return (
|
|
160
|
-
normalized === "::" ||
|
|
161
|
-
normalized === "::1" ||
|
|
162
|
-
normalized.startsWith("fc") ||
|
|
163
|
-
normalized.startsWith("fd") ||
|
|
164
|
-
normalized.startsWith("fe80:") ||
|
|
165
|
-
normalized.startsWith("2001:db8:")
|
|
166
|
-
);
|
|
167
|
-
};
|
|
168
|
-
|
|
169
144
|
export const assertPublicHttpUrl = (rawUrl: string): string => {
|
|
170
145
|
let parsed: URL;
|
|
171
146
|
try {
|
|
@@ -176,13 +151,11 @@ export const assertPublicHttpUrl = (rawUrl: string): string => {
|
|
|
176
151
|
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new Error("URL must use http or https.");
|
|
177
152
|
|
|
178
153
|
const hostname = parsed.hostname.toLowerCase();
|
|
179
|
-
if (
|
|
154
|
+
if (isUnsafeNetworkHostname(hostname)) {
|
|
180
155
|
throw new Error("URL host is not allowed.");
|
|
181
156
|
}
|
|
182
157
|
|
|
183
|
-
|
|
184
|
-
if (ipKind === 4 && isPrivateIpv4(hostname)) throw new Error("URL must not point to a private IP address.");
|
|
185
|
-
if (ipKind === 6 && isPrivateIpv6(hostname)) throw new Error("URL must not point to a private IP address.");
|
|
158
|
+
if (parseNetworkAddress(hostname) && isUnsafeNetworkAddress(hostname)) throw new Error("URL must not point to a private IP address.");
|
|
186
159
|
return parsed.toString();
|
|
187
160
|
};
|
|
188
161
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
|
-
import {
|
|
2
|
+
import type { LookupFunction } from "node:net";
|
|
3
|
+
import { isUnsafeNetworkAddress, isUnsafeNetworkHostname, normalizeNetworkHostname, parseNetworkAddress } from "../shared/network-address";
|
|
3
4
|
|
|
4
5
|
export type PublicNetworkAddress = { address: string; family: 4 | 6 };
|
|
5
6
|
export type NetworkLookup = (
|
|
@@ -7,25 +8,48 @@ export type NetworkLookup = (
|
|
|
7
8
|
options: { all: true; verbatim: true },
|
|
8
9
|
) => Promise<Array<{ address: string; family: number }>>;
|
|
9
10
|
|
|
11
|
+
export class NetworkTargetBlockedError extends Error {
|
|
12
|
+
constructor(message: string) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "NetworkTargetBlockedError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
export const resolvePublicNetworkAddresses = async (
|
|
11
19
|
rawHostname: string,
|
|
12
20
|
lookup: NetworkLookup = (hostname) => dnsLookup(hostname, { all: true, verbatim: true }),
|
|
13
21
|
): Promise<PublicNetworkAddress[]> => {
|
|
14
22
|
const hostname = normalizeNetworkHostname(rawHostname);
|
|
15
|
-
if (
|
|
23
|
+
if (isUnsafeNetworkHostname(rawHostname)) throw new NetworkTargetBlockedError("Network target host is not allowed");
|
|
16
24
|
|
|
17
|
-
const
|
|
18
|
-
const addresses =
|
|
25
|
+
const literal = parseNetworkAddress(rawHostname);
|
|
26
|
+
const addresses = literal ? [literal] : await lookup(hostname, { all: true, verbatim: true });
|
|
19
27
|
if (addresses.length === 0) throw new Error("Network target could not be resolved");
|
|
20
28
|
|
|
21
29
|
const normalized: PublicNetworkAddress[] = addresses.map((entry) => {
|
|
22
|
-
const
|
|
23
|
-
if (entry.family !== 4 && entry.family !== 6)
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
const literal = parseNetworkAddress(entry.address);
|
|
31
|
+
if (entry.family !== 4 && entry.family !== 6)
|
|
32
|
+
throw new NetworkTargetBlockedError("Network target returned an unsupported address family");
|
|
33
|
+
if (!literal || literal.family !== entry.family) throw new NetworkTargetBlockedError("Network target returned an invalid address");
|
|
34
|
+
return literal;
|
|
26
35
|
});
|
|
27
36
|
if (normalized.some((entry) => isUnsafeNetworkAddress(entry.address))) {
|
|
28
|
-
throw new
|
|
37
|
+
throw new NetworkTargetBlockedError("Network target resolved to a private or reserved address");
|
|
29
38
|
}
|
|
30
39
|
return normalized;
|
|
31
40
|
};
|
|
41
|
+
|
|
42
|
+
/** Socket lookup using only addresses already checked by the public resolver. */
|
|
43
|
+
export const pinnedNetworkLookup =
|
|
44
|
+
(addresses: readonly PublicNetworkAddress[]): LookupFunction =>
|
|
45
|
+
(_hostname, options, callback) => {
|
|
46
|
+
const family = options.family === 4 || options.family === 6 ? options.family : 0;
|
|
47
|
+
const candidates = family ? addresses.filter((entry) => entry.family === family) : [...addresses];
|
|
48
|
+
const selected = candidates[0];
|
|
49
|
+
if (!selected) {
|
|
50
|
+
callback(Object.assign(new Error("No validated address for requested IP family"), { code: "ENOTFOUND" }), "", family || undefined);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (options.all) callback(null, candidates);
|
|
54
|
+
else callback(null, selected.address, selected.family);
|
|
55
|
+
};
|
|
@@ -32,22 +32,12 @@ const UNSAFE_IPV4_RANGES: ReadonlyArray<readonly [string, number]> = [
|
|
|
32
32
|
["240.0.0.0", 4],
|
|
33
33
|
];
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (address.includes(".")) {
|
|
38
|
-
const separator = address.lastIndexOf(":");
|
|
39
|
-
const ipv4 = ipv4ToNumber(address.slice(separator + 1));
|
|
40
|
-
if (separator < 0 || ipv4 === null) return null;
|
|
41
|
-
address = `${address.slice(0, separator)}:${(ipv4 >>> 16).toString(16)}:${(ipv4 & 0xffff).toString(16)}`;
|
|
42
|
-
}
|
|
35
|
+
// Only canonical IPv6 from the WHATWG parser reaches this conversion.
|
|
36
|
+
const ipv6ToBigInt = (address: string): bigint => {
|
|
43
37
|
const halves = address.split("::");
|
|
44
|
-
if (halves.length > 2) return null;
|
|
45
38
|
const left = halves[0] ? halves[0].split(":") : [];
|
|
46
39
|
const right = halves[1] ? halves[1].split(":") : [];
|
|
47
|
-
const
|
|
48
|
-
if (missing < 0 || (halves.length === 1 && left.length !== 8)) return null;
|
|
49
|
-
const groups = [...left, ...Array.from({ length: missing }, () => "0"), ...right];
|
|
50
|
-
if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group))) return null;
|
|
40
|
+
const groups = [...left, ...Array.from({ length: 8 - left.length - right.length }, () => "0"), ...right];
|
|
51
41
|
return groups.reduce((value, group) => (value << 16n) | BigInt(`0x${group}`), 0n);
|
|
52
42
|
};
|
|
53
43
|
|
|
@@ -57,51 +47,62 @@ const ipv6InRange = (address: bigint, base: bigint, bits: number): boolean => {
|
|
|
57
47
|
};
|
|
58
48
|
|
|
59
49
|
const UNSAFE_IPV6_RANGES: ReadonlyArray<readonly [bigint, number]> = [
|
|
60
|
-
[ipv6ToBigInt("::")
|
|
61
|
-
[ipv6ToBigInt("
|
|
62
|
-
[ipv6ToBigInt("
|
|
63
|
-
[ipv6ToBigInt("
|
|
64
|
-
[ipv6ToBigInt("2001::")!, 32],
|
|
65
|
-
[ipv6ToBigInt("2001:2::")!, 48],
|
|
66
|
-
[ipv6ToBigInt("2001:db8::")!, 32],
|
|
67
|
-
[ipv6ToBigInt("2002::")!, 16],
|
|
68
|
-
[ipv6ToBigInt("3fff::")!, 20],
|
|
69
|
-
[ipv6ToBigInt("fc00::")!, 7],
|
|
70
|
-
[ipv6ToBigInt("fe80::")!, 10],
|
|
71
|
-
[ipv6ToBigInt("fec0::")!, 10],
|
|
72
|
-
[ipv6ToBigInt("ff00::")!, 8],
|
|
50
|
+
[ipv6ToBigInt("2001::"), 23],
|
|
51
|
+
[ipv6ToBigInt("2001:db8::"), 32],
|
|
52
|
+
[ipv6ToBigInt("2002::"), 16],
|
|
53
|
+
[ipv6ToBigInt("3fff::"), 20],
|
|
73
54
|
];
|
|
74
55
|
|
|
75
|
-
export const normalizeNetworkHostname = (hostname: string): string =>
|
|
76
|
-
hostname
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
56
|
+
export const normalizeNetworkHostname = (hostname: string): string => {
|
|
57
|
+
const lower = hostname.toLowerCase();
|
|
58
|
+
if (lower.startsWith("[") && lower.endsWith("]") && lower.includes(":")) return lower.slice(1, -1);
|
|
59
|
+
return lower.includes(":") ? lower : lower.replace(/\.$/, "");
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// WHATWG's "ends in a number" rule: these hosts must parse as IPv4,
|
|
63
|
+
// rather than falling back to DNS (including invalid octal and overflow).
|
|
64
|
+
const endsInNumber = (hostname: string): boolean => {
|
|
65
|
+
const last = hostname.split(".").at(-1) ?? "";
|
|
66
|
+
return /^\d+$/.test(last) || /^0x[0-9a-f]*$/i.test(last);
|
|
67
|
+
};
|
|
80
68
|
|
|
81
|
-
|
|
69
|
+
/** Parse all URL IP literal forms without DNS or server-only dependencies. */
|
|
70
|
+
export const parseNetworkAddress = (rawAddress: string): { address: string; family: 4 | 6 } | null => {
|
|
82
71
|
const address = normalizeNetworkHostname(rawAddress);
|
|
83
|
-
|
|
84
|
-
|
|
72
|
+
const ipv6 = address.includes(":");
|
|
73
|
+
if (!address || /[\s/@\\?#%\[\]]/.test(address) || (!ipv6 && !endsInNumber(address))) return null;
|
|
74
|
+
try {
|
|
75
|
+
// URL implements legacy IPv4 (decimal, octal, hex and shortened forms),
|
|
76
|
+
// as well as IPv6 compression and dotted tails. Reject zone IDs above.
|
|
77
|
+
const parsed = new URL(`http://${ipv6 ? `[${address}]` : address}/`).hostname;
|
|
78
|
+
if (ipv6) return { address: normalizeNetworkHostname(parsed), family: 6 };
|
|
79
|
+
return ipv4ToNumber(parsed) !== null ? { address: parsed, family: 4 } : null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
85
83
|
};
|
|
86
84
|
|
|
85
|
+
export const networkAddressFamily = (rawAddress: string): 4 | 6 | null => parseNetworkAddress(rawAddress)?.family ?? null;
|
|
86
|
+
|
|
87
87
|
export const isUnsafeNetworkAddress = (rawAddress: string): boolean => {
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
if (family === 4) return UNSAFE_IPV4_RANGES.some(([base, bits]) => ipv4InRange(address, base, bits));
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (value === null) return true;
|
|
94
|
-
if (value >> 32n === 0xffffn) {
|
|
88
|
+
const literal = parseNetworkAddress(rawAddress);
|
|
89
|
+
if (!literal) return true;
|
|
90
|
+
if (literal.family === 4) return UNSAFE_IPV4_RANGES.some(([base, bits]) => ipv4InRange(literal.address, base, bits));
|
|
91
|
+
const value = ipv6ToBigInt(literal.address);
|
|
92
|
+
if (value >> 32n === 0xffffn || ipv6InRange(value, ipv6ToBigInt("64:ff9b::"), 96)) {
|
|
95
93
|
const mapped = Number(value & 0xffffffffn);
|
|
96
|
-
|
|
97
|
-
return isUnsafeNetworkAddress(ipv4);
|
|
94
|
+
return isUnsafeNetworkAddress([mapped >>> 24, (mapped >>> 16) & 255, (mapped >>> 8) & 255, mapped & 255].join("."));
|
|
98
95
|
}
|
|
99
|
-
return UNSAFE_IPV6_RANGES.some(([base, bits]) => ipv6InRange(value, base, bits));
|
|
96
|
+
return !ipv6InRange(value, ipv6ToBigInt("2000::"), 3) || UNSAFE_IPV6_RANGES.some(([base, bits]) => ipv6InRange(value, base, bits));
|
|
100
97
|
};
|
|
101
98
|
|
|
102
99
|
export const isUnsafeNetworkHostname = (rawHostname: string): boolean => {
|
|
103
100
|
const hostname = normalizeNetworkHostname(rawHostname);
|
|
104
101
|
return (
|
|
105
|
-
hostname
|
|
102
|
+
!hostname ||
|
|
103
|
+
["localhost", "localhost.localdomain", "metadata", "metadata.google.internal", "instance-data"].includes(hostname) ||
|
|
104
|
+
[".localhost", ".internal", ".local", ".home.arpa"].some((suffix) => hostname.endsWith(suffix)) ||
|
|
105
|
+
/[%\[\]]/.test(hostname) ||
|
|
106
|
+
((endsInNumber(hostname) || hostname.includes(":")) && !parseNetworkAddress(hostname))
|
|
106
107
|
);
|
|
107
108
|
};
|
package/src/ssr/LayoutHelp.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
MarkdownView,
|
|
9
9
|
NoticeCard,
|
|
10
10
|
Placeholder,
|
|
11
|
+
prefersReducedMotion,
|
|
11
12
|
prompts,
|
|
12
13
|
ScrollArea,
|
|
13
14
|
TextInput,
|
|
@@ -186,7 +187,7 @@ const HelpShell = (props: {
|
|
|
186
187
|
).find((element) => element.id === hash);
|
|
187
188
|
if (!heading) return false;
|
|
188
189
|
setActiveSectionId(hash);
|
|
189
|
-
heading.scrollIntoView({ block: "start", behavior:
|
|
190
|
+
heading.scrollIntoView({ block: "start", behavior: prefersReducedMotion() ? "auto" : "smooth" });
|
|
190
191
|
return true;
|
|
191
192
|
};
|
|
192
193
|
|
package/src/styles/global.css
CHANGED
|
@@ -255,8 +255,26 @@ html:has(.k2b-mobile-shell) body {
|
|
|
255
255
|
--color-selection-fg: rgb(239 246 255);
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
@
|
|
259
|
-
|
|
258
|
+
/* @k2b/ui makes motion inside .k2b-ui instant under reduced motion; the
|
|
259
|
+
page owns its view transitions, so the same two sources stop them here. */
|
|
260
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
261
|
+
@view-transition {
|
|
262
|
+
navigation: auto;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
@media (prefers-reduced-motion: reduce) {
|
|
267
|
+
::view-transition-group(*),
|
|
268
|
+
::view-transition-old(*),
|
|
269
|
+
::view-transition-new(*) {
|
|
270
|
+
animation: none !important;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
html[data-k2b-motion="reduced"]::view-transition-group(*),
|
|
275
|
+
html[data-k2b-motion="reduced"]::view-transition-old(*),
|
|
276
|
+
html[data-k2b-motion="reduced"]::view-transition-new(*) {
|
|
277
|
+
animation: none !important;
|
|
260
278
|
}
|
|
261
279
|
|
|
262
280
|
::selection {
|
|
@@ -275,15 +293,6 @@ solid-island {
|
|
|
275
293
|
display: contents;
|
|
276
294
|
}
|
|
277
295
|
|
|
278
|
-
@media (prefers-reduced-motion: reduce) {
|
|
279
|
-
*,
|
|
280
|
-
*::before,
|
|
281
|
-
*::after {
|
|
282
|
-
scroll-behavior: auto !important;
|
|
283
|
-
transition-duration: 0.01ms !important;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
296
|
/* Cloud has three shell modes. Phones, below md, get the header and no rail.
|
|
288
297
|
From md, signed-in people get the rail instead of the header: up to 2xl the
|
|
289
298
|
app area meets the window's top, right and bottom edges, and from 2xl it is
|