@camstack/server 1.2.455 → 1.2.456

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.
@@ -30,9 +30,9 @@ under [Non-commercial models](#non-commercial-models).
30
30
 
31
31
  ## Required attributions
32
32
 
33
- - © Ultralytics, AGPL-3.0. Corresponding source: the upstream Ultralytics checkpoint and scripts/build-camstack-models.py in https://github.com/camstack/server. https://github.com/ultralytics/ultralytics
33
+ - © Ultralytics, AGPL-3.0. Corresponding source: the upstream Ultralytics checkpoint and build-camstack-models.py (with convert_lib.py) published at https://huggingface.co/camstack/camstack-models/tree/main/source. https://github.com/ultralytics/ultralytics
34
34
  - Coral Edge TPU models and AIY Vision Birds V1 © Google LLC, Apache-2.0. https://github.com/google-coral/test_data
35
- - © Ultralytics, AGPL-3.0 (Ultralytics YOLOv9 weights). YOLOv9 architecture: Chien-Yao Wang, I-Hau Yeh, Hong-Yuan Mark Liao, GPL-3.0 (https://github.com/WongKinYiu/yolov9). Corresponding source: the upstream Ultralytics checkpoint and scripts/build-camstack-models.py in https://github.com/camstack/server. https://github.com/ultralytics/ultralytics
35
+ - © Ultralytics, AGPL-3.0 (Ultralytics YOLOv9 weights). YOLOv9 architecture: Chien-Yao Wang, I-Hau Yeh, Hong-Yuan Mark Liao, GPL-3.0 (https://github.com/WongKinYiu/yolov9). Corresponding source: the upstream Ultralytics checkpoint and build-camstack-models.py (with convert_lib.py) published at https://huggingface.co/camstack/camstack-models/tree/main/source. https://github.com/ultralytics/ultralytics
36
36
  - RF-DETR © Roboflow, Apache-2.0. https://github.com/roboflow/rf-detr
37
37
  - YuNet weights Copyright (c) 2020 Shiqi Yu, MIT. Training code: ShiqiYu/libfacedetection.train, BSD-3-Clause. https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet
38
38
  - AuraFace v1 by fal.ai, Apache-2.0. https://huggingface.co/fal/AuraFace-v1
@@ -45,11 +45,10 @@ under [Non-commercial models](#non-commercial-models).
45
45
  - YAMNet © Google LLC, Apache-2.0. AudioSet ontology and labels © Google LLC, CC BY 4.0. https://github.com/tensorflow/models/tree/master/research/audioset/yamnet
46
46
  - Qwen models © Alibaba Cloud, Apache-2.0. https://huggingface.co/Qwen · https://huggingface.co/unsloth · https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF
47
47
  - Built with Llama. Llama 3.2 is licensed under the Llama 3.2 Community License, Copyright © Meta Platforms, Inc. All Rights Reserved. https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF
48
- - Scrypted YOLOv9 ReLU detectors, Koushik Dutta (scrypted/plugin-models, repository tagged MIT); trained with the YOLOv9 code of Chien-Yao Wang, I-Hau Yeh, Hong-Yuan Mark Liao, GPL-3.0 (https://github.com/WongKinYiu/yolov9). https://huggingface.co/scrypted/plugin-models
49
48
 
50
49
  Training data under attribution terms:
51
50
 
52
- - **COCO 2017** (training data of `yolo26n` and 51 more): annotations CC-BY-4.0; images under their Flickr owners' terms. https://cocodataset.org/#termsofuse
51
+ - **COCO 2017** (training data of `yolo26n` and 50 more): annotations CC-BY-4.0; images under their Flickr owners' terms. https://cocodataset.org/#termsofuse
53
52
  - **WIDER FACE** (training data of `yunet-2023mar`): research-oriented terms; images CC BY-NC-ND. http://shuoyang1213.me/WIDERFACE/
54
53
  - **Open Images V7** (training data of `animal-classifier`): annotations CC-BY-4.0 (Google LLC); images listed as CC-BY-2.0, no warranty. https://storage.googleapis.com/openimages/web/factsfigures_v7.html
55
54
  - **DrBimmer/vehicle-classification** (training data of `vehicle-type-v1`): CC-BY-NC-4.0 — treated as binding the trained weights (conservative reading). https://huggingface.co/datasets/DrBimmer/vehicle-classification
@@ -65,27 +64,29 @@ Training data under attribution terms:
65
64
 
66
65
  The folders `objectDetection/yolo26`, `objectDetection/yolov9`, `plateDetection/yolov8-plate` and `segmentation/yolo26-seg` of the model mirror hold Ultralytics weights,
67
66
  modified by CamStack and distributed under AGPL-3.0. Their Corresponding Source
68
- is the public CamStack source repository, **https://github.com/camstack/server**
69
- (MIT), together with the upstream checkpoints it starts from:
67
+ is published in the mirror itself, in its **`source/`** folder,
68
+ **https://huggingface.co/camstack/camstack-models/tree/main/source**, together
69
+ with the upstream checkpoints it starts from:
70
70
 
71
71
  1. **The upstream checkpoints**, published by Ultralytics at
72
72
  https://github.com/ultralytics/assets/releases: `yolo26{n,s,m,l,x}.pt`,
73
73
  `yolov9{t,s,m,c}.pt`, `yolo26{n,s,m}-seg.pt`.
74
- 2. **The export scripts** in https://github.com/camstack/server:
75
- `scripts/build-camstack-models.py` (ONNX with a dynamic batch axis, CoreML
76
- FP16, OpenVINO IR, OpenVINO NNCF INT8) and `scripts/export-models.py`. The
77
- build instructions (Python environment, invocation, calibration images for
78
- INT8) are in each script's header.
79
- 3. **The software** that downloads and runs these weights, CamStack itself, is
80
- the same repository.
74
+ 2. **The export scripts** in `source/`: `build-camstack-models.py` (ONNX
75
+ with a dynamic batch axis, CoreML FP16, OpenVINO IR, OpenVINO NNCF INT8),
76
+ `convert_lib.py` (the ONNX to OpenVINO / INT8 / CoreML converter it imports,
77
+ the same one CamStack's Model Studio runs), `requirements.txt` and
78
+ `export-models.py`. The build instructions (Python environment,
79
+ invocation, calibration images for INT8) are in `source/README.md` and in
80
+ each script's header. These scripts are CamStack's own code and are offered
81
+ under the MIT licence; the weights they produce keep their upstream licence.
81
82
 
82
- What the repository does **not** cover yet:
83
+ What the published scripts do **not** cover yet:
83
84
 
84
85
  - **Some hosted sizes and input resolutions.** The export scripts do not list
85
86
  every variant in the mirror (for example the `-320`/`-256` YOLO26 exports,
86
87
  `yolov9m`/`yolov9c`, and the `yolo26*-seg` models). Each one is the same
87
- Ultralytics `export()` with a different model id or `imgsz`, but no script in
88
- the repository reproduces it as-is.
88
+ Ultralytics `export()` with a different model id or `imgsz`, but no
89
+ published script reproduces it as-is.
89
90
  - **`yolov8n-plate` has no training checkpoint.** It is a third-party model
90
91
  (trained 2023-12-05 according to its own metadata, origin not identified).
91
92
  The ONNX file is the only form CamStack has.
@@ -205,16 +206,13 @@ entry kept resolvable for a saved selection but no longer offered.
205
206
  | `llm-qwen3-vl-8b-instruct-q4` | [Qwen3-VL (GGUF by unsloth)](https://huggingface.co/unsloth) | — / Apache-2.0 (`Apache-2.0.txt`) | — | Q4_K_M GGUF quantisation by unsloth (not re-hosted by CamStack) | https://huggingface.co/unsloth/Qwen3-VL-8B-Instruct-GGUF |
206
207
  | `llm-qwen3.6-35b-a3b-ud-q4` | [Qwen3.6-35B-A3B (GGUF by unsloth)](https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF) | — / Apache-2.0 (`Apache-2.0.txt`) | — | UD-Q4_K_M GGUF quantisation by unsloth (not re-hosted by CamStack) | https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF |
207
208
 
208
- ### Model Studio catalogs (object detection, imported on request)
209
-
210
- | Catalog ids | Upstream | Code / weights | Training data | CamStack changes | Downloaded from |
211
- | --- | --- | --- | --- | --- | --- |
212
- | `scrypted-yolov9t-relu-320`, `scrypted-yolov9t-relu_test-320`, `scrypted-yolov9s-relu-320`, `scrypted-yolov9s-relu_test-320`, `scrypted-yolov9m-relu-320`, `scrypted-yolov9m-relu_test-320`, `scrypted-yolov9c-relu-320`, `scrypted-yolov9c-relu_test-320`, `scrypted-yolov9t-relu-int8-320`, `scrypted-yolov9t-relu_test-int8-320`, `scrypted-yolov9s-relu-int8-320`, `scrypted-yolov9s-relu_test-int8-320`, `scrypted-yolov9m-relu-int8-320`, `scrypted-yolov9m-relu_test-int8-320`, `scrypted-yolov9c-relu-int8-320`, `scrypted-yolov9c-relu_test-int8-320` | [Scrypted plugin-models (repository tagged MIT, no per-model terms)](https://huggingface.co/scrypted/plugin-models) | GPL-3.0 (`GPL-3.0.txt`) / **Unknown** | not stated by Scrypted: UNKNOWN | none: downloaded as published, at a pinned revision | https://huggingface.co/scrypted/plugin-models |
213
- | `frigate-mobiledet-edgetpu` | [Google Coral test_data](https://github.com/google-coral/test_data) | Apache-2.0 / Apache-2.0 (`Apache-2.0.txt`) | COCO 2017: annotations CC-BY-4.0; images under their Flickr owners' terms | none | https://github.com/google-coral/test_data |
214
-
215
209
  ### Your own models
216
210
 
217
- The Scrypted and Frigate catalogs that Model Studio offers are listed above. A
218
- model you upload or convert yourself, or pull from your own Frigate+ account,
219
- keeps whatever licence its author published. CamStack does not change it and
220
- does not record it.
211
+ Model Studio can import third-party models (for example community
212
+ object-detection catalogs) on request. They are downloaded from their
213
+ publishers, are not hosted or redistributed by CamStack, and keep their
214
+ publishers' licences; Model Studio shows the licence where the source declares
215
+ one. A model you upload or convert
216
+ yourself, or pull from your own Frigate+ account, likewise keeps whatever
217
+ licence its author published. CamStack does not change it and does not record
218
+ it.
@@ -0,0 +1,193 @@
1
+ "use strict";
2
+ /**
3
+ * cors-policy — which browser origins may READ this hub's responses (D707).
4
+ *
5
+ * The viewer PWA is hosted at {@link VIEWER_PWA_ORIGIN} and talks to each
6
+ * operator's own hub straight from the browser — no proxy, no cloud. Before
7
+ * D707 the hub answered every request with `Access-Control-Allow-Origin: *`,
8
+ * which let ANY web page a user visited read any hub response its script could
9
+ * reach (an unauthenticated probe of a LAN hub, for one). The policy is now an
10
+ * allowlist:
11
+ *
12
+ * - the hub's OWN origin — the `Origin` whose host is the request's `Host`
13
+ * (or `X-Forwarded-Host`, which a relay such as the Home Assistant panel
14
+ * proxy sets to ITS origin). Same-origin calls never needed CORS; this
15
+ * entry exists for the WebSocket check, where a browser always sends
16
+ * `Origin` and the native clients send their target's;
17
+ * - {@link VIEWER_PWA_ORIGIN}, the hosted viewer;
18
+ * - {@link OPAQUE_ORIGIN} (`null`): the native app's OTA-bundled embed is a
19
+ * `file://` page inside a WKWebView, whose origin is opaque. Removing it
20
+ * breaks live view on iOS;
21
+ * - `CAMSTACK_PUBLIC_ORIGIN`, when set, and the operator's extras
22
+ * (`server.corsOrigins` in config.yaml / `CAMSTACK_CORS_ORIGINS`).
23
+ *
24
+ * The answer to an allowed origin REFLECTS it exactly — never `*` — and never
25
+ * carries `Access-Control-Allow-Credentials`. The cross-origin viewer
26
+ * authenticates with a bearer token in `Authorization` (or tRPC
27
+ * `connectionParams` on the socket), which needs no credentials mode; the
28
+ * httpOnly session cookie therefore never rides a cross-origin request, so the
29
+ * cookie-gated surfaces (`auth.exchangeSession`, `/api/embed-auth`, the
30
+ * admin-ui) stay same-origin by construction.
31
+ *
32
+ * Pure: no Fastify, no I/O — `register-cors-policy.ts` is the glue.
33
+ */
34
+ Object.defineProperty(exports, "__esModule", { value: true });
35
+ exports.CORS_PREFLIGHT_MAX_AGE_SEC = exports.CORS_EXPOSED_HEADERS = exports.CORS_ALLOWED_HEADERS = exports.CORS_ALLOWED_METHODS = exports.OPAQUE_ORIGIN = exports.VIEWER_PWA_ORIGIN = void 0;
36
+ exports.normalizeOrigin = normalizeOrigin;
37
+ exports.parseConfiguredOrigins = parseConfiguredOrigins;
38
+ exports.builtInOrigins = builtInOrigins;
39
+ exports.judgeCorsOrigin = judgeCorsOrigin;
40
+ exports.corsResponseHeaders = corsResponseHeaders;
41
+ exports.appendVary = appendVary;
42
+ /** The hosted viewer PWA (operator decision 2026-10-06). */
43
+ exports.VIEWER_PWA_ORIGIN = 'https://camstack.zentik.app';
44
+ /**
45
+ * The serialisation of an opaque origin. The native app's OTA embed
46
+ * (`assets/embed/camstack-embed.html`, loaded from `file://`) sends it.
47
+ */
48
+ exports.OPAQUE_ORIGIN = 'null';
49
+ /** Every method a hub route answers. */
50
+ exports.CORS_ALLOWED_METHODS = 'GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS';
51
+ /**
52
+ * Request headers a cross-origin caller may send. `authorization` is the
53
+ * whole point; `range` is recorded-media seeking; the `trpc-*` / `x-trpc-*`
54
+ * names are what `@trpc/client` links may add. NOT `x-camstack-exchange`:
55
+ * the cookie→bearer exchange is a same-origin mechanism and a cross-origin
56
+ * page has no business asking for it.
57
+ */
58
+ exports.CORS_ALLOWED_HEADERS = 'authorization, content-type, range, if-range, if-none-match, cache-control, trpc-accept, x-trpc-source, x-trpc-batch-mode';
59
+ /**
60
+ * Response headers a cross-origin script may read. The byte-range trio is
61
+ * recorded playback (a seek reads `content-range`); `content-disposition` is
62
+ * export downloads; `retry-after` is the rate limiter; the `x-camstack-*`
63
+ * trio is the clip-still contract (a 204 that says WHY there is no image).
64
+ */
65
+ exports.CORS_EXPOSED_HEADERS = 'content-range, accept-ranges, content-length, content-disposition, etag, retry-after, x-camstack-reason, x-camstack-reason-code, x-camstack-disposition';
66
+ /** Preflight cache. Chromium caps at 7200 s; Firefox honours up to 86400. */
67
+ exports.CORS_PREFLIGHT_MAX_AGE_SEC = 7200;
68
+ /**
69
+ * The canonical serialisation of an origin (`scheme://host[:port]`, lower
70
+ * case, default port dropped), or `null` when `raw` is not an http(s) origin.
71
+ * `null` (the opaque origin) is accepted verbatim.
72
+ */
73
+ function normalizeOrigin(raw) {
74
+ const trimmed = raw.trim();
75
+ if (trimmed === exports.OPAQUE_ORIGIN)
76
+ return exports.OPAQUE_ORIGIN;
77
+ let url;
78
+ try {
79
+ url = new URL(trimmed);
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
85
+ return null;
86
+ // An origin has no path, query, fragment or credentials. Accept a bare
87
+ // trailing slash (a common paste), refuse anything that names more.
88
+ if (url.username !== '' || url.password !== '')
89
+ return null;
90
+ if ((url.pathname !== '/' && url.pathname !== '') || url.search !== '' || url.hash !== '') {
91
+ return null;
92
+ }
93
+ return url.origin;
94
+ }
95
+ /** Read the configured value (array from YAML, already split from env). */
96
+ function parseConfiguredOrigins(value) {
97
+ if (!Array.isArray(value))
98
+ return { origins: [], rejected: [] };
99
+ const origins = [];
100
+ const rejected = [];
101
+ for (const entry of value) {
102
+ if (typeof entry !== 'string') {
103
+ rejected.push(String(entry));
104
+ continue;
105
+ }
106
+ const normalized = normalizeOrigin(entry);
107
+ if (normalized === null)
108
+ rejected.push(entry);
109
+ else
110
+ origins.push(normalized);
111
+ }
112
+ return { origins, rejected };
113
+ }
114
+ /** The built-in entries plus the optional public origin. */
115
+ function builtInOrigins(publicOrigin) {
116
+ const entries = [exports.VIEWER_PWA_ORIGIN, exports.OPAQUE_ORIGIN];
117
+ const pub = publicOrigin !== undefined ? normalizeOrigin(publicOrigin) : null;
118
+ if (pub !== null && pub !== exports.OPAQUE_ORIGIN)
119
+ entries.push(pub);
120
+ return entries;
121
+ }
122
+ /** The first value of a possibly comma-joined forwarding header, lower case. */
123
+ function firstHost(value) {
124
+ if (value === undefined)
125
+ return null;
126
+ const first = value.split(',')[0]?.trim().toLowerCase() ?? '';
127
+ return first.length > 0 ? first : null;
128
+ }
129
+ /** `host[:port]` of an http(s) origin, or null. */
130
+ function hostOfOrigin(origin) {
131
+ if (origin === exports.OPAQUE_ORIGIN)
132
+ return null;
133
+ try {
134
+ return new URL(origin).host.toLowerCase();
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ }
140
+ /**
141
+ * Judge one request's `Origin`. `listed` is the full allowlist (built-ins and
142
+ * the operator's extras), already normalised.
143
+ */
144
+ function judgeCorsOrigin(facts, listed) {
145
+ const raw = facts.origin;
146
+ if (raw === undefined || raw.length === 0)
147
+ return { kind: 'no-origin' };
148
+ const normalized = normalizeOrigin(raw);
149
+ if (normalized === null)
150
+ return { kind: 'denied', origin: raw };
151
+ if (listed.has(normalized))
152
+ return { kind: 'allowed', origin: raw, reason: 'listed' };
153
+ const originHost = hostOfOrigin(normalized);
154
+ if (originHost !== null) {
155
+ const ownHosts = [firstHost(facts.host), firstHost(facts.forwardedHost)];
156
+ if (ownHosts.includes(originHost))
157
+ return { kind: 'allowed', origin: raw, reason: 'own-host' };
158
+ }
159
+ return { kind: 'denied', origin: raw };
160
+ }
161
+ /**
162
+ * The headers an ALLOWED origin gets. `preflight` adds the method/header
163
+ * grants and the cache lifetime. The origin is the exact one the browser
164
+ * sent — reflecting it is what lets the browser hand the response to the
165
+ * page, and `Vary: Origin` (set by the glue on every response) keeps a cache
166
+ * from serving one origin's answer to another.
167
+ */
168
+ function corsResponseHeaders(origin, preflight) {
169
+ const base = {
170
+ 'access-control-allow-origin': origin,
171
+ 'access-control-expose-headers': exports.CORS_EXPOSED_HEADERS,
172
+ };
173
+ if (preflight === null)
174
+ return base;
175
+ return {
176
+ ...base,
177
+ 'access-control-allow-methods': exports.CORS_ALLOWED_METHODS,
178
+ 'access-control-allow-headers': exports.CORS_ALLOWED_HEADERS,
179
+ 'access-control-max-age': String(exports.CORS_PREFLIGHT_MAX_AGE_SEC),
180
+ // A public origin (the PWA) calling a LAN hub is a "private network"
181
+ // request to Chromium; its preflight must be answered explicitly.
182
+ ...(preflight.requestPrivateNetwork ? { 'access-control-allow-private-network': 'true' } : {}),
183
+ };
184
+ }
185
+ /** Append `token` to a `Vary` value unless it is already there. */
186
+ function appendVary(existing, token) {
187
+ if (existing === undefined || existing.trim().length === 0)
188
+ return token;
189
+ const parts = existing.split(',').map((p) => p.trim().toLowerCase());
190
+ if (parts.includes('*') || parts.includes(token.toLowerCase()))
191
+ return existing;
192
+ return `${existing}, ${token}`;
193
+ }
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createCorsPolicy = createCorsPolicy;
4
+ exports.registerCorsPolicy = registerCorsPolicy;
5
+ exports.refuseForeignWsUpgrade = refuseForeignWsUpgrade;
6
+ const cors_policy_js_1 = require("./cors-policy.js");
7
+ /** Distinct refused origins remembered for log de-duplication. */
8
+ const MAX_LOGGED_DENIALS = 256;
9
+ function headerValue(value) {
10
+ if (Array.isArray(value))
11
+ return value[0];
12
+ return value;
13
+ }
14
+ function varyText(value) {
15
+ if (typeof value === 'string')
16
+ return value;
17
+ if (Array.isArray(value))
18
+ return value.filter((v) => typeof v === 'string').join(', ');
19
+ return undefined;
20
+ }
21
+ function createCorsPolicy(deps) {
22
+ const builtIns = (0, cors_policy_js_1.builtInOrigins)(deps.publicOrigin);
23
+ let lastConfigured = Symbol('unset');
24
+ let listed = new Set(builtIns);
25
+ const loggedDenials = new Set();
26
+ const currentList = () => {
27
+ const configured = deps.configuredOrigins();
28
+ if (configured === lastConfigured)
29
+ return listed;
30
+ lastConfigured = configured;
31
+ const parsed = (0, cors_policy_js_1.parseConfiguredOrigins)(configured);
32
+ if (parsed.rejected.length > 0) {
33
+ deps.logger.warn('cors: ignored configured origins that are not http(s) origins', {
34
+ meta: { rejected: parsed.rejected, setting: 'server.corsOrigins' },
35
+ });
36
+ }
37
+ listed = new Set([...builtIns, ...parsed.origins]);
38
+ return listed;
39
+ };
40
+ return {
41
+ judge(headers) {
42
+ const verdict = (0, cors_policy_js_1.judgeCorsOrigin)({
43
+ origin: headerValue(headers.origin),
44
+ host: headerValue(headers.host),
45
+ forwardedHost: headerValue(headers['x-forwarded-host']),
46
+ }, currentList());
47
+ if (verdict.kind === 'denied' && !loggedDenials.has(verdict.origin)) {
48
+ if (loggedDenials.size < MAX_LOGGED_DENIALS)
49
+ loggedDenials.add(verdict.origin);
50
+ deps.logger.warn('cors: refused a cross-origin caller not on the allowlist', {
51
+ meta: {
52
+ origin: verdict.origin,
53
+ host: headerValue(headers.host) ?? null,
54
+ hint: 'add it to server.corsOrigins (config.yaml) or CAMSTACK_CORS_ORIGINS',
55
+ },
56
+ });
57
+ }
58
+ return verdict;
59
+ },
60
+ };
61
+ }
62
+ /**
63
+ * Install the policy on the hub's Fastify instance. Every `OPTIONS` is
64
+ * answered here with 204 (as before D707) — with grants only for an allowed
65
+ * origin, so a refused preflight fails in the browser.
66
+ */
67
+ function registerCorsPolicy(fastify, policy) {
68
+ fastify.addHook('onRequest', async (request, reply) => {
69
+ // The answer depends on Origin for every route, so every response says so
70
+ // — a cached same-origin answer must never be replayed to another origin.
71
+ const vary = (0, cors_policy_js_1.appendVary)(varyText(reply.getHeader('vary')), 'Origin');
72
+ reply.header('vary', vary);
73
+ reply.raw.setHeader('vary', vary);
74
+ const verdict = policy.judge(request.headers);
75
+ const isPreflight = request.method === 'OPTIONS' && request.headers['access-control-request-method'] !== undefined;
76
+ if (verdict.kind === 'allowed') {
77
+ const headers = (0, cors_policy_js_1.corsResponseHeaders)(verdict.origin, isPreflight
78
+ ? {
79
+ requestPrivateNetwork: headerValue(request.headers['access-control-request-private-network']) === 'true',
80
+ }
81
+ : null);
82
+ for (const [name, value] of Object.entries(headers)) {
83
+ reply.header(name, value);
84
+ // Hijacked replies (the data-plane proxy) skip Fastify's header
85
+ // write; Node merges these into their `writeHead`.
86
+ reply.raw.setHeader(name, value);
87
+ }
88
+ }
89
+ if (request.method === 'OPTIONS') {
90
+ return reply.code(204).send();
91
+ }
92
+ return undefined;
93
+ });
94
+ }
95
+ /**
96
+ * Refuse a WebSocket upgrade whose `Origin` is not on the allowlist. Returns
97
+ * `true` when it refused (the socket is answered 403 and destroyed); the
98
+ * caller must then do nothing else with it. A request with no `Origin` (a
99
+ * Node client, an agent) passes — Origin is the browser's signal, and auth is
100
+ * the bearer in `connectionParams` either way.
101
+ */
102
+ function refuseForeignWsUpgrade(policy, headers, socket) {
103
+ const verdict = policy.judge(headers);
104
+ if (verdict.kind !== 'denied')
105
+ return false;
106
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
107
+ socket.destroy();
108
+ return true;
109
+ }
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Used by `/api/oauth2/authorize` to admit a `redirect_uri` whose host the hub
6
6
  * cannot know in advance — a self-hosted Home Assistant at
7
- * `http://192.168.x.x:8123/auth/external/callback`. Pairing "private host" with
7
+ * `http://192.168.10.40:8123/auth/external/callback`. Pairing "private host" with
8
8
  * "exact path" is what keeps that from being a wildcard prefix: a public host
9
9
  * never satisfies it, so the worst an attacker can aim a code at is a machine
10
10
  * they already need LAN presence to read.
@@ -101,7 +101,7 @@ function extractUserAgent(req) {
101
101
  return null;
102
102
  }
103
103
  /**
104
- * Strip an IPv4-mapped IPv6 prefix (`::ffff:192.168.x.x` → `192.168.x.x`)
104
+ * Strip an IPv4-mapped IPv6 prefix (`::ffff:192.168.10.5` → `192.168.10.5`)
105
105
  * and any zone id (`fe80::1%en0` → `fe80::1`) so the range checks below
106
106
  * see a clean address.
107
107
  */
@@ -216,13 +216,13 @@ function isPrivateIpv4(ip) {
216
216
  if (a === undefined || b === undefined)
217
217
  return false;
218
218
  if (a === 10)
219
- return true; // 10.x.x.x/8
219
+ return true; // 10.0.0.0/8
220
220
  if (a === 127)
221
221
  return true; // 127.0.0.0/8 loopback
222
222
  if (a === 172 && b >= 16 && b <= 31)
223
223
  return true; // 172.16.0.0/12
224
224
  if (a === 192 && b === 168)
225
- return true; // 192.168.x.x/16
225
+ return true; // 192.168.0.0/16
226
226
  if (a === 169 && b === 254)
227
227
  return true; // 169.254.0.0/16 link-local
228
228
  return false;
@@ -347,7 +347,7 @@ function redactConfigEntries(data) {
347
347
  *
348
348
  * The stream surfaces (`cameraStreams.getRtspEntries`,
349
349
  * `streamBroker.getRtspEntry`, `deviceOps.getStreamSources`, …) hand a
350
- * non-admin caller `rtsp://admin:hunter2@192.168.x.x/…` verbatim: a
350
+ * non-admin caller `rtsp://admin:hunter2@192.168.10.139/…` verbatim: a
351
351
  * read-only token can open every camera by copy-pasting the string. These
352
352
  * are NOT config blobs, so {@link redactRecord} never sees them — the leak
353
353
  * lives in a plain `url` / `mutedUrl` string field.
package/dist/main.js CHANGED
@@ -70,6 +70,7 @@ const precompressed_asset_js_1 = require("./api/static/precompressed-asset.js");
70
70
  const spa_static_1 = require("./api/static/spa-static");
71
71
  const spa_mount_1 = require("./api/static/spa-mount");
72
72
  const frame_policy_1 = require("./api/static/frame-policy");
73
+ const register_cors_policy_1 = require("./api/cors/register-cors-policy");
73
74
  const cap_providers_1 = require("./api/core/cap-providers");
74
75
  const logging_settings_1 = require("./api/core/logging-settings");
75
76
  const request_census_settings_1 = require("./api/core/request-census-settings");
@@ -347,8 +348,18 @@ async function bootstrap() {
347
348
  const fastifyOpts = tlsOptions ? { https: tlsOptions } : {};
348
349
  const app = await (0, manual_boot_1.bootManual)({ infra, fastifyOpts });
349
350
  app.enableShutdownHooks();
350
- app.enableCors();
351
351
  const fastify = app.getHttpAdapter().getInstance();
352
+ // Which browser origins may read hub responses, and open the /trpc socket
353
+ // (D707): the hub's own, the hosted viewer PWA, the native embed's opaque
354
+ // `null`, and the operator's `server.corsOrigins`. First hook on the
355
+ // instance so every route — and every OPTIONS — is judged.
356
+ const corsConfig = app.get(config_service_1.ConfigService);
357
+ const corsPolicy = (0, register_cors_policy_1.createCorsPolicy)({
358
+ configuredOrigins: () => corsConfig.get('server.corsOrigins'),
359
+ publicOrigin: process.env.CAMSTACK_PUBLIC_ORIGIN,
360
+ logger: app.get(logging_service_1.LoggingService).createLogger('cors'),
361
+ });
362
+ (0, register_cors_policy_1.registerCorsPolicy)(fastify, corsPolicy);
352
363
  await fastify.register(cookie_1.default);
353
364
  // Every HTML document the hub serves may be framed by its own origin only
354
365
  // (D425). Stamped here, on the one instance, so no shell-send site added
@@ -1239,6 +1250,9 @@ async function bootstrap() {
1239
1250
  httpServer.on('upgrade', (request, socket, head) => {
1240
1251
  const pathname = (request.url ?? '').split('?')[0];
1241
1252
  if (pathname === '/trpc') {
1253
+ // CORS does not cover a WebSocket: refuse a foreign page here (D707).
1254
+ if ((0, register_cors_policy_1.refuseForeignWsUpgrade)(corsPolicy, request.headers, socket))
1255
+ return;
1242
1256
  wss.handleUpgrade(request, socket, head, (ws) => {
1243
1257
  wss.emit('connection', ws, request);
1244
1258
  });
@@ -394,16 +394,6 @@ async function bootManual(opts) {
394
394
  getHttpAdapter() {
395
395
  return { getInstance: () => fastify };
396
396
  },
397
- enableCors() {
398
- fastify.addHook('onRequest', async (request, reply) => {
399
- reply.header('access-control-allow-origin', '*');
400
- reply.header('access-control-allow-methods', 'GET,POST,PUT,DELETE,OPTIONS,PATCH');
401
- reply.header('access-control-allow-headers', 'content-type,authorization');
402
- if (request.method === 'OPTIONS') {
403
- reply.status(204).send();
404
- }
405
- });
406
- },
407
397
  enableShutdownHooks() {
408
398
  if (shutdownHooksRegistered)
409
399
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.455",
3
+ "version": "1.2.456",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "files": [
@@ -37,18 +37,18 @@
37
37
  ]
38
38
  },
39
39
  "dependencies": {
40
- "@camstack/addon-admin-ui": "1.2.329",
41
- "@camstack/addon-agent-ui": "1.2.173",
40
+ "@camstack/addon-admin-ui": "1.2.330",
41
+ "@camstack/addon-agent-ui": "1.2.174",
42
42
  "@camstack/addon-auth": "1.2.126",
43
43
  "@camstack/addon-decoder-nodeav": "1.2.119",
44
- "@camstack/addon-notifiers": "1.2.127",
45
- "@camstack/addon-pipeline": "1.2.326",
46
- "@camstack/addon-pipeline-orchestrator": "1.2.280",
47
- "@camstack/addon-post-analysis": "1.2.332",
48
- "@camstack/sdk": "1.2.120",
49
- "@camstack/system": "1.2.353",
50
- "@camstack/types": "1.2.304",
51
- "@camstack/ui-library": "1.2.231",
44
+ "@camstack/addon-notifiers": "1.2.128",
45
+ "@camstack/addon-pipeline": "1.2.327",
46
+ "@camstack/addon-pipeline-orchestrator": "1.2.281",
47
+ "@camstack/addon-post-analysis": "1.2.333",
48
+ "@camstack/sdk": "1.2.121",
49
+ "@camstack/system": "1.2.354",
50
+ "@camstack/types": "1.2.305",
51
+ "@camstack/ui-library": "1.2.232",
52
52
  "@fastify/compress": "^9.0.0",
53
53
  "@fastify/cookie": "^11.0.2",
54
54
  "@fastify/cors": "^11.2.0",