Repository navigation
Expand file tree
/
Copy paththreat-model.json
More file actions
1936 lines (1936 loc) · 101 KB
/
Copy paththreat-model.json
File metadata and controls
1936 lines (1936 loc) · 101 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
<
10BC8
div data-line-number="811" class="react-line-number react-code-text" style="padding-right:16px">811812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
{
"spec_version": 1,
"repository": "https://github.com/adeelahmad/lense",
"commit": "5f799f2ff8bb8a762d8151d9b10b535a17878b55",
"date": "2026-10-05",
"scope_subpath": null,
"description": "Lens is a self-hosted archive for documents, recordings, photos and messages: it reads, transcribes, indexes and publishes them, and people search and chat over them. One operator (the instance admin) runs an instance for their own people: a FastAPI API, a Next.js web app that proxies the API and holds the sign-in session, and background workers, around one SurrealDB database, with content split into namespaces and per-namespace roles. Exposing an instance to the internet behind HTTPS (reverse proxy or Cloudflare tunnel) is supported. Only the instance admin (equivalent to host code execution) and the host operator are trusted; everyone else is trusted only as far as their role.",
"confidence": {
"documented": 246,
"inferred": 99
},
"components": [
{
"name": "web",
"entry_points": [
"every request"
],
"touches": [
"network"
],
"in_scope": true,
"provenance": "inferred",
"source": "open question Q34"
},
{
"name": "identity",
"entry_points": [
"every /api/v1/* route",
"passkey origin, OAuth discovery, external-login redirect",
"POST /auth/login, /iiif/auth/access form",
"/auth/setup, /auth/passkey/setup*, /auth/setup/no-passkey",
"/auth/refresh, /auth/ticket, /auth/signin-link/*, /auth/password/*, WebAuthn flows",
"/oauth/register, /authorize, /token, /revoke",
"/auth/external/{key}/*",
"/mcp"
],
"touches": [
"network"
],
"in_scope": true,
"provenance": "documented",
"source": "docs/authentication.md \"one-time **setup code**\""
},
{
"name": "authz",
"entry_points": [
"IP groups, throttles",
"namespace, recording and child-object routes",
"routes not read one by one (about 370)",
"media, report, embed, IIIF content",
"/iiif/auth/token, /probe, /logout",
"workers and DB peers",
"host control inbox and the monitoring scrape token",
"admin machine setup requests",
"entity and topic curation tools",
"microsandbox program requests"
],
"touches": [
"filesystem"
],
"in_scope": true,
"provenance": "documented",
"source": "SECURITY.md \"What counts\""
},
{
"name": "outbound",
"entry_points": [
"watched folders, storage sources, IMAP, iCal",
"Google Calendar sources",
"POST /imports/web, extension http tools, notification targets",
"admin settings (model, embeddings, speech, decisions, Fedora, bridge, telemetry, IIIF import, rclone remotes, IMAP, iCal, MQTT bridge, OIDC issuer)",
"replies from remote servers",
".env, archive.yaml, LENS_*, ARCHIVE_SECRET_KEY, RCLONE_BINARY"
],
"touches": [
"filesystem",
"network",
"env",
"child_processes"
],
"in_scope": true,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"name": "ingest",
"entry_points": [
"/uploads, /import, /recordings/{rid}/files"
],
"touches": [
"filesystem",
"child_processes"
],
"in_scope": true,
"provenance": "inferred",
"source": "open question Q34"
},
{
"name": "query",
"entry_points": [
"/recordings, /search, /public/search, chat retrieval",
"/graph/query, MCP graph_query",
"/namespaces/{name}/sparql",
"/namespaces/{name}/rdf/import"
],
"touches": [
"global_state"
],
"in_scope": true,
"provenance": "inferred",
"source": "open question Q34"
},
{
"name": "assistant",
"entry_points": [
"chat, /graph/ask, MCP tools",
"extensions (prompt, graph and http tools, hooks), custom nodes including template nodes",
"Python code tools, shipped and admin-created templates, workflows, pipelines, routines",
"Lens extension side panel"
],
"touches": [
"network",
"child_processes"
],
"in_scope": true,
"provenance": "inferred",
"source": "open question Q34"
},
{
"name": "optional",
"entry_points": [
"MQTT 1883, syslog 5514, POST /sensors/push/{token}/{stream}",
"SMB share (port 1445 on the host, 4445 in the worker)"
],
"touches": [
"network",
"child_processes",
"global_state"
],
"in_scope": true,
"provenance": "inferred",
"source": "open question Q34"
}
],
"out_of_scope": [
{
"item": "ci-tooling-and-samples",
"reason": "examples/ (source clone only), overrides/ (mkdocs theme), ci/, .github/, .pre-commit* (CI, in no image) and prod-frontend-deploy.yml (stale Vercel workflow) are unsupported paths built into no running service (§1.3)",
"provenance": "inferred",
"source": "open question Q34"
},
{
"item": "inert-backend-files",
"reason": "fastapi_backend/tests/, watcher.py and commands/ are copied into the backend image but never started by the production command; a report must show production code reaching them (§1.3)",
"provenance": "inferred",
"source": "open question Q34"
}
],
"trust_boundaries": [
{
"component": "web",
"boundary": "B1, network to web app: everything a browser, app or agent sends is untrusted; the web app forwards /api, /mcp, /iiif, /embed and /s to the API, which authorizes every call.",
"provenance": "inferred",
"reachability_precondition": "reachable from a browser request and changes what the API decides or what another person's browser runs",
"source": "threat-model.md §1.4 (untagged boundary list)"
},
{
"component": "identity",
"boundary": "B2, request to role: the API turns a bearer token, signed link, share link, IIIF cookie or vouched-for address into a role per namespace.",
"provenance": "inferred",
"reachability_precondition": "reachable with no account, or with an account, and yields a session, token or admin the attacker was not given; setup-code findings apply only while no account exists",
"source": "threat-model.md §1.4 (untagged boundary list)"
},
{
"component": "authz",
"boundary": "B2, request to role: the main boundary; namespace isolation lives here, in application code. B5: workers, the API and the web app trust the database and each other.",
"provenance": "inferred",
"reachability_precondition": "lets a principal read or change more than its role and the asset's access level allow, or open media without a link the server signed for it",
"source"
8
;: "
10BC8
;threat-model.md §1.4 (untagged boundary list)"
},
{
"component": "outbound",
"boundary": "B4, Lens to the outside: fetches whose URL a non-admin sets, and iCal feeds, go through netguard; other admin-set URLs do not; the telemetry endpoint is only shape-checked (P-ADMIN-GUARD).",
"provenance": "inferred",
"reachability_precondition": "a URL a non-admin sets, or a guarded admin operand, reaches a non-public address; or a stored secret reaches anyone but the host the admin set",
"source": "threat-model.md §1.4 (untagged boundary list)"
},
{
"component": "ingest",
"boundary": "B3, content to parser: uploaded and fetched bytes cross into ffmpeg, LibreOffice, Chromium, poppler and Tesseract.",
"provenance": "inferred",
"reachability_precondition": "reachable from bytes an editor uploads or a watched source delivers, and the fault is in how Lens runs the tool (arguments, environment, timeouts, sandbox flags)",
"source": "threat-model.md §1.4 (untagged boundary list)"
},
{
"component": "query",
"boundary": "B2, request to role: query text from a non-admin is untrusted and runs only over the caller's readable namespaces.",
"provenance": "inferred",
"reachability_precondition": "query text from a non-admin reads outside the caller's namespaces, writes, fetches, or reaches SurrealQL",
"source": "threat-model.md §1.4 (untagged boundary list)"
},
{
"component": "assistant",
"boundary": "B3, content to parser: uploaded and fetched bytes cross into the LLM prompt; B2 bounds the tools to the person's role.",
"provenance": "inferred",
"reachability_precondition": "a non-admin (or content steering the model for one) exceeds the person's reads, changes data without approval outside notes and admin setup, or runs code on the host",
"source": "threat-model.md §1.4 (untagged boundary list)"
},
{
"component": "optional",
"boundary": "B6, devices: sensor listeners take traffic from the LAN (or wider, per sensors.bind) once an admin enables them.",
"provenance": "inferred",
"reachability_precondition": "the service is turned on by an admin, and the attacker lacks the login, network or token the feature requires",
"source": "threat-model.md §1.4 (untagged boundary list)"
}
],
"entry_points": [
{
"entry_point": "every /api/v1/* route",
"parameter": "Authorization: Bearer (web JWT, la_ API token, lo_ app token)",
"attacker_controllable": "yes",
"component": "identity",
"caller_must_enforce": "holder keeps it secret; Lens checks signature or hash, expiry, account, session",
"provenance": "documented",
"source": "docs/authentication.md \"Every request checks that the account is still active\""
},
{
"entry_point": "every request",
"parameter": "Host header",
"attacker_controllable": "yes",
"component": "web",
"caller_must_enforce": "operator lists served hosts",
"provenance": "documented",
"source": "docs/configuration.md \"checks the `Host` header\""
},
{
"entry_point": "IP groups, throttles",
"parameter": "X-Forwarded-For, peer address",
"attacker_controllable": "yes",
"component": "authz",
"caller_must_enforce": "a reverse proxy sets the header; trusted_proxies lists exactly the front hops",
"provenance": "documented",
"source": "docs/configuration.md \"Without it, a visitor can claim any address\""
},
{
"entry_point": "passkey origin, OAuth discovery, external-login redirect",
"parameter": "X-Forwarded-Host, X-Forwarded-Proto",
"attacker_controllable": "yes",
"component": "identity",
"caller_must_enforce": "TRUST_PROXY_HEADERS only behind a real proxy",
"provenance": "documented",
"source": "docs/authentication.md \"anyone could choose the address\""
},
{
"entry_point": "POST /auth/login, /iiif/auth/access form",
"parameter": "email, password",
"attacker_controllable": "yes",
"component": "identity",
"caller_must_enforce": "none — P-PASSWORDS-OFF (passwords work only where auth.passwords is on)",
"provenance": "documented",
"source": "docs/authentication.md \"With passwords off, password sign-in\""
},
{
"entry_point": "/auth/setup, /auth/passkey/setup*, /auth/setup/no-passkey",
"parameter": "setup code, email, name",
"attacker_controllable": "yes",
"component": "identity",
"caller_must_enforce": "keep the log private until the first admin exists",
"provenance": "documented",
"source": "docs/configuration.md \"so a stranger who finds a new\""
},
{
"entry_point": "/auth/refresh, /auth/ticket, /auth/signin-link/*, /auth/password/*, WebAuthn flows",
"parameter": "rotating and one-time secrets",
"attacker_controllable": "yes",
"component": "identity",
"caller_must_enforce": "holder keeps them secret",
"provenance": "documented",
"source": "docs/authentication.md \"A link lasts three days\""
},
{
"entry_point": "/oauth/register, /authorize, /token, /revoke",
"parameter": "redirect URIs, client metadata, code, verifier, refresh token",
"attacker_controllable": "yes",
"component": "identity",
"caller_must_enforce": "the app keeps state and its verifier; the person consents",
"provenance": "documented",
"source": "docs/authentication.md \"Apps register themselves\""
},
{
"entry_point": "/auth/external/{key}/*",
"parameter": "next, state, code, provider profile",
"attacker_controllable": "yes",
"component": "identity",
"caller_must_enforce": "admin chooses providers",
"provenance": "documented",
"source": "docs/authentication.md \"when the provider vouches for the email\""
},
{
"entry_point": "/mcp",
"parameter": "bearer, Origin, JSON-RPC body",
"attacker_controllable": "yes",
"component": "identity",
"caller_must_enforce": "none — P-OAUTH",
"provenance": "documented",
"source": "docs/mcp.md \"A token an app asked for another server\""
},
{
"entry_point": "namespace, recording and child-object routes",
"parameter": "namespace name, rid, child ids (sequential integers)",
"attacker_controllable": "yes",
"component": "authz",
"caller_must_enforce": "none — P-NS-ISOLATION (Lens checks the role in each handler)",
"provenance": "documented",
"source": "docs/api.md \"Namespaces you can't read answer 404\""
},
{
"entry_point": "routes not read one by one (about 370)",
"parameter": "per route",
"attacker_controllable": "yes",
"component": "authz",
"caller_must_enforce": "follow the gate pattern",
"provenance": "inferred",
"source": "open question Q8"
},
{
"entry_point": "media, report, embed, IIIF content",
"parameter": "exp, sig, full, ?s= share token, /s/<code> short code",
"attacker_controllable": "yes",
"component": "authz",
"caller_must_enforce": "holder keeps the link private",
"provenance": "documented",
"source": "SECURITY.md \"only the server's own signed links should work\""
},
{
"entry_point": "/iiif/auth/token, /probe, /logout",
"parameter": "origin, messageId, IIIF cookie, probe token",
"attacker_controllable": "yes",
"component": "authz",
"caller_must_enforce": "operator sets iiif.allowed_origins",
"provenance": "documented",
"source": "docs/iiif.md \"limits which viewer sites can get tokens\""
},
{
"entry_point": "/recordings, /search, /public/search, chat retrieval",
"parameter": "query text, filters, sort, limit, offset",
"attacker_controllable": "yes",
"component": "query",
"caller_must_enforce": "none — P-NO-INJECTION",
"provenance": "documented",
"source": "SECURITY.md \"injection into the database queries\""
},
{
"entry_point": "/graph/query, MCP graph_query",
"parameter": "Cypher text, params",
"attacker_controllable": "yes",
"component": "query",
"caller_must_enforce": "none — P-QUERY",
"provenance": "documented",
"source": "docs/graph.md \"3 million steps\""
},
{
"entry_point": "/namespaces/{name}/sparql",
"parameter": "SPARQL text",
"attacker_controllable": "yes",
"component": "query",
"caller_must_enforce": "none — P-NO-FETCH",
"provenance": "documented",
"source": "routes/rdf.py sparql docstring \"SERVICE and FROM aren't allowed\""
},
{
"entry_point": "/namespaces/{name}/rdf/import",
"parameter": "Turtle, N-Triples, JSON-LD",
"attacker_controllable": "yes",
"component": "query",
"caller_must_enforce": "none — P-NO-FETCH",
"provenance": "documented",
"source": "rdf_import.py docstring \"RDF/XML isn't read\""
},
{
"entry_point": "/uploads, /import, /recordings/{rid}/files",
"parameter": "file bytes, name, extension, size",
"attacker_controllable": "yes",
"component": "ingest",
"caller_must_enforce": "none — P-UPLOAD",
"provenance": "documented",
"source": "docs/configuration.md \"Uploads accept only the types\""
},
{
"entry_point": "watched folders, storage sources, IMAP, iCal",
"parameter": "remote file names, bytes, mail, events",
"attacker_controllable": "yes",
"component": "outbound",
"caller_must_enforce": "admin creates sources",
"provenance": "inferred",
"source": "open question Q17"
},
{
"entry_point": "Google Calendar sources",
"parameter": "service account key file or connected account's refresh token, calendar ids, impersonate; the events Google returns",
"attacker_controllable": "yes",
"component": "outbound",
"caller_must_enforce": "admin creates sources; read-only calendar.readonly scope; Google's own token endpoint and API address (a key file's token_uri is ignored); event text escaped into its .ics without control characters",
"provenance": "documented",
"source": "docs/processing.md \"The token endpoint is always Google's own\""
},
{
"entry_point": "POST /imports/web, extension http tools, notification targets",
"parameter": "URL",
"attacker_controllable": "yes",
"component": "outbound",
"caller_must_enforce": "none — P-SSRF-GUARD",
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"entry_point": "admin settings (model, embeddings, speech, decisions, Fedora, bridge, telemetry, IIIF import, rclone remotes, IMAP, iCal, MQTT bridge, OIDC issuer)",
"parameter": "URL, credentials, api_key_env",
"attacker_controllable": "no",
"component": "outbound",
"caller_must_enforce": "admin is trusted",
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"entry_point": "replies from remote servers",
"parameter": "web page HTML and scripts, IIIF manifests, model replies, OIDC discovery, calendars and their redirects",
"attacker_controllable": "yes",
"component": "outbound",
"caller_must_enforce": "treat as untrusted",
"provenance": "inferred",
"source": "open question Q17"
},
{
"entry_point": "chat, /graph/ask, MCP tools",
"parameter": "prompt text; model-chosen tool calls",
"attacker_controllable": "yes",
"component": "assistant",
"caller_must_enforce": "none — P-RETRIEVAL",
"provenance": "documented",
"source": "docs/assistant.md \"limited to what the person can read\""
},
{
"entry_point": "Lens extension side panel",
"parameter": "answers shown there (model text that can repeat third-party text), the sign-in coming back",
"attacker_controllable": "yes",
"component": "assistant",
"caller_must_enforce": "none; answers render as Markdown cleaned by DOMPurify (no scripts, images or forms; only http and https links), and the sign-in checks its state and PKCE",
"provenance": "documented",
"source": "extensions/browser/src/core/assistant.js answerHtml docstring \"cleaned of anything that could run or fetch on its own\""
},
{
"entry_point": "extensions (prompt, graph and http tools, hooks), custom nodes including template nodes",
"parameter": "tool spec, graph JSON, template text",
"attacker_controllable": "yes",
"component": "assistant",
"caller_must_enforce": "person enables only extensions they trust",
"provenance": "documented",
"source": "docs/assistant.md \"other people's run only when an admin shared them\""
},
{
"entry_point": "Python code tools, shipped and admin-created templates, workflows, pipelines, routines",
"parameter": "Python, Jinja source, regexes",
"attacker_controllable": "no",
"component": "assistant",
"caller_must_enforce": "admin is trusted",
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"entry_point": "MQTT 1883, syslog 5514, POST /sensors/push/{token}/{stream}",
"parameter": "payloads, hub logins, token in the path",
"attacker_controllable": "yes",
"component": "optional",
"caller_must_enforce": "admin enables; operator firewalls",
"provenance": "documented",
"source": "docs/sensors.md \"Sensors are off until an admin turns them on\""
},
{
"entry_point": "NFS share (port 12049 on the host, 2049 in the worker)",
"parameter": "NFS and MOUNT RPCs, client address",
"attacker_controllable": "yes",
"component": "optional",
"caller_must_enforce": "admin enables and picks the account; operator firewalls",
"provenance": "documented",
"source": "docs/file-shares.md \"NFS has no login\""
},
{
"entry_point": "SMB share (port 1445 on the host, 4445 in the worker)",
"parameter": "SMB2 requests, NTLM sign-in with a file access key, client address",
"attacker_controllable": "yes",
"component": "optional",
"caller_must_enforce": "admin enables; operator firewalls",
"provenance": "documented",
"source": "docs/smb.md \"What keeps it safe\""
},
{
"entry_point": ".env, archive.yaml, LENS_*, ARCHIVE_SECRET_KEY, RCLONE_BINARY",
"parameter": "process configuration",
"attacker_controllable": "no",
"component": "outbound",
"caller_must_enforce": "keep unreadable to others",
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"entry_point": "workers and DB peers",
"parameter": "DB credential, shared database rows",
"attacker_controllable": "no",
"component": "authz",
"caller_must_enforce": "run peers on trusted hosts",
"provenance": "inferred",
"source": "open question Q9"
},
{
"entry_point": "host control inbox and the monitoring scrape token",
"parameter": "inbox/upgrade request (release ref, empty for the version running, with https=0|1, public=0|1 and target=full|lean; or action=services with monitoring=on|off); /metrics scrape token and .lens-control/prometheus-token",
"attacker_controllable": "yes",
"component": "authz",
"caller_must_enforce": "the agent installs only a release tag or the install's branch, changes only the HTTPS ports, the web app's network reach and the edition (other values dropped), and only toggles the monitoring profile, so a writer can at most restart Lens with those choices or open it to the network; the token is compared in constant time and grants metrics only",
"provenance": "documented",
"source": "docs/updates.md, docs/prometheus.md"
},
{
"entry_point": "admin machine setup requests",
"parameter": "PUT /api/v1/admin/install {public, target}, POST /api/v1/settings/https/ports {open}, MCP set_install_settings",
"attacker_controllable": "no",
"component": "authz",
"caller_must_enforce": "admin is trusted; audited (lens.install, https.ports); reaches the machine only as an inbox request the agent checks again",
"provenance": "documented",
"source": "docs/updates.md \"Setting the machine up again\""
},
{
"entry_point": "entity and topic curation tools",
"parameter": "MCP edit_entity, edit_topic, merge_topics, delete_topic, undo_topic_change, undo_graph_change; assistant propose_topic_change: entity and topic ids, names, labels, definitions",
"attacker_controllable": "yes",
"component": "authz",
"caller_must_enforce": "none — the curate permission in each namespace touched and a write-capable principal (the web app's own route functions); the assistant's changes wait for the person's approval; merges and deletes are kept to undo, and audited",
"provenance": "documented",
"source": "docs/mcp.md \"curate entities and topics as an editor does\""
},
{
"entry_point": "microsandbox program requests",
"parameter": "ask/NAME.want in /data/.lens/programs: a program's name",
"attacker_controllable": "yes",
"component": "authz",
"caller_must_enforce": "the start script installs only libreoffice or chromium, each a fixed list of signed Debian packages, answers in a folder only root writes, and reads nothing but the name",
"provenance": "documented",
"source": "docs/microsandbox.md"
}
],
"environment": {
"assumes": [
"Host is Linux containers, an LXC on Proxmox, a NAS package, or a native install on Linux or macOS; the host and its root user are trusted.",
"One SurrealDB database holds every namespace, with optimistic, retried transactions; the embedded engine is for trying Lens and small archives.",
"Expiry uses wall-clock time with no skew handling.",
"Several API processes and workers may run at once; throttle counters and the setup code live in each process's memory.",
"Child processes (rclone, ffmpeg, LibreOffice, Chromium, Tesseract, poppler, cloudflared, llama-server, code tools, uv/pip) run as argument lists.",
"Listening sockets: API and web app; a loopback netguard proxy during captures; MQTT 1883 and syslog 5514 when sensors are on; llama-server when enabled.",
"Files live under data_dir, including secret.key when ARCHIVE_SECRET_KEY is unset; the server log carries the setup code and, without mail, sign-in links."
],
"does_not": [
"make outbound connections except to configured sources, model servers, mail, notification and telemetry targets, components.auto installs and model downloads, and the llama.cpp/cloudflared/hf: downloads when those features are on",
"send telemetry by default",
"check for updates"
],
"provenance": "inferred",
"source": "open question Q19"
},
"build_variants": [
{
"name": "docker-compose.yml, make dev",
"default": "dev secrets, web on 0.0.0.0:3000",
"effect": "voids P-AUTH-REQ; voids P-MEDIA-LINK; narrows P-HARDENED",
"discouraged": false,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"name": "docker-compose.prod.yml, install.sh, Cloudron, Synology, QNAP, Proxmox",
"default": "random secrets (Makefile .env writes SURREAL_PASS=root)",
"effect": "preserves P-HARDENED; preserves P-IPGROUP",
"discouraged": false,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"name": "Proxmox LXC service user",
"default": "systemd units have no User=, so Lens runs as root inside the unprivileged LXC; the SurrealDB container runs as root",
"effect": "narrows P-NO-CODE",
"discouraged": false,
"provenance": "inferred",
"source": "open question Q33"
},
{
"name": "install.sh on a headless host, NAS packages",
"default": "HTTP on all interfaces, ?code= setup URL printed",
"effect": "narrows P-SETUP-ONCE",
"discouraged": true,
"provenance": "inferred",
"source": "open question Q31"
},
{
"name": "Plain HTTP",
"default": "on",
"effect": "voids P-SETUP-ONCE; narrows P-AUTH-REQ",
"discouraged": true,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"name": "SurrealDB root/root",
"default": "on in env defaults",
"effect": "voids P-NS-ISOLATION",
"discouraged": true,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"name": "Image build context (.dockerignore per image)",
"default": ".dockerignore per image",
"effect": "narrows P-SECRETS",
"discouraged": false,
"provenance": "inferred",
"source": "open question Q44"
},
{
"name": "/docs and /openapi.json (OPENAPI_URL)",
"default": "on",
"effect": "no effect on claimed properties",
"discouraged": false,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"name": "tokens.oauth_enabled",
"default": "on",
"effect": "preserves P-OAUTH",
"discouraged": false,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"name": "iiif.allowed_origins",
"default": "[\"*\"]",
"effect": "narrows P-ACCESS-PARTS",
"discouraged": false,
"provenance": "inferred",
"source": "open question Q1"
},
{
"name": "server.trusted_proxies, LENS_TRUSTED_PROXY_HOSTS",
"default": "127.0.0.0/8, ::1; compose names its containers",
"effect": "narrows P-IPGROUP; narrows P-THROTTLE",
"discouraged": false,
"provenance": "documented",
"source": "docs/configuration.md \"Trusted proxies\""
},
{
"name": "TRUST_PROXY_HEADERS (web app)",
"default": "off",
"effect": "narrows
10BC8
P-OAUTH",
"discouraged": false,
"provenance": "documented",
"source": "docs/authentication.md \"anyone could choose the address\""
},
{
"name": "server.allowed_hosts, ARCHIVE_ALLOWED_HOSTS",
"default": "127.0.0.1, localhost",
"effect": "voids P-HOST",
"discouraged": false,
"provenance": "documented",
"source": "docs/configuration.md \"stops DNS rebinding\""
},
{
"name": "server.secure_cookies",
"default": "false",
"effect": "narrows P-BEARER-ONLY",
"discouraged": false,
"provenance": "documented",
"source": "docs/configuration.md \"Put everything behind HTTPS\""
},
{
"name": "auth.passwords",
"default": "off on fresh installs",
"effect": "voids P-PASSWORDS-OFF",
"discouraged": false,
"provenance": "documented",
"source": "docs/authentication.md \"With passwords off\""
},
{
"name": "documents.web_networks, LENS_WEB_NETWORKS; notifications.networks",
"default": "empty",
"effect": "narrows P-SSRF-GUARD; narrows P-ADMIN-GUARD",
"discouraged": false,
"provenance": "documented",
"source": "docs/configuration.md \"loopback and cloud metadata addresses stay out\""
},
{
"name": "Chromium sandbox",
"default": "off as root or without user namespaces",
"effect": "narrows P-CONVERT-OFFLINE",
"discouraged": false,
"provenance": "documented",
"source": "docs/configuration.md \"runs with its sandbox where it can\""
},
{
"name": "sensors.enabled, sensors.mqtt_anonymous",
"default": "off (bind 0.0.0.0); off",
"effect": "narrows P-SENSORS",
"discouraged": false,
"provenance": "documented",
"source": "docs/sensors.md \"Sensors are off until an admin turns them on\""
},
{
"name": "tunnel.mode, local_llm.enabled, telemetry",
"default": "off",
"effect": "preserves P-TELEMETRY; preserves P-CHILD-SECRETS",
"discouraged": false,
"provenance": "documented",
"source": "docs/remote-access.md \"It's off until an admin turns it on\""
},
{
"name": "components.auto",
"default": "on",
"effect": "preserves P-MODEL-CHECKSUM",
"discouraged": false,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"name": "encryption.files",
"default": "on for new archives",
"effect": "voids P-ENCRYPTION",
"discouraged": false,
"provenance": "documented",
"source": "docs/encryption.md \"A new archive turns this on at its first start\""
},
{
"name": "ai.extensions, ai.disabled_tools",
"default": "on, none",
"effect": "preserves P-EXT-SCOPE",
"discouraged": false,
"provenance": "documented",
"source": "docs/assistant.md \"Extending the assistant\""
},
{
"name": "ALGORITHM, MEDIA_URL_EXPIRE_SECONDS, ACCESS_TOKEN_EXPIRE_SECONDS",
"default": "HS256, 21600, 900",
"effect": "narrows P-LIFETIMES; narrows P-MEDIA-LINK",
"discouraged": false,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"name": "VALIDATE_CERTS (mail), IMAP security: none, http:// model URLs",
"default": "certs checked",
"effect": "narrows P-SECRETS",
"discouraged": false,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
}
],
"adversaries": {
"in_scope": [
"anonymous-internet-visitor",
"link-holder",
"signed-in-person-no-role",
"namespace-viewer-editor-owner",
"api-token-oauth-app-mcp-agent",
"content-author",
"lan-host-or-device",
"network-attacker-on-path"
],
"out_of_scope": [
"instance-admin",
"host-operator-log-readers-backup-recipients",
"worker-host-db-credential-holder",
"upstream-publishers-cloudflare-providers"
],
"provenance": "inferred"
},
"properties_provided": [
{
"property": "P-NS-ISOLATION",
"violation_symptom": "info-leak, integrity-bypass",
"severity_tier": "security",
"conditions": "a principal with no role in a namespace and no grant for an asset cannot read or change anything in it, except what the asset's access level opens to anyone (P-ACCESS-PARTS); the namespace and its assets answer 404; grants for one asset open only that asset's visitor pages and IIIF",
"provenance": "documented",
"source": "SECURITY.md \"reading or changing anything in a namespace you have no role in\""
},
{
"property": "P-ACCESS-PARTS",
"violation_symptom": "info-leak",
"severity_tier": "security",
"conditions": "a closed part of a public asset, and any restricted or private asset, opens only for a role, permission, IP group or link; a closed text part stays out of search and downloads; only owners change access and open parts",
"provenance": "documented",
"source": "docs/access.md \"the parts of a public asset anyone may use\""
},
{
"property": "P-ROLE",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "a principal does no more than its role allows; writes need a write-capable principal and a role with the permission the route names (or editor or owner where a route still asks for a rank); publishing and access changes need the publish permission, through every API path; a custom role counts as the largest built-in role it covers and never does more than its permissions",
"provenance": "documented",
"source": "SECURITY.md \"doing more than your role allows\""
},
{
"property": "P-ROLE-GRANT",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "only admins make, change and delete roles; built-in roles never change; a non-admin with the members permission gives only roles whose permissions they hold all of, and changes only people whose role they hold all of",
"provenance": "documented",
"source": "docs/access.md \"they can't promote themselves or take out an owner\""
},
{
"property": "P-NO-INJECTION",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "request text does not inject into database queries, a shell or the processing tools",
"provenance": "documented",
"source": "SECURITY.md \"injection into the database queries, the shell\""
},
{
"property": "P-MEDIA-LINK",
"violation_symptom": "info-leak",
"severity_tier": "security",
"conditions": "media, frames, documents and reports open without a session only through a link the server signed for that path, or a live share link; text people or models write is never signed",
"provenance": "documented",
"source": "docs/architecture.md \"is never signed\""
},
{
"property": "P-SHARE",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "a share link opens one asset, read-only; it expires and can be revoked; only hashes are stored; a dead link answers the same 410 whatever went wrong",
"provenance": "documented",
"source": "docs/authentication.md \"give read-only access to one asset's player and embed\""
},
{
"property": "P-AUTH-REQ",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "a request is authenticated only if its bearer verifies and the account and session are live; sign-out and disabling take effect at once, for every token kind; revoking an app grant or API token ends its tokens at once",
"provenance": "documented",
"source": "docs/authentication.md \"Every request checks that the account is still active\""
},
{
"property": "P-REMOVE",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "removing an account ends every way it signs in at once and takes every role, collection role and recording permission; its email can make a new account, which inherits nothing; admins only, never yourself, never the Matterbridge account, never the last passkey into a vault unless the admin agrees to lose its files",
"provenance": "documented",
"source": "docs/authentication.md \"Removing someone\""
},
{
"property": "P-BEARER-ONLY",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "/api/v1 takes bearer tokens only, never cookies; the IIIF authorization flow and the web app's NextAuth handlers are the cookie surfaces",
"provenance": "documented",
"source": "docs/architecture.md \"so there is no CSRF surface\""
},
{
"property": "P-SETUP-ONCE",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "no public sign-up; only the one-time setup code makes the first admin, and it dies once an account exists",
"provenance": "documented",
"source": "docs/configuration.md \"so a stranger who finds a new\""
},
{
"property": "P-ONCE",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "sign-in links, WebAuthn flows, sign-in tickets, OAuth codes and external-login states work once; a refused or failed attempt consumes nothing",
"provenance": "documented",
"source": "docs/authentication.md \"works once\""
},
{
"property": "P-REUSE",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "a rotated web refresh token replayed after 60 s ends the whole session; a rotated OAuth refresh token replayed within 60 s is refused and changes nothing, after that it ends the grant; a spent OAuth code replayed ends the grant",
"provenance": "documented",
"source": "docs/authentication.md \"A swapped one that comes back within 60 seconds is refused\""
},
{
"property": "P-LIFETIMES",
"violation_symptom": "bad-data-accepted",
"severity_tier": "correctness",
"conditions": "access JWT 900 s; sign-in link 72 h, emailed links 60 min; OAuth code 5 min; app tokens 60 min / 30 days; API tokens default 90 days, never past tokens.max_days",
"provenance": "documented",
"source": "docs/authentication.md \"A link lasts three days\""
},
{
"property": "P-OAUTH",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "PKCE (S256) required; redirects must match a registered URI; addresses with a user name or backslash refused; the consent page cannot be framed; app tokens act as the person and never administer; /mcp refuses a token issued for another server, caps bodies at 1 MB and batches at 20",
"provenance": "documented",
"source": "docs/authentication.md \"PKCE is required\""
},
{
"property": "P-PASSWORDS-OFF",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "with passwords off, password sign-in, changes and resets answer 403",
"provenance": "documented",
"source": "docs/authentication.md \"With passwords off, password sign-in\""
},
{
"property": "P-THROTTLE",
"violation_symptom": "x-brute-force",
"severity_tier": "correctness",
"conditions": "failed password sign-ins are throttled per email and address (8 per 15 minutes); unknown emails take as long to reject as wrong passwords; in both compose files each visitor gets their own throttle",
"provenance": "documented",
"source": "docs/authentication.md \"throttled per email and address\""
},
{
"property": "P-IPGROUP",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "an address opens an IP group only when the server can vouch for it, through server.trusted_proxies or LENS_TRUSTED_PROXY_HOSTS; both compose files trust the web app container so IP groups see real addresses; elsewhere a reverse proxy must set X-Forwarded-For (other variants: Q2)",
"provenance": "documented",
"source": "docs/access.md \"An address the server can't vouch for opens nothing\""
},
{
"property": "P-HOST",
"violation_symptom": "bad-data-accepted",
"severity_tier": "correctness",
"conditions": "the Host header must be a served host; this stops DNS rebinding",
"provenance": "documented",
"source": "docs/configuration.md \"stops DNS rebinding\""
},
{
"property": "P-FRAME",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "the API sends a strict CSP; only the player (/embed/<id>, /s/<code>) can be framed, and only by server.embed_frame_ancestors",
"provenance": "documented",
"source": "docs/configuration.md \"can be framed\""
},
{
"property": "P-HARDENED",
"violation_symptom": "x-missing-hardening",
"severity_tier": "correctness",
"conditions": "in the compose files the database, API and Fedora ports bind to 127.0.0.1, containers run non-root with no-new-privileges, and the web app sends nosniff, a referrer policy, a permissions policy and a frame policy (HSTS through Cloudflare); in docker-compose.prod.yml the DB password is random and the web app listens on this machine unless LENS_BIND is set",
"provenance": "documented",
"source": "docs/deployment.md \"What's hardened already\""
},
{
"property": "P-SECRETS",
"violation_symptom": "info-leak",
"severity_tier": "security",
"conditions": "stored secrets (source credentials, notification secrets, model API keys, iCal addresses) are encrypted and write-only; the API reports whether one is set, never its value; a key from the environment is never shown; they do not leak through the API, logs, exports, or a redirect to another server",
"provenance": "documented",
"source": "SECURITY.md \"stored credentials for sources and model servers leaking\""
},
{
"property": "P-SSRF-GUARD",
"violation_symptom": "info-leak",
"severity_tier": "security",
"conditions": "URLs a non-admin sets (web import and extension http tools by editors; notification targets by owners) reach public addresses only, or networks the operator listed; web paths use ports 80 and 443, follow redirects only to http and https, re-check every redirect and sub-request, and connect to the checked address",
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"property": "P-ADMIN-GUARD",
"violation_symptom": "info-leak",
"severity_tier": "security",
"conditions": "guards Lens documents on admin-set operands hold: iCal feeds are fetched like web captures (public addresses only and documents.web_networks, ports 80 and 443, the password never goes on to a server the calendar redirects to); the telemetry endpoint cannot carry a user name or password or be a link-local address",
"provenance": "documented",
"source": "docs/processing.md \"fetched the way web pages are captured\""
},
{
"property": "P-NO-FETCH",
"violation_symptom": "info-leak",
"severity_tier": "security",
"conditions": "SPARQL refuses SERVICE and FROM and never loads a named graph; RDF import fetches nothing, refuses remote JSON-LD contexts and does not read RDF/XML",
"provenance": "documented",
"source": "rdf_import.py docstring \"Nothing is fetched while reading\""
},
{
"property": "P-QUERY",
"violation_symptom": "integrity-bypass, hang",
"severity_tier": "security",
"conditions": "Cypher is read-only and runs on the caller's readable namespaces; a query stops within 3 million steps or 8 seconds; paths run up to 8 hops",
"provenance": "documented",
"source": "docs/graph.md \"3 million steps\""
},
{
"property": "P-RETRIEVAL",
"violation_symptom": "info-leak, hang",
"severity_tier": "security",
"conditions": "chat, assistant tools and MCP return only what the person can read, within the conversation's scope, and the tool loop stops at ai.max_steps and ai.max_transcript_reads",
"provenance": "documented",
"source": "docs/assistant.md \"limited to what the person can read\""
},
{