@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.
- package/THIRD_PARTY_MODELS.md +25 -27
- package/dist/api/cors/cors-policy.js +193 -0
- package/dist/api/cors/register-cors-policy.js +109 -0
- package/dist/api/oauth2/private-host.js +1 -1
- package/dist/api/trpc/client-ip.js +3 -3
- package/dist/api/trpc/device-config-secret-redaction.js +1 -1
- package/dist/main.js +15 -1
- package/dist/manual-boot.js +0 -10
- package/package.json +11 -11
package/THIRD_PARTY_MODELS.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
- **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
|
|
69
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
|
88
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
});
|
package/dist/manual-boot.js
CHANGED
|
@@ -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.
|
|
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.
|
|
41
|
-
"@camstack/addon-agent-ui": "1.2.
|
|
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.
|
|
45
|
-
"@camstack/addon-pipeline": "1.2.
|
|
46
|
-
"@camstack/addon-pipeline-orchestrator": "1.2.
|
|
47
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
48
|
-
"@camstack/sdk": "1.2.
|
|
49
|
-
"@camstack/system": "1.2.
|
|
50
|
-
"@camstack/types": "1.2.
|
|
51
|
-
"@camstack/ui-library": "1.2.
|
|
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",
|