Repository navigation
Expand file tree
/
Copy pathphotoview-opus-5.sarif
More file actions
1855 lines (1855 loc) · 99.4 KB
/
Copy pathphotoview-opus-5.sarif
File metadata and controls
1855 lines (1855 loc) · 99.4 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
811
812
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
{
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"version": "2.1.0",
"runs": [
{
"tool": {
"driver": {
"name": "Shannon",
"informationUri": "https://github.com/KeygraphHQ/shannon",
"rules": [
{
"id": "shannon/injection",
"name": "Injection",
"shortDescription": {
"text": "Injection"
},
"fullDescription": {
"text": "Untrusted input reaches an interpreter sink (SQL, OS command, template, file path or deserializer) at a position where it can alter the structure of the statement rather than only supply data."
},
"help": {
"text": "Separate code from data at the sink: bind SQL parameters, pass command arguments as an array, and allowlist file paths. Escaping is a weaker control than parameterisation and breaks whenever the sink context changes."
},
"properties": {
"tags": [
"security",
"shannon"
]
}
},
{
"id": "shannon/auth",
"name": "Authentication",
"shortDescription": {
"text": "Authentication"
},
"fullDescription": {
"text": "A weakness in credential verification or session lifecycle that lets an attacker assume another identity or retain access they should have lost."
},
"help": {
"text": "Issue a fresh session identifier on every privilege change, set HttpOnly, Secure and SameSite on session cookies, rate-limit credential endpoints, and verify the signature and algorithm of externally issued tokens."
},
"properties": {
"tags": [
"security",
"shannon"
]
}
},
{
"id": "shannon/authz",
"name": "Authorization",
"shortDescription": {
"text": "Authorization"
},
"fullDescription": {
"text": "An access control decision is missing, evaluated in the client, or applied at the wrong layer, letting a caller act on resources they do not own."
},
"help": {
"text": "Check ownership and role on the server for every object reference, and enforce it in the data-access layer rather than per route, denying by default. An unguessable identifier is not an access control."
},
"properties": {
"tags": [
"security",
"shannon"
]
}
},
{
"id": "shannon/miscellaneous",
"name": "Miscellaneous Security Vulnerability",
"shortDescription": {
"text": "Miscellaneous Security Vulnerability"
},
"fullDescription": {
"text": "A security weakness outside Shannon's named vulnerability classes that can affect confidentiality, integrity, or availability."
},
"help": {
"text": "Apply the finding-specific remediation, add a regression test at the affected trust boundary, and verify that equivalent entry points enforce the same control."
},
"properties": {
"tags": [
"security",
"shannon"
]
}
}
]
}
},
"automationDetails": {
"id": "shannon/exploit/photoview-v240-doyensec-xbow-local-r4"
},
"invocations": [
{
"executionSuccessful": true
}
],
"taxonomies": [
{
"name": "OWASP Top Ten 2025",
"organization": "OWASP",
"informationUri": "https://owasp.org/Top10/",
"shortDescription": {
"text": "OWASP Top Ten 2025 categories."
},
"taxa": [
{
"id": "A01:2025",
"name": "Broken Access Control"
},
{
"id": "A02:2025",
"name": "Security Misconfiguration"
},
{
"id": "A04:2025",
"name": "Cryptographic Failures"
},
{
"id": "A05:2025",
"name": "Injection"
},
{
"id": "A06:2025",
"name": "Insecure Design"
},
{
"id": "A07:2025",
"name": "Authentication Failures"
},
{
"id": "A10:2025",
"name": "Mishandling of Exceptional Conditions"
}
]
}
],
"results": [
{
"ruleId": "shannon/injection",
"level": "error",
"message": {
"text": "SQL Injection — {album_id} Path Segment in GET /api/download/album/{album_id}/{media_purpose}. The album ZIP-download handler splices the raw `{album_id}` URL path segment into a GORM inline condition. GORM only binds the value as a primary key when it parses as an integer, so any non-numeric string is concatenated verbatim into `SELECT * FROM albums WHERE <attacker SQL>`. The query executes before `authenticateAlbum`, making the injection reachable with no session and no share token, and the handler's 404-vs-403 status split provides a fast boolean oracle.",
"markdown": "**SQL Injection — {album_id} Path Segment in GET /api/download/album/{album_id}/{media_purpose}**\n\nThe album ZIP-download handler splices the raw `{album_id}` URL path segment into a GORM inline condition. GORM only binds the value as a primary key when it parses as an integer, so any non-numeric string is concatenated verbatim into `SELECT * FROM albums WHERE <attacker SQL>`. The query executes before `authenticateAlbum`, making the injection reachable with no session and no share token, and the handler's 404-vs-403 status split provides a fast boolean oracle.\n\n**Impact**\n\nUnauthenticated read of the entire `photoview` MariaDB database and full takeover of the `admin` account. Demonstrated: DB fingerprint (MariaDB 12.3.3, database `photoview`, user `photoview@10.89.5.3`), all 14 table names, the `users` and `access_tokens` column lists, five user rows with bcrypt password hashes and admin flags, and five plaintext session tokens — one of which was replayed against /api/graphql to authenticate as `admin` (id 1, admin: true).\n\n**Remediation**\n\nBind the path segment as a typed parameter rather than passing it to GORM as an inline condition: parse `{album_id}` with strconv.Atoi and reject non-integer values with HTTP 400 before any query, and use `db.Where(\"id = ?\", id).First(&album)` instead of `db.Find(&album, albumID)`. Move the `authenticateAlbum` check ahead of the database lookup, remove `MultiStatements = true` from the MySQL DSN in api/database/database.go:33, and store access tokens as hashes rather than plaintext so a database read cannot be replayed as a session.\n\nFull exploitation evidence: `Security-Assessment-Report.pdf`"
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "api/routes/downloads.go"
},
"region": {
"startLine": 25,
"startColumn": 2
}
},
"message": {
"text": "Validated SAST source location (CWE-89)"
}
}
],
"webRequest": {
"method": "GET",
"target": "http://host.docker.internal:4800/api/download/album/{album_id}/thumbnail"
},
"taxa": [
{
"id": "A05:2025",
"toolComponent": {
"name": "OWASP Top Ten 2025"
}
}
],
"properties": {
"findingId": "INJ-01",
"parameter": "album_id",
"status": "exploited",
"authState": "Unauthenticated",
"prerequisites": "None. Network access to http://host.docker.internal:4800 only — no credentials, cookie, or album share token.",
"sastRuleId": "CWE-89"
},
"ruleIndex": 0
},
{
"ruleId": "shannon/injection",
"level": "note",
"message": {
"text": "Missing Path Confinement on the userAddRootPath rootPath Argument. `userAddRootPath` passes the client-supplied `rootPath` through `path.Clean` and then only `os.Stat` in `ValidRootPath` — there is no allow-list, chroot, or prefix confinement. Any directory on the server can therefore be registered as a media root; the scanner walks it and `MediaURL.CachedPath()` returns the raw on-disk path, which `http.ServeFile` streams. The same mutation also acts as an arbitrary-path existence oracle for the whole server filesystem.",
"markdown": "**Missing Path Confinement on the userAddRootPath rootPath Argument**\n\n`userAddRootPath` passes the client-supplied `rootPath` through `path.Clean` and then only `os.Stat` in `ValidRootPath` — there is no allow-list, chroot, or prefix confinement. Any directory on the server can therefore be registered as a media root; the scanner walks it and `MediaURL.CachedPath()` returns the raw on-disk path, which `http.ServeFile` streams. The same mutation also acts as an arbitrary-path existence oracle for the whole server filesystem.\n\n**Impact**\n\nAn application administrator can step outside the configured media roots: any directory on the server can be registered as an album, and every file in it that the scanner classifies as media is streamed byte-for-byte through /api/photo/... Demonstrated by mounting /app/ui (the app install directory, not the configured /photos root) and retrieving /app/ui/logo512.png with a matching SHA-256. The mutation additionally leaks whether any absolute path exists on the server.\n\n**Remediation**\n\nConfine root paths to an operator-configured allow-list: extend `ValidRootPath` (api/scanner/scanner_album.go:78-86) to resolve the cleaned path with `filepath.EvalSymlinks` and require it to be a directory (`IsDir()`) whose absolute form is a prefix-match under one of the permitted media base directories supplied by configuration/environment. Reject anything else, and return a single generic error for both 'not permitted' and 'does not exist' so the mutation stops functioning as a filesystem existence oracle. Do not follow symlinks out of the mounted tree (api/utils/utils.go:68-93).\n\nFull exploitation evidence: `Security-Assessment-Report.pdf`"
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "api/scanner/scanner_album.go"
},
"region": {
"startLine": 20,
"endLine": 33
}
},
"logicalLocations": [
{
"name": "NewRootAlbum",
"kind": "function"
}
],
"message": {
"text": "sink"
}
}
],
"relatedLocations": [
{
"id": 1,
"physicalLocation": {
"artifactLocation": {
"uri": "api/graphql/resolvers/user.go"
},
"region": {
"startLine": 266,
"endLine": 282
}
},
"logicalLocations": [
{
"name": "mutationResolver.UserAddRootPath",
"kind": "function"
}
],
"message": {
"text": "source"
}
},
{
"id": 2,
"physicalLocation": {
"artifactLocation": {
"uri": "api/graphql/resolvers/user.go"
},
"region": {
"startLine": 107,
"endLine": 140
}
},
"logicalLocations": [
{
"name": "mutationResolver.InitialSetupWizard",
"kind": "function"
}
],
"message": {
"text": "source"
}
},
{
"id": 3,
"physicalLocation": {
"artifactLocation": {
"uri": "api/scanner/scanner_album.go"
},
"region": {
"startLine": 78,
"endLine": 86
}
},
"logicalLocations": [
{
"name": "ValidRootPath",
"kind": "function"
}
],
"message": {
"text": "guard"
}
}
],
"webRequest": {
"method": "POST",
"target": "http://host.docker.internal:4800/api/graphql"
},
"taxa": [
{
"id": "A01:2025",
"toolComponent": {
"name": "OWASP Top Ten 2025"
}
}
],
"properties": {
"findingId": "INJ-02",
"parameter": "rootPath",
"status": "exploited",
"authState": "Authenticated administrator (admin flag set)",
"prerequisites": "An administrator GraphQL session (admin flag set) — login as the engagement admin account or an auth-token cookie belonging to an admin. The proof creates state (root albums and a scan) that was removed afterwards with userRemoveRootAlbum."
},
"ruleIndex": 0
},
{
"ruleId": "shannon/auth",
"level": "error",
"message": {
"text": "No Server-Side Session Invalidation on SPA /logout and the updateUser Password Change. The backend contains no logout mutation, no revocation endpoint and no DELETE against `access_tokens`. Logging out only erases the client-side cookie, and changing an account's password does not touch its issued tokens, which carry a fixed 14-day TTL. A captured token was replayed successfully after both a full UI logout and an administrator password reset.",
"markdown": "**No Server-Side Session Invalidation on SPA /logout and the updateUser Password Change**\n\nThe backend contains no logout mutation, no revocation endpoint and no DELETE against `access_tokens`. Logging out only erases the client-side cookie, and changing an account's password does not touch its issued tokens, which carry a fixed 14-day TTL. A captured token was replayed successfully after both a full UI logout and an administrator password reset.\n\n**Impact**\n\nBoth recovery actions available to a defender after a session compromise fail. An administrator token still returned {\"myUser\":{\"id\":1,\"username\":\"admin\",\"admin\":true}} after the browser logged out and cleared the cookie, and a user token still authenticated after the administrator changed that user's password to a strong new value (the old password was correctly rejected at the same moment). A stolen token stays live for its full 14-day lifetime with no operator lever short of deleting the account.\n\n**Remediation**\n\nAdd a server-side `logout` mutation that deletes the presented token's `access_tokens` row, and make `UpdateUser` (api/graphql/resolvers/user.go:220-238) delete all `access_tokens` rows for the user whenever the password column changes. Store token values hashed, and provide an admin-facing 'revoke all sessions' mutation. Have the SPA /logout route call the server mutation before `clearTokenCookie()`, and clear any `share-token-pw-*` cookies at the same time.\n\nFull exploitation evidence: `Security-Assessment-Report.pdf`"
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "ui/src/components/routes/Routes.tsx"
},
"region": {
"startLine": 151,
"endLine": 155
}
},
"logicalLocations": [
{
"name": "LogoutPage",
"kind": "function"
}
],
"message": {
"text": "sink"
}
}
],
"relatedLocations": [
{
"id": 1,
"physicalLocation": {
"artifactLocation": {
"uri": "api/graphql/resolvers/user.go"
},
"region": {
"startLine": 220,
"endLine": 238
}
},
"logicalLocations": [
{
"name": "UpdateUser",
"kind": "function"
}
],
"message": {
"text": "guard"
}
},
{
"id": 2,
"physicalLocation": {
"artifactLocation": {
"uri": "api/graphql/models/user.go"
},
"region": {
"startLine": 126,
"endLine": 152
}
},
"logicalLocations": [
{
"name": "GenerateAccessToken",
"kind": "function"
}
],
"message": {
"text": "source"
}
},
{
"id": 3,
"physicalLocation": {
"artifactLocation": {
"uri": "api/dataloader/userLoader.go"
},
"region": {
"startLine": 17,
"endLine": 22
}
},
"logicalLocations": [
{
"name": "userLoader",
"kind": "function"
}
],
"message": {
"text": "guard"
}
}
],
"webRequest": {
"method": "POST",
"target": "http://host.docker.internal:4800/api/graphql"
},
"taxa": [
{
"id": "A07:2025",
"toolComponent": {
"name": "OWASP Top Ten 2025"
}
}
],
"properties": {
"findingId": "AUTH-01",
"parameter": "auth-token cookie",
"status": "exploited",
"authState": "Holder of a valid session token for the target account",
"prerequisites": "A valid session token for the target account. The password-reset arm used an administrator session to create a disposable test user (victim1) and reset its password — that is the defender action under test, not attacker capability."
},
"ruleIndex": 1
},
{
"ruleId": "shannon/auth",
"level": "warning",
"message": {
"text": "No Rate Limiting, Lockout or Failed-Login Monitoring on the authorizeUser Login Mutation. The only login endpoint applies no per-IP or per-account rate limit, no lockout, no backoff and no CAPTCHA, and the server keeps no failed-attempt state at all (models.User has no failed_attempts/locked_until column). 120 consecutive failed logins against admin all returned HTTP 200 with flat latency, and the correct password worked immediately afterwards.",
"markdown": "**No Rate Limiting, Lockout or Failed-Login Monitoring on the authorizeUser Login Mutation**\n\nThe only login endpoint applies no per-IP or per-account rate limit, no lockout, no backoff and no CAPTCHA, and the server keeps no failed-attempt state at all (models.User has no failed_attempts/locked_until column). 120 consecutive failed logins against admin all returned HTTP 200 with flat latency, and the correct password worked immediately afterwards.\n\n**Impact**\n\nAn unbounded, unmonitored online password-guessing channel against every account on the instance, including the administrator: 120 consecutive failures produced no lockout, no throttling and no distinguishable response, and a valid 14-day session token was minted immediately afterwards. Concurrency raised the rate to ~16 req/s with no penalty. No credential was recovered during this run.\n\n**Remediation**\n\nAdd rate-limiting middleware to the GraphQL endpoint registration in api/server.go: track failed attempts per source IP and per username in a shared store, apply exponential backoff after ~5 failures and return HTTP 429, and add a temporary account lockout (e.g. locked_until column on models.User) with an alert to the site log. Cap GraphQL query complexity/aliases so a single request cannot invoke authorizeUser many times, and emit a security log event for each failed login.\n\nFull exploitation evidence: `Security-Assessment-Report.pdf`"
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "api/graphql/resolvers/user.go"
},
"region": {
"startLine": 75,
"endLine": 105
}
},
"logicalLocations": [
{
"name": "AuthorizeUser",
"kind": "function"
}
],
"message": {
"text": "sink"
}
}
],
"relatedLocations": [
{
"id": 1,
"physicalLocation": {
"artifactLocation": {
"uri": "api/server.go"
},
"region": {
"startLine": 67,
"endLine": 72
}
},
"logicalLocations": [
{
"name": "main",
"kind": "function"
}
],
"message": {
"text": "guard"
}
}
],
"webRequest": {
"method": "POST",
"target": "http://host.docker.internal:4800/api/graphql"
},
"taxa": [
{
"id": "A07:2025",
"toolComponent": {
"name": "OWASP Top Ten 2025"
}
}
],
"properties": {
"findingId": "AUTH-02",
"parameter": "authorizeUser",
"status": "exploited",
"authState": "Unauthenticated",
"prerequisites": "None. Anonymous network access to http://host.docker.internal:4800."
},
"ruleIndex": 1
},
{
"ruleId": "shannon/auth",
"level": "warning",
"message": {
"text": "Session Token Returned in the authorizeUser Response Body and Written to a JavaScript-Readable Cookie Without HttpOnly or Secure. The Go backend never issues Set-Cookie; the session token is returned in the login response body and written to document.cookie by the SPA, so HttpOnly is structurally impossible and Secure is absent. The token is a plain bearer credential with a 14-day lifetime and no binding to IP, User-Agent or origin, and the token-bearing response carries no Cache-Control.",
"markdown": "**Session Token Returned in the authorizeUser Response Body and Written to a JavaScript-Readable Cookie Without HttpOnly or Secure**\n\nThe Go backend never issues Set-Cookie; the session token is returned in the login response body and written to document.cookie by the SPA, so HttpOnly is structurally impossible and Secure is absent. The token is a plain bearer credential with a 14-day lifetime and no binding to IP, User-Agent or origin, and the token-bearing response carries no Cache-Control.\n\n**Impact**\n\nThe administrator's session token was read from document.cookie in a logged-in browser and replayed from an unrelated command-line client, which was recognised as admin with admin:true and could enumerate every account on the instance via the admin-gated user query.\n\n**Remediation**\n\nIssue the session server-side: have the authorizeUser resolver call http.SetCookie with HttpOnly, Secure, SameSite=Strict and Path=/ (ideally a __Host- prefixed name), stop returning the token in the GraphQL response body, and set Cache-Control: no-store on authentication responses. Update ui/src/helpers/authentication.ts to stop writing document.cookie, and stop storing share passwords in cleartext cookies in saveSharePassword (authentication.ts:16).\n\nFull exploitation evidence: `Security-Assessment-Report.pdf`"
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "ui/src/helpers/authentication.ts"
},
"region": {
"startLine": 1,
"endLine": 9
}
},
"logicalLocations": [
{
"name": "saveTokenCookie",
"kind": "function"
}
],
"message": {
"text": "sink"
}
}
],
"relatedLocations": [
{
"id": 1,
"physicalLocation": {
"artifactLocation": {
"uri": "api/graphql/resolvers/user.go"
},
"region": {
"startLine": 100,
"endLine": 104
}
},
"logicalLocations": [
{
"name": "AuthorizeUser",
"kind": "function"
}
],
"message": {
"text": "source"
}
},
{
"id": 2,
"physicalLocation": {
"artifactLocation": {
"uri": "api/graphql/auth/auth.go"
},
"region": {
"startLine": 28,
"endLine": 52
}
},
"logicalLocations": [
{
"name": "Middleware",
"kind": "function"
}
],
"message": {
"text": "guard"
}
}
],
"webRequest": {
"method": "POST",
"target": "http://host.docker.internal:4800/api/graphql"
},
"taxa": [
{
"id": "A04:2025",
"toolComponent": {
"name": "OWASP Top Ten 2025"
}
}
],
"properties": {
"findingId": "AUTH-03",
"parameter": "authorizeUser",
"status": "exploited",
"authState": "Unauthenticated for replay; script execution in the origin's context for the read",
"prerequisites": "Ability to run script in the origin's context or otherwise observe the login response/cookie (XSS, a malicious browser extension, or passive capture on the plaintext HTTP channel). The replay half needs nothing but the token value."
},
"ruleIndex": 1
},
{
"ruleId": "shannon/auth",
"level": "warning",
"message": {
"text": "Share-Link Expiry Not Enforced in the shareToken Query or the Token-Authenticated Media Routes. ShareToken.Expire is written when a share is created (share_token_actions.go:46,87) and displayed in the UI, but it is never compared against time.Now() in any validation path. A share token whose expiry is six years in the past still resolves anonymously and still serves the album's media over both GraphQL and REST. The REST path additionally leaks a token-existence oracle through its status codes (500 = no such token, 403 = token exists but password-protected, 200 = valid).",
"markdown": "**Share-Link Expiry Not Enforced in the shareToken Query or the Token-Authenticated Media Routes**\n\nShareToken.Expire is written when a share is created (share_token_actions.go:46,87) and displayed in the UI, but it is never compared against time.Now() in any validation path. A share token whose expiry is six years in the past still resolves anonymously and still serves the album's media over both GraphQL and REST. The REST path additionally leaks a token-existence oracle through its status codes (500 = no such token, 403 = token exists but password-protected, 200 = valid).\n\n**Impact**\n\nA share link created with expire: 2020-01-01 — reported as long expired by the owner and the UI — was used from an unauthenticated client to read the full album listing (titles and absolute server paths such as /photos/autumn-park.jpg) and to download the album archive (HTTP 200, 23,684 bytes). Every share link ever issued on this instance is permanently live regardless of the expiry the owner chose.\n\n**Remediation**\n\nAdd an expiry predicate to every share-token consumption site: in shareTokenFromRequest (api/routes/authenticate_routes.go:63-127) and in the shareToken / shareTokenValidatePassword resolvers, reject tokens where Expire is non-nil and before time.Now(), ideally by scoping the query itself (`WHERE value = ? AND (expire IS NULL OR expire > NOW())`). Return HTTP 403 uniformly for missing, expired and unauthorised tokens so the 500/403/200 status split stops acting as a token-existence oracle, and add a unique index on ShareToken.Value.\n\nFull exploitation evidence: `Security-Assessment-Report.pdf`"
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "api/routes/authenticate_routes.go"
},
"region": {
"startLine": 63,
"endLine": 127
}
},
"logicalLocations": [
{
"name": "shareTokenFromRequest",
"kind": "function"
}
],
"message": {
"text": "sink"
}
}
],
"relatedLocations": [
{
"id": 1,
"physicalLocation": {
"artifactLocation": {
"uri": "api/routes/authenticate_routes.go"
},
"region": {
"startLine": 72,
"endLine": 73
}
},
"logicalLocations": [
{
"name": "shareTokenFromRequest",
"kind": "function"
}
],
"message": {
"text": "guard"
}
},
{
"id": 2,
"physicalLocation": {
"artifactLocation": {
"uri": "api/utils/utils.go"
},
"region": {
"startLine": 15,
"endLine": 31
}
},
"logicalLocations": [
{
"name": "GenerateToken",
"kind": "function"
}
],
"message": {
"text": "source"
}
},
{
"id": 3,
"physicalLocation": {
"artifactLocation": {
"uri": "api/graphql/models/actions/share_token_actions.go"
},
"region": {
"startLine": 46
}
},
"logicalLocations": [
{
"name": "AddAlbumShare",
"kind": "function"
}
],
"message": {
"text": "guard"
}
}
],
"webRequest": {
"method": "GET",
"target": "http://host.docker.internal:4800/api/download/album/1/thumbnail"
},
"taxa": [
{
"id": "A01:2025",
"toolComponent": {
"name": "OWASP Top Ten 2025"
}
}
],
"properties": {
"findingId": "AUTH-04",
"parameter": "token",
"status": "exploited",
"authState": "Unauthenticated (share token only)",
"prerequisites": "Possession of a share token value. The expired token used in the proof was minted through the album owner's shareAlbum mutation with a past expire — owner-side state, not attacker privilege."
},
"ruleIndex": 1
},
{
"ruleId": "shannon/auth",
"level": "warning",
"message": {
"text": "Unauthenticated, Unthrottled Password Oracle on the shareTokenValidatePassword Query. The share-link password check is exposed as a fully anonymous boolean oracle with no attempt counter, lockout, backoff or CAPTCHA. 150 guesses against a single share token ran at a flat ~0.32 s each with no defensive response, recovering the password, which was then replayed via the share-token-pw-<token> cookie to download the protected album.",
"markdown": "**Unauthenticated, Unthrottled Password Oracle on the shareTokenValidatePassword Query**\n\nThe share-link password check is exposed as a fully anonymous boolean oracle with no attempt counter, lockout, backoff or CAPTCHA. 150 guesses against a single share token ran at a flat ~0.32 s each with no defensive response, recovering the password, which was then replayed via the share-token-pw-<token> cookie to download the protected album.\n\n**Impact**\n\nRecovered the password protecting share token aQbuI8He in 150 unthrottled guesses, and repeated the chain end to end on a second share (sp4kBHWj, password letmein): cracked password → share-token-pw-<token> cookie → HTTP 200 and 23,684 bytes of the protected album archive, plus the full media listing with absolute server paths. The password on a share link provides no meaningful protection.\n\n**Remediation**\n\nRate-limit shareTokenValidatePassword per token and per source IP (e.g. 5 attempts then exponential backoff / HTTP 429), and lock or disable a share token after a threshold of failed password attempts with a notification to the share owner. Enforce a minimum share-password strength at shareAlbum/shareMedia/protectShareToken time, and stop persisting the share password in a cleartext share-token-pw-<token> cookie — issue a short-lived signed capability token after successful validation instead.\n\nFull exploitation evidence: `Security-Assessment-Report.pdf`"
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "api/graphql/resolvers/share_token.go"
},
"region": {
"startLine": 67,
"endLine": 94
}
},
"logicalLocations": [
{
"name": "ShareTokenValidatePassword",
"kind": "function"
}
],
"message": {
"text": "sink"
}
}
],
"webRequest": {
"method": "POST",
"target": "http://host.docker.internal:4800/api/graphql"
},
"taxa": [
{
"id": "A07:2025",
"toolComponent": {
"name": "OWASP Top Ten 2025"
}
}
],
"properties": {
"findingId": "AUTH-05",
"parameter": "shareTokenValidatePassword",
"status": "exploited",
"authState": "Unauthenticated",
"prerequisites": "Possession of a share token value (8-character string from the share URL). The password-protected shares used in the proof were created through the owner's shareAlbum mutation to give the brute force a known target; no owner privilege was used during the attack itself."
},
"ruleIndex": 1
},
{
"ruleId": "shannon/auth",
"level": "warning",
"message": {
"text": "No Origin Validation on the WebSocket Upgrade at /api/graphql (CheckOrigin Fails Open). CheckOrigin fails open in this deployment — it returns true in devMode, when UiEndpointUrl() is nil (which it is whenever the container also serves the UI), or when the Origin header is empty. The server therefore accepts a WebSocket upgrade carrying an arbitrary attacker Origin and authenticates the subscription purely from the ambient auth-token cookie, enabling cross-site WebSocket hijacking.",
"markdown": "**No Origin Validation on the WebSocket Upgrade at /api/graphql (CheckOrigin Fails Open)**\n\nCheckOrigin fails open in this deployment — it returns true in devMode, when UiEndpointUrl() is nil (which it is whenever the container also serves the UI), or when the Origin header is empty. The server therefore accepts a WebSocket upgrade carrying an arbitrary attacker Origin and authenticates the subscription purely from the ambient auth-token cookie, enabling cross-site WebSocket hijacking.\n\n**Impact**\n\nA WebSocket opened with Origin: http://evil.example.com, authenticated only by the victim's cookie, received 50 frames of the victim's live notification stream — leaking server-side cache paths and other users' media filenames such as /home/photoview/media-cache/2/11/thumbnail_alice-sunset_jpg_8M94hUta.jpg. The identical connection with no cookie was rejected with 'unauthorized', proving the data was released solely on the strength of the ambient cookie and that the origin is never checked.\n\n**Remediation**\n\nMake CheckOrigin (api/server/websocket.go:14-25) fail closed: require the Origin header to be present and to exactly match the configured public UI origin (fall back to the request Host when the API serves the UI), and reject the upgrade with HTTP 403 otherwise; remove the devMode and nil-UiEndpointUrl escape hatches from production builds. Register auth.AuthWebsocketInit as the transport.Websocket InitFunc (api/graphql/endpoint/graphql_endpoint.go:30-33) so the socket authenticates from an explicit connection_init bearer token rather than an ambient cookie.\n\nFull exploitation evidence: `Security-Assessment-Report.pdf`"
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "api/graphql/endpoint/graphql_endpoint.go"
},
"region": {
"startLine": 30,
"endLine": 33
}
},
"logicalLocations": [
{
"name": "GraphqlEndpoint",
"kind": "function"
}
],
"message": {
"text": "sink"
}
}
],
"relatedLocations": [
{
"id": 1,
"physicalLocation": {
"artifactLocation": {
"uri": "api/server/websocket.go"
},
"region": {
"startLine": 14,
"endLine": 42
}
},
"logicalLocations": [
{
"name": "WebsocketUpgrader",
"kind": "function"
}
],
"message": {
"text": "guard"
}
},
{
"id": 2,
"physicalLocation": {
"artifactLocation": {
"uri": "api/utils/Endpoints.go"
},
"region": {
"startLine": 69,
"endLine": 73
}
},
"logicalLocations": [
{
"name": "UiEndpointUrl",
"kind": "function"
}
],
"message": {
"text": "guard"
}
},
{
"id": 3,
"physicalLocation": {
"artifactLocation": {
"uri": "api/graphql/auth/auth.go"
},
"region": {
"startLine": 75,
"endLine": 101
}
},
"logicalLocations": [
{
"name": "AuthWebsocketInit",
"kind": "function"
}
],
"message": {
"text": "guard"
}
}
],
"webRequest": {
"method": "GET",
"target": "http://host.docker.internal:4800/api/graphql"
},
"taxa": [
{
"id": "A02:2025",
"toolComponent": {
"name": "OWASP Top Ten 2025"
}
}
],
"properties": {
"findingId": "AUTH-06",
"parameter": "Origin",
"status": "exploited",
"authState": "Victim's ambient session cookie (cross-site attacker context)",
"prerequisites": "The victim must be logged in and visit an attacker-controlled page. The auth-token cookie is SameSite=Lax, which browsers do not apply to WebSocket handshakes the way they apply to XHR, and no CSRF token or Origin check exists on the upgrade."
},
"ruleIndex": 1
},
{
"ruleId": "shannon/auth",
"level": "warning",
"message": {
"text": "Session Fixation — Login Does Not Clear or Path-Pin the auth-token Cookie. The SPA writes auth-token on path=/ at login without destroying any pre-existing cookie of the same name on a narrower path, and the Go server reads whichever auth-token cookie the browser sends first. Planting auth-token=<attacker token>; path=/api before login causes the victim to log in successfully in the UI while every API request executes as the attacker's account. The cookie also carries no __Host- prefix or path pinning.",
"markdown": "**Session Fixation — Login Does Not Clear or Path-Pin the auth-token Cookie**\n\nThe SPA writes auth-token on path=/ at login without destroying any pre-existing cookie of the same name on a narrower path, and the Go server reads whichever auth-token cookie the browser sends first. Planting auth-token=<attacker token>; path=/api before login causes the victim to log in successfully in the UI while every API request executes as the attacker's account. The cookie also carries no __Host- prefix or path pinning.\n\n**Impact**\n\nA browser was driven through a genuine successful administrator login — it reached /timeline and document.cookie held the freshly issued admin token — yet every API call from that page resolved as the attacker-controlled account: {\"myUser\":{\"id\":\"7\",\"username\":\"victim2\",\"admin\":false}}, and the admin-only user query was refused with 'user must be admin'. The victim transparently operates inside a session the attacker holds the token for, so anything they upload or create lands in the attacker's account.\n\n**Remediation**\n\nIssue the session cookie server-side on login with the __Host- prefix (which forces path=/ and forbids Domain), and have the server reject requests carrying more than one auth-token cookie rather than silently taking the first match from r.Cookie. In the SPA, call clearTokenCookie() before saveTokenCookie() in login() (ui/src/Pages/LoginPage/loginUtilities.tsx:13-16), and mint a fresh token at login while invalidating any token presented on the login request.\n\nFull exploitation evidence: `Security-Assessment-Report.pdf`"
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "ui/src/Pages/LoginPage/loginUtilities.tsx"
},
"region": {
"startLine": 13,
"endLine": 16
}
},
"logicalLocations": [
{
"name": "login",
"kind": "function"
}
],
"message": {
"text": "sink"
}
}
],
"relatedLocations": [
{
"id": 1,
"physicalLocation": {
"artifactLocation": {
"uri": "api/graphql/models/user.go"
},
"region": {
"startLine": 145
}
},
"logicalLocations": [
{
"name": "GenerateAccessToken",
"kind": "function"
}
],
"message": {
"text": "guard"
}
}
],
"webRequest": {
"method": "POST",
"target": "http://host.docker.internal:4800/api/graphql"
},
"taxa": [
{
"id": "A07:2025",
"toolComponent": {
"name": "OWASP Top Ten 2025"
}
}
],
"properties": {
"findingId": "AUTH-07",
"parameter": "auth-token cookie",
"status": "exploited",
"authState": "Unauthenticated attacker holding a token for an account they control; victim logs in normally",
"prerequisites": "The attacker must be able to set a cookie for the target origin in the victim's browser before the victim logs in (XSS, a sibling subdomain, a malicious extension, or a Set-Cookie injected on the plaintext HTTP channel), and must hold a session token for an account they control."
},
"ruleIndex": 1
},
{
"ruleId": "shannon/auth",
"level": "note",
"message": {
"text": "Username Enumeration via bcrypt Timing Side Channel on the authorizeUser Mutation. AuthorizeUser returns before doing any bcrypt work when the username does not exist, so an unauthenticated caller can distinguish real accounts from fake ones purely by response latency — ~1.5 ms for a miss versus ~323 ms for a hit, a 215x separation with zero overlap. The response bodies are byte-identical, so content-based enumeration is correctly blocked and only the timing channel leaks.",
"markdown": "**Username Enumeration via bcrypt Timing Side Channel on the authorizeUser Mutation**\n\nAuthorizeUser returns before doing any bcrypt work when the username does not exist, so an unauthenticated caller can distinguish real accounts from fake ones purely by response latency — ~1.5 ms for a miss versus ~323 ms for a hit, a 215x separation with zero overlap. The response bodies are byte-identical, so content-based enumeration is correctly blocked and only the timing channel leaks.\n\n**Impact**\n\nRecovered the complete username roster of the instance — admin, alice, bob — from an unauthenticated position, verified to match the admin-only user query exactly. This is the targeting step that makes the unthrottled brute force practical, discarding invalid candidates at 1.5 ms each instead of spending 330 ms of server bcrypt on them.\n\n**Remediation**\n\nIn api/graphql/models/user.go, always pay the bcrypt cost: when the username lookup misses, run bcrypt.CompareHashAndPassword against a fixed dummy hash of the same cost before returning ErrorInvalidUserCredentials, so hit and miss paths take equivalent time. Optionally add a small constant-time jitter and log repeated failed-username probes from the same source.\n\nFull exploitation evidence: `Security-Assessment-Report.pdf`"
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "api/graphql/models/user.go"
},
"region": {
"startLine": 79,
"endLine": 95
}
},
"logicalLocations": [
{
"name": "AuthorizeUser",
"kind": "function"
}
],
"message": {
"text": "sink"
}
}
],
"relatedLocations": [
{
"id": 1,
"physicalLocation": {
"artifactLocation": {
"uri": "api/graphql/resolvers/user.go"
},
"region": {
"startLine": 79,
"endLine": 82
}
},
"logicalLocations": [
{
"name": "AuthorizeUser",
"kind": "function"
}
],
"message": {
"text": "source"
}
}
],
"webRequest": {
"method": "POST",
"target": "http://host.docker.internal:4800/api/graphql"
},
"taxa": [
{
"id": "A07:2025",
"toolComponent": {
"name": "OWASP Top Ten 2025"
}
}
],
"properties": {
"findingId": "AUTH-08",
"parameter": "username",
"status": "exploited",
"authState": "Unauthenticated",