From 0b8b253efc437b02a005615be03e64b20fd846af Mon Sep 17 00:00:00 2001 From: xbingW <150229899+xbingW@users.noreply.github.com> Date: Thu, 10 Jul 2025 19:31:10 +0800 Subject: [PATCH 01/31] Create version.json --- version.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 version.json diff --git a/version.json b/version.json new file mode 100644 index 00000000..f5a85ec5 --- /dev/null +++ b/version.json @@ -0,0 +1,5 @@ +{ + "latest_version": "v9.0.0", + "rec_version": "v9.0.0", + "lts_version": "v8.8.0-lts" +} From bf8d64c6b25f8a56a9a299f9f0a2f2685e3c0e74 Mon Sep 17 00:00:00 2001 From: xbingW <150229899+xbingW@users.noreply.github.com> Date: Thu, 24 Jul 2025 20:25:36 +0800 Subject: [PATCH 02/31] Update version.json --- version.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.json b/version.json index f5a85ec5..73f184b7 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "latest_version": "v9.0.0", - "rec_version": "v9.0.0", + "latest_version": "v9.1.0", + "rec_version": "v9.1.0", "lts_version": "v8.8.0-lts" } From 63588b8f5efeb68a2e88ca25265db166416547d8 Mon Sep 17 00:00:00 2001 From: xbingW <150229899+xbingW@users.noreply.github.com> Date: Thu, 7 Aug 2025 20:13:00 +0800 Subject: [PATCH 03/31] Update version.json --- version.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.json b/version.json index 73f184b7..a4bd3292 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "latest_version": "v9.1.0", - "rec_version": "v9.1.0", + "latest_version": "v9.2.0", + "rec_version": "v9.2.0", "lts_version": "v8.8.0-lts" } From 8247d11ddaec403e0d2baebfd3403659c3841755 Mon Sep 17 00:00:00 2001 From: xbingW <150229899+xbingW@users.noreply.github.com> Date: Fri, 8 Aug 2025 10:55:18 +0800 Subject: [PATCH 04/31] Update version.json --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index a4bd3292..7bafd18d 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "latest_version": "v9.2.0", + "latest_version": "v9.2.1", "rec_version": "v9.2.0", "lts_version": "v8.8.0-lts" } From ad2aeb4cf9f83d88b83198d711843edcc37381c0 Mon Sep 17 00:00:00 2001 From: xbingW <150229899+xbingW@users.noreply.github.com> Date: Thu, 14 Aug 2025 14:45:49 +0800 Subject: [PATCH 05/31] Update version.json --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 7bafd18d..3e66015d 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { "latest_version": "v9.2.1", "rec_version": "v9.2.0", - "lts_version": "v8.8.0-lts" + "lts_version": "v9.1.0-lts" } From 6d871e638bf8750c3422413afae0fd7b2d311a55 Mon Sep 17 00:00:00 2001 From: "jialong.wang" Date: Wed, 5 Nov 2025 15:26:38 +0800 Subject: [PATCH 06/31] feat: update version --- version.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.json b/version.json index 3e66015d..387ca1f9 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "latest_version": "v9.2.1", - "rec_version": "v9.2.0", + "latest_version": "v9.2.7", + "rec_version": "v9.2.7", "lts_version": "v9.1.0-lts" } From 721623cedfa20c1fc7953e5318e1a131700fa3b8 Mon Sep 17 00:00:00 2001 From: Ankush Kunwar Date: Tue, 2 Dec 2025 12:46:24 +0545 Subject: [PATCH 07/31] Fix spelling error in README.md Corrected the spelling of 'human' in the Anti-Bot Challenge section. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e29364fa..457f07f9 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ List of the main features as follows: - **`Rate Limiting`** - Defend your web apps against `DoS attacks`, `bruteforce attempts`, `traffic surges`, and other types of abuse by throttling traffic that exceeds defined limits. - **`Anti-Bot Challenge`** - - Anti-Bot challenges to protect your website from `bot attacks`, humen users will be allowed, crawlers and bots will be blocked. + - Anti-Bot challenges to protect your website from `bot attacks`, human users will be allowed, crawlers and bots will be blocked. - **`Authentication Challenge`** - When authentication challenge turned on, visitors need to enter the password, otherwise they will be blocked. - **`Dynamic Protection`** From 3715cbdc00cf72cc66776a1a9e91794eacd0c8d5 Mon Sep 17 00:00:00 2001 From: Stayer Date: Wed, 29 Jul 2026 17:53:25 +0800 Subject: [PATCH 08/31] docs: update WeChat QR code --- images/wechat.png | Bin 69227 -> 46453 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/images/wechat.png b/images/wechat.png index 436ed63b913f83ef2e36323eaa344227a0a212f4..c72cb85c6b656c924da7111fec7ae6cb6d6510f0 100644 GIT binary patch literal 46453 zcmZsDbzGBe-#3VWD2+-pkrG84JaA zhhqKF;d<9Xi44O(7`rhq+oRYjWjzr?;fn2v`rlO%qggj39{9Zoct);#?Zy2%qZis* zX4=3w3=TJYpz>#Rh?X?3b;xjPBJ!^H$Y3d!=@yOnge zm+M2;Sl7f3W*ix5;lL@Fl&eYSbfTM>yD-o%IYQN`pZ+cNMh;6jFbc)zNk%N9vFP7# z^jl{s3(AwVyZiM&7j}Mq;emdpY+2EpZ$$q+3b-!+^XvKvtBm>Q&OW@!5I62x-7r}Z z`q@IJ2b0($tJ)5FGd+J%Vc%JYI@?Uz?uk5Wv*bgH#H7uq_c0+27rTb(KfS#iWb&0- zbmz6TwI0ikQjMi*_#rzP4NKX>JW@-)Fi9m+ZkPD3g@%kd?tZ00t@2&G%7qXLAbFX)VQs_OO?w{lZob=8C~6h zF)bCg@BK}w8J5w*)jsXvdiXu&#_i~E!aqZM<;|V%iPE%fj2Z+S!Hhn{Ra8W&Nb>Z-- z;Sh&Iq6cB8O|~I#iZT#jsio($CEH7{ss&w|}fT3J!$$ z(&Ej)Ny&$w9A9@GZiz2ObficdPK}F8H}{Trm2ME5yt~G#lfGdy>#EEq55V?#6!K~B zk3Vfc>LL#lOHicV20`QxV~JA@BYE5xW@4}1>0((5A^oA~a@AA9BqE@n;}a<-DOuOB z5?+qqI>hnoD+?iS2LD}hx|$ov1$I>~JfY1O<`i)J5$XYhUMjD9x8LC<#N0#5IL7{f zQs^VMW0btP-XzA**Y0?je!;Aw(Y@;jv8X}BqU&&b#*caVif@8P;*P_>RRO0nmc*mp zk+}5yBk-c^6%Q$})ItOVl$OKN)yqOcHcapLp6bdDu{FrUZSa@bWaS&fRa0o!syu!I zY-Xg=_Jcjv<7IH-c9sy2bJzE(E^E#`MAMH+^GQy`ULSa4RIJnm*UQ(nn%MPyF42+X z&W;_;|M!^mlA=2ZL+q~~vL$y>XFdNJmU>(N*ChiclgWtQAH$NNxrd^q2(gVwRMc`? z_d7|YIcS6KQc_Jkk_mxdce1EX#MUHX5f*J6*kD)N;jSUbs1Mj8z}8|4StS^y>^>o=Pg0J)Crg!B~~_U-vW0GIc%&?ThoY@(3r_zQVDryIX)oJmuf69XjgGZN09K^61qkmR^CH}eI zhwgg0>~-~;s^^e>hmoD-a+94W6qiRTg7Q8Fuv~r^$y}USHh0BP@7VSvxImuI{)w%i zlwi%1iX=x{zHLs^uZ!ZZTZXg6MRj0-!P(A^Iq&G?L=sf}jn~7p)9smh5YWtshR6}= zp+ko^!Y-EJR$(dH8J>F;<43!*Aol&kU-bvSWH{a^NCj2d4xZMjH+B1u!ozldCHCttoZRWH|g zVZ(i zdegD_vb$HXE~eR|uF;<@yZS`*bB873CUBdytpkbMx-jR3iYImBjqhm>FL&v}fWepo zXsqJPPw!u;iQym^-{!%ioE;2-m%SNve=3gdV1}AIQ954|7!z)~zSEJ4upw;BQ4$&- zKJ1e0=+2IB+dTn`9tAxTt_(lXNRRJmdtbw^E78qGOo8eaRe6F0@TiLJfdqw39%jsK zKe%vya&8GyIEAnE@rZc8s!X`15vZ_hwS`e{i(J?@ z(xu>0(F?C~b~-XxYVkZ_ftA7_akTByM3kJ;wM4w^+MOLY-7<9r#|+5Crh%T{NV zYr_&7Yt<`cBv0zyk!y5*ws||t0lhI!7Uspv8mi3ZL&{H$Q?p*Ac3)ksL`ea&rDDeP zd3gbIqcGQjuxSIBx%c3F?!{bMbn>-m({DgVv(1pbWZF*LFz~n6vBI5&l!E- zuZcOsk*H)~g<_CC#r?r_$m)mwiL^xY zhqEl{VK&DZ+L}w)cBX;*3SU}8-`BpC=QZq{2>08hZkEi#IQL;ciK@{60;WR@G)%j? z96(8g;u1WTdCER~Ip!(OQ&~gs>>(J-uNibrI=?wzgRd6*FVk@S&?bGjF1Jr~;sRBN zhsGeXBS&E0Mql_e`#in6^GU{xZo*3Qkmd-i(Iu*sdU+zRpmSp~?tCH2NVl%Frw?x4{1Qr`4M>m#I#bK6g}l!qb*D0w2UXnWf@h7E zOLL_sxYO}(REzbg3Uq&7{~2rdo;nOMu!*Z?(OglX#$hgcfwyeO@QOn7A5kZeJqbU9 zr@%@@?7^KwvO}+{mj`n}eNJ=|gc^ky(-d~uT-Od6iI`*Wqqkh;(gv@9n|LwoLH&V( zZZm^~LJj)jBnEqSbFO!_?;tA@cW|~R%RU0NT0;Gxuf7g*4h)5@eIK8;J zBeN({HM`pO^GD>PA{nUJ7>F!DLg}=1rSwH-MU-Dndgn93Vpp>GaQ#t?wZb*04+bA- z-sX<}9DeOdJqS6IsX9vh#cFLNJXYGPXivi+gNWWNR_Ujc9M0{Q3o>c^`d5}3Q#-dd zJ~Il?+;P?V*7B#AJ0|d!K3?jdp8+cO9k?vY4?1bETE|^*+X}>8We;dy57tB?=vj`T z(d3wMf4H9{86Q-dJLhfP!^!piN%kz)w?~9^|4pfD8A*dmVE~+#9`0tU_`n_U=Wx34 z^Z$a=QA16IyM~s;->YO$_yd@L(7#^5lew=~i4ZJD!c=9wKhN-*n zCGqIz6i>=pKIX+#Vwsu3E#|lD1lh}9kL~58a{Jk`P~$XP%EgvF00+ei)kS*@q`*%K z6eIb}v}kJfYo~doF;ZKY7e>-LV`bRVjq%lu1l!K*hzt!_V3>6i7z#J1ej#S;>sk1v zO$s1oH<-xwBx_HxOS&`+mwKGFKhp(>*dS)ebhtkY)R~axasKX;4`bhkIZh=BO$)VW zP){2wQjmj<9S3Py>HV{%D72{utwPp*m)!eK)B}0n2yP#0krX(%Nu9qel~^+v2By3; zNZY|sXh}UEEI~;BYE(yURE62kLbpus-Oi*(C{uQ%H;9P&2H^--kG^=1}}X2s&5 zJowEW+a?kQ!&}b-$3;B+V*2yhPK-X4YA7!9CcZBEHM0T1YM&jUn>-2|{hV0Fj3{%8 z<9){8PTt;p*ON!KykHmTR5bQTsAS%TiMc)`_GZ~N4zUMVrGjnAP)g8GUW68{FI~ND zOVqWVu@)L+GwxKT!nS;GE@(%}q5B&Mm{=>pi-upPNtJH0f<=@s#)iYDS9td8Ikw5o zLx`*MN}S+)CZ5*eUbK!-)hWrx*>uwZmB<$yWn^68e4)lM4wr0@^OFe(q25LM#Q~3= z3yeaS8;$8Nn;TDsJ(}4pZCagcRqN#TOjqqUAlE_6w&l-g166^ek|fC?&M!YjGkKBw z$y25L1^T+)?4_F~dj-e$ydt_YSb!_LjlcDa{5Sr6W=p{n542SP&tj4VS^^nrE@S?b z2a-F_W3{8tEAUv=dJEw#DTmUn)ngQ8;d5_oz!1E#>C(G99)< zWoeOBOu64QJcj8dvx=z&m6OJ)imTZLbhy-T?(cq%9rLLQo`{=t`%eLnPYDvMwhnxilK zAZHX%oJ}TH;kRX5FwlqavUC`|Q5j-gHS&P5If}if_2I*{&L8WSjNmJng0DbPGg>hB zdUH;ySQnpN{7`ci`evQb$#<3O_0_tFW--zIokXvb=E-`G*GAGxJ9#b@TCQ8UVZx?( z-fC-agSOJy2acUOZ>+r*#QRzyt_MTgz-P{VHy(9?OZy?X$^r8|yzq=RO|H%zWNST6dk%3uOU?@1i-j zH#{u291kCH-0giq$opP6O0UOsHy6;E!l0Jlyf0~6`W+RcPW1iVQS-n2?x8shycs~3|qcuzIn+yOm#!MGE>Ux7~0njkly_xf3t>#a7 z*m!tV6m~IbH>prw{Q6Ipr6^#s66Z$6Sjh@O8^#jnAe2PCJ1P|a_-B#EE11ORIdu=O z>bsoCd5aGY7B+l8aJ1d`2g;KJj6mi*&L6wq9Z+$HqEZ(0bl350t0`?>a~!=kpBQxoO>{xZgK|S;&AX+E4OD8Xn3levU?U zO9-E@%f8P8V3v@zvfP>J@EyaRF*#Nh|fAW0$VBsi3^7x#*iCiV(VC`di%o_#BfF2mlS1P6i^Dfv+nsbiUzgb(Si=;EVk+6P8OJfppNES5?QNF+sqy8sNDZEei@w$s&q;74}+d}Ef-%x zc>bYrx~{ra7xd3NPRZS*0mmUkhBhpateDvcE29!VkvAgQ{|>wcHW;i?d=nWgt`eS^ z7ito<9MqJ+voW}m?>Skp=lFKSEgUz}>WrLY6Pp@e1?_M~ogebUsFLlbK1PbXkr|A- zN4}+ zKlMm7``9J5C(T0oojA&RsFNjOT4;3oortN$R^jgE(EiZ~=^j0$^T})V&gX^P589>3 zsTZz{*YFl`%T4Qh1cfjS$1}ka(6E#kRm@x8U#mHi(uCF6&a2_5h#cnQqrFx;8$@WS zdQ)XM^6jX3NrC6+G|R7V1!=WjCN$X-y{SQ$-bv=`{V*$S4fe5tHtO@~%JZ!r<>fw( ziQIZ`D+<47lksJEkFg?ekL^I!N>By2s1E2g_yB&QOu#Z?$fE6cm&p$=v zn6t6`BS6+f5fp0#OH8nW|L@=*>7Q*wRz7OCNwpzaiM9p}`>Tah|4V{OA$`Jh_rDm|i)NmBqbPB+ta6e9p&8`++iNJ^@ z(Wub1wufjV;RIf%aIoG|*-O5%(9n&!#SbGWI~4Un=}9$Rz80~{C;Y|P^EK}^ANbMc zA@B+5gBYK(40+~#Yx_b>VWVh$!?0uH!?(uks-L3lgKJurMk#A<=%&RG8jBwbkL!9b zRdV14ly;I(Q0=pN*$F;Mh|vv=F=&z2VZk1+-4j||lN!@n)Z+@7jNVt2vzA|fiBH=% z^7T^T>n+N>*^^Ym0WVMpE}~x;YpMpBnp%YtqJ2C}l~^tqj-{7#(yHkk|R zqfC`mlb_U@nq7#ru#`T|X8c?UuJ5eUA$yaKMdD9?n6t$9u!M*iyE($b9MCgOW^-m4 z*r4`PUX8ss=pDf$Z{h=ieMLk*LvhImUcQ{O7{k0fmNed(^F z)_kh0LcDMAaoQC<1FeoP@}&;Zn}hd>!PDctBZQsh#hAnKUhk&_x(Jt$BfR>!HxLEE)NAh?F92A9@m$I#YQP5>Pl{_%VzCi(;z$QvyV7acgNK) zP}b%H6tseURd*#8Tm&09Pl0S5E4FL{YDA_O)6^pn{hc^w0pw!;3R7LU!E_8xEC?cj zw7?G40vV8&t)PQyiP3scxFBJ$^8Ol7?w-IaE}na(Q8~T&fQqe<`)iygc+SHaj6Ytv z%j2w7j}tK{*gW#egRR6f$V~C40GUM~`lK?x8L$@4tz+0~GwQ@e%?3bIVUPKqc9u~~ z>n#zF!7`Xgb+#QVIzjWJifQL110V;b6NHyZZn1vF6pZr?-E@11xordAYa}z>kWj)| z+_{ivn}Q}T7J-6!f5%0oxT8!hRs<>$Hf*0SHh|ObtDWXFlC5VwqP4?dLI=4bA=NCm zF^cB&cik=t!REuSZ^YT61NWZ~3-3;z$y$c2kl@1J22x}X8L_1?a9LWnJ5spg^eEXa zNd|@G!YcyRjM)WJ`*iQ*rI4;$+-;)`Cs(D+9QqjJR6@7{PL)t%9DfKbF`OBRh};H5!d|T5&hIvyMMET_1yCLNlX_P55pWQMKf)_}L-KZ*Zpr zpHsPPVKmiAQzdQ$m*R(ZK(X7eyLYSK>=tiX3X2Z;*9%}T8OV}2^NSc^&l=}6TX%|a|jZRXV|Iv@b(7c8lB*{_F?XtWH+h8AjwZTzLyqE z#P_RxlfwkoM>AK=L)yhmp>bcPiv#ZRr48Oiq#o4R!hX!iNP>=0gXIJC+#mVbSh~49 zyK_sp&=UQof2#z1HzfCQtb0A(>@PMacnd#=q}LKm?-fFw35|A0CXEp3Z)S4}x4j6K zuw?osVq~l$9IkaCIZwOD(tdOt`~$e3kxq#Mfw z#mOIYSaqqP$c6mUXLf0J8pVZ+LZXjTA+W0i%Zd9fFOo0TzkkWq?;_m%Ww(5c6-h0|127XK9G`>`w(-PehU^3J${ zi$^s|fMkd#^Fqz<_M09OQ-j*4Dm8LNwK^_I9%pjZY|hR{B}N%E$y7?Ngb$UD!06Hw0lAZiOqT%? zBR1MKOU9kQ?^OLh^CagWO0!WTk*pRVKpUzLY_zITYnhRvl+MV<*YGJUsQLU=O>Dg- z6285=Fi??$^pU0MhX!Zv-*?xfw)IZNuMMcL#G-r0`Q~G4-%x`siG6mzR@ERZV27r# zaD%v;=r`0(7R0`iu*&&(ha$+{%;d{-3EQJt=gI~;qn92GZbpBQ#;ZJ(Rzpg*Awd*5 zq?2wIDA2Bpw`wuSB~b#A(Q|mj`sBwII9PHf!%{dMmb_t}(J`_XWpy1&#@3d#zfrs7 zeKuGF5*VU%g*!@hU&F`VW21ma-OGGb^sK?&K%lK&2fjBHWMN?p|Io24zE9-QS9_?n zZ*q4_c(C=wH?y;{Rs1&6)-c^q%3Mn!*~5am0s`o!r|C7vo@4WbxxgLhDxK4}R{+W+ zXW*@=i_ zBIA4l(;qjanv4}vIf13mq%rE*<_^Z24qR?&PaPXaOVwxh4d(;_{|rv?MaG$nwC1RW z8(@UdRoXQD<08j>gt-Sa5t*HoW=3lq1ofnbxY6e}d#TMY_J7i>>kJU5!&u-a!Fyz` zgMC*iy%|2|`zHACwW!VTx8?-uqKt2$s{qhQ(23<&Ufk{%yzVnjqZPV8CoVcXdl}7nRC~c!&*zHpn&s?KBZYN1%Gj6IpSWOW0;K5;=k!3M zO~qR>9lG0i0|(w$?%Wt_*Eh8QXbZWzO;;%Qbt7k8u92vSdb&L{1{lpL6v&Y7QtnA~ z0D-zyAwcIwDI|Om#zoX|2LkcXniRDYOT$P9KexiS|{WgRjxJYb&Da{|PY#C>jElV#u0&8E3DP@xsjqH=uvw@Lr-d((*q1 z4|g+zOQhHDg7uH?S^M9OS8>*r1D@JBM5CZVD$Xc%rn;8!2>9&6E-8PF06^*%wrpPu zgTEeH_}$+1=abT|?)JHZNmvR@dH>WVg`e!+rloegwpKdf$d|Dw7W<8W_@ZT@jWBCe zI09qA6lV1$)vnN2&&I!sYsH24Z*BG>9 zamR5^vm$7}Jc?rg!ch3y4xrj!(%nC&#!N-7lj(sVY@Fdvw^@?|%+GpL{GjXRAc&by zxb|rXE6y@xv(Z9qRz^0U#DS04!ze&;onthS<)P%j><2@qxDy;MfzGkb@qX#!E-oN; z_grhc=!I%o%N$tCZ1bVLF9dgNv1fuclOvO>Gev*^e)N}ad9a~P6N4AvAz%$&wqgt? z+_m-qwEbiR@?sNSsyxOG@a9HcR~@iU%)ol@t>)U3`bb2txUt3eu_a1Wmc+R^KS zl;vWUb|xPQvsQYsnogOQw))jah#WgTX6LZ=cABwg&$Xn1pP;5c>5Ex`n~w5Fw3eM( z@;*$xR$qxnKMt>j0^Uy3Fog>H)a|EL-%S1rf59!%vABUV>XhAdNp%bdP7|@~Vb7Zo z&ZfuhiVK$MmL6=D9wgLJ>`C1NN&|AP=Mx=H0~YHJKN+1LO<*EOgl-%?qBfG=)d#+V zP7aCxz4G%_#ZOYBmcH5KdU>)+wyeG7kQ~>H>Msy?)|;3_^y2g#ZQrK}JTF0DQ(W%e z53Mbh!iHb5N)7UGkj(>oVQ~E8x?? zhe+`Ll4cIlsV#CcS~FQ4=+920yO|Lrv$x6MSK8HfFOt(&uSd10VcEc@Q~Bj-p1?e7 zZOQhh>&%rwgoGfpHtgc&t;+}|)?eSKhR30{R;6LejvYGKhDhWJ`39LP=Xi~+YOx5x zyHO>XZS>&gexI1@vF+g<^r?*7H`^2BVX&M!8gpB?nOJBOj2p8IA<>D|C#gK>vj$$# zV_+?6+tq1b*)e+Im5JT}-+wB_MlU!YY53>q20B&v7F6w@vRV`!*Rmw-C_NXkZn z0wwzH3bw_b7J6ZkWDR#ZUd%HB4uOl6S8Gh%Ln z_Y!m5Z&pqsx&6`(YwhXpjkc7nk%^Q^BN?_aWL1!bjewcEN@wGEvR@5WVKgk6$$bXB z7J1!Ak+ZoxC=cPk$TDg8;uq9KHvMa_dE^oxXw@dFac}$gZ7OIqQdKqEsy4_HP3tqK zh{ApYakA1^)j9EXPmmo1BXY~wpR6Cfez&EDUrUa8DT+D%-j{8>ac84XyN;3iP*Ou*dq2M< z>U!Ztd8_E}?`uTZpg0RJ2%-J+W1%RsD@b5vuYJ;0*c`ex`1$dK4x$2U6$~|(2i9zl z9Ne_^Z3icIc40gEo^IN6g8vw3-(mRWU1-i*VHQ_l&2Fl!en3ju*ip}$7_avZjj#4P zh2^l489Bw<>GzwB<+bEtBdLwD;b$f4-kAkQ2%?d$W=R69j79Q zZ0PkeaSFVT!uOJf)dMYN*uNh9vqcUsz!q}0S{Sgo6yawH7R_FC(iK1qo!hk_>erSb zeSVg+%%to^U@QNVM1ZNbtz`l|LO_S3F71-jf+9!Dl{aV|dS_gEZAQ;CjJ~ftxy5aBSVwC)`kHS zEfOV>MRtID0HD1JfcB(Y=e$5QSuRYfN3eb`x94g+YGj(RD|SlK$f?w~+yC?^WrIS; zQcvO7p^d&U^xSMbQ2QyMa-xYB$*#JZjGp{dRrwR-CS-n+Dy>vQx+vtchJMS9F@&)n z0|S8EoP75j2dD6nm0J^TRR~-!&@dn_^r`Ti2t6sb2bFMCE>t6+JwQGXIeQVu(grL6 zj5zycr}bd>JQ+-!+j{R};tGgq7%Wc~f^vaRV(GCJ6Bme^%lMD6S%lC#-W z0U4+zxVg~US}hH@JtbgUb@zpLe|ph8EuI0|IIh`*CO$7Sw+`Wb#dLE;js!mY?I(YI zK7!plAJSzfM}4q=f4!!3=gz;~my1$T#SOG=n)4O&#AMS#CuS7B(I9V5kCMssTQt-u zdsc`+^YR`bOR-87p;H*?_fK)V(PSQ`H*4O6k34-P;L@q1TYO~rOtsa3Ey{M&f+Nfj zBL%7Qu6y4S-#@lutNXx?CLpKxv0CyumJExw=`;cUfy2ja?hB9Lrj3$%Kwm(gi8|9- zn-b7)U^ew4*c~MiA?Tw!{*2D+(EkpPGa{i(%{D>?Q2d#9URCI3VBZ4DvD^HKXR+&2 zr3^YZ^q@U8TBF>)K!qz5@D-4cuHmZ=3r&x_+Id_$FQo!am1t0GUyl(m-+*)z0<@I? zJ<)(P0(znWRHXt4569M6%O*MyNFf&hmjN?8?BZl*8PfFSg7Dy2EtIU9P*Zl8ID;D;RFjmE?+oCE-?;9?(+ z4ztNlR>Shq`~sad9y$TO;r97EwPmz}wCc~8Aa|5DEbYDk zP5}*QG4Xf7@=omjeZiw4CH{ND%Hs2XG`}Fm1Tab=l91d*7!k z)VkJaqFqMDumysk%|fS-W3M`2Wk&K_Oa~KzZpn!LmeNS|%Tj~y68~T2pFNh9bCuTA zMS>94it;bH2z{G2{T%3|6eKMTvMnNxr_6G!wv1bbOc#Yd{CX}~#ogqq`<1GH)_3Jf zWl6!mXphigv-VYh@Pz2lTytHa;tF;%fEK}i9|--c|CUss&rXYK)&w*c*HTL51P>~$ zd3uq}sE^K1XWO)hkw%Z{+*#b<36A$lucxZwhaSCchngNa6(Md)$2vftC0DhBx;bFR)1TPydztE`*S508DEBNb2`l*PNd=pi~N& zth0Y4l)jq$)1~nrPr~UO18Pz#hAgRff4*DztN)F^oZH2}1H^Iv75X2e{6=Y_&ph-D zSZuwU^<7Md&@?-Nt10Lv9ITYdik;0g_t0tBNI;u&40&-ryi(+4=n?RMTf{rw3oee} z!#@?)Ls|q=_eTLen!YwNh!xPlyO@R+AI#1kIu&Uu15LeA9d^Gevr}LL#FOv5J2L1N zZEwG!bVTZH#ydkkR<1xzwOU-p4(!^Tm_NqD5r^(JfcpLIF=ZE`k0dDy9qZX{xDF!f zTv$@R9!B1wk4dC+{wji4x{~~?MGEMT{u(**LCq$J4XFNuqOCT{%sbI_IF|C#!X2X) zQ%J^usi}IbKGf%UGtxvvQhkYi2L=G}p5}mMC9HJlgFGBXY)>6LZY#ka_;Xbr5;{4& zP>li!7Lbf6;K}lOV=|(E?Fg)7)0#|ovw*-=6I2t6YRECNV9$}Jcw%Ba6`*lhtr>h5 z0LzW%tLl|^{WZP*@YKeu4xy;Ur3iV6xP3PE=S#ULff5rDm?j$rI!XI{d;_0bGD;;fZpWu zlhNO=uCaTq9(->=sK*+tRz55b_OV4D$?g@r#s=h-AcY>jtmVh85Ct)~(M zuG^=p3+)ejn@UD%VF@GcoK`XBMJdGU)nU4{77*Yi=KRTPWv;t!vF@xP{|)H-mZ!|}5U z`$WxqV(f?vS~9`XP?y<1<+jPC?bvrnO2~awb?)!-#@*`-At%{vs6xi&!bS2 z%Puo)?qkrhkp53`;WUzM2Z7LpI78nOnY)o-(W_*TG!um&1r_*=?!bKzlNU`0S`1{c zc6u-bcr^bEcXILn8t&8+)DIhG#B(tX(|bh{0Dku~v-OeLjY5;Pb&T_?*gcLeUjxG8 zJBAWT&Ry7(wWkjZs<{|T7YxNSq?A_w5F2_49}#p`$UWlLt!vgs)x=)|y_UN15HS7s z@8F4NDHT7T6S0vp2c~j?K)>i4>o>)Vr{flg!2N-RK*u8QT(!Vifh$ z!=zg$)+&15n$YiDAV1yw700w}?k45_FV~5e5qF`xDE9MW^;BK<%)O+STiNIEXhzPo z{`q^?Q_nZkJ^?*6FVEU^nOKJEjyFRAYvo@*k{_c;OHm=qPppE>v&Oii(Q34z0*EE{ zFp=86s*TF&MwZjhWo*}R+M#&qYslEq)q{5VCj`ILw_!E>rm@-FF~&?B#a|e}?>eD3 ztLyH!hJ`$O^82sUH z!lMU!K^Zv0MrNF`!o9hF3_14BAKm$rqY(A-lFyA(yMvq48zbW^^nfqI;~B2yg*#6l z%`#SKiC(92EmzmySccHQh^1GItpv#)2OpN@wzi~+WR~Kk-RgQ@-HUQy8a?=zG}WIe z>)|s~8-mn@w}9R)9oi}W9|&9+(5_hci$+cPoid&UEhU&hno`JL^UQiHQzcS=|KgU! z{WNaAmTS;4FksXJTU^xPi&*xaN?n+&@5EqRL%P`hT#!@8-`8sp@tl6PlY`tO^`_~a z`pvCHodjXzG*cR}UCT5whj8R}4hIXft-T_N6}Y24Nri#^&Ud~!CuV7GQZp02YR4X& zuDsfu>4GEihG)0fvT*P7oVZtU7{_j9X}2`T;dJrq>mb8NHxf5uUwlV8*fa=HI8Nn% zEx5xBSDSRg+)e;85VaE#uiE9|{A#Rp@OZW^UVW7=q!dqG~=4J7XmZ2v-j zJTftYTxW1RQK7C_GIXV_3-Eivb-@nUsh!eD}L4>P++&-+NYE zE-oqJpWaE-IWG|Tf+{iyIy7j$cp@4i^~&#lY{ZbLIN)v8&~wN0*En_ws^O@I@(p@2 zBh}6Hu1ITA%&)gTjEG4rUOKnb3wHf^q_hu*Cc;{o4TR#?S}}=8O#`9&rHaI_Aa=?0 zm|a;KjmOi}VPH{nip?y>LJ&=?2$+3<^*vx)T*dsfp7n;gP8jryRQ7W!nT)x71~4ngXl zJsx>x#=mKzkq&uK@3Fhdez+55o~v1r%MaM~&eM{15{)(1u}V~l**YOuKz2E(z5ZEX zo9!xCv~PY=YKxVOIjEmyDaNlxLE5QQ;fEfOCdfR5KUjof+4%d(uHadYuFnsBu@^Fd z0uL(Fc0kh9^0!3JVKFO7=_+e+>k#hquBR>Dqfqx+3zztFb z8XX&DVeuy&AIJef<_Z=z6!0VO3gVNq-?y+tEy+Ny=ikC->KnM&3ZwY2KMsO!Ef_mS zNAsw4xF0nRt3(nY@Kv`T)rtiL1*4^-g&!=vFp7E7NEj{(3mzbm?{ronSTN@f?xK_6I(NaK{t=~E5j-b3ws+C)%lt^BxyiNRw zV~&<_aKv4p^9w+X0d|oK%P`rRpBm2}s_BcFiq%nqg|*;R-x*L5qt4zr`kI4 z60L!wXSt_2kjB@ka%DhmZB$J48o>M9e3|f@vPudvdf?ijmx{Bs0;Kh#8u4Ite)Gh! zyIfKUPSvjUNJuR-(e$1wmInFQX-9M4a4la!S}x3-deHR7g3oZ>P9@1}DS#7nWvlb+ z|4Q8rySvZ@oa#cg+0hObdEduTsS|F;%0eu0)k{^Drf_9t8At_u1cMy-K%n3A9ytJH z0s}P;u2PGsFzNhK=J79{w_QrS$5$1|C%79JFyaKdcp+|qVW0qp9 zO@(XC@b6WXdO1`3G)uTWcMtjGcgJJ6++82r&mKW)S1TBWY9 z^LwWx=|OmyFc;fr1oId{M|j}iw5{g;gL$xlh4>&_??ZO)P37>+O}$AX_3m>2o?Rp0 z+z8->Xc!a^0Y8uGf`M>ZNH&#YI~N{K4n`68W+=TgzibLY^I2Ms(gNzO2J~{E{K4*WF&n{!#{uNc?xVS)I&LxOyzAuRO?09b^XN@O~AAHt#z4yY! zzm$rJy$ku%=jq?>B*51&WoAdGtD0^p=PrCa!Q}xBHc>-2D4%6c{ObkC(3ArX8Kd{S z-TMOx8$qHFjYi@nkwi%YKwJq%FoBOIb_*Tj53EWPgO*)~tYQaW-e2izEA8tNaMixxsAh}bK2JN&UZa~+*BdpA#}`FT%;(j>Jel;T)6 zQDjAe=Z$@cV=N$-sM>Cc1d3rq4*~vVmRztJ&CM^jGGrv#PAr70v)>;x0gwmU7Z>e(bIZsF=U-MG(Za1TEuZ8o)t z%8HV6e5$bD!9$FmlF0%cREF)IA$u84iaJk11tOiktO`98G+&x3?Dti-($apzXmXVz@II)31(Ib8^CYU-w++m}dA5(!iFTDL?l2M`*0+`&YQ z2#%tvnL1qNA?DM8`lFDWVs)!KZGmoao9nR$QE%2(B{Kt$B;v)vDRbr|y;`ZV4ec6Y zj8-u`P@ut6SwxD(>B^&SR;I+&w!#h4@pfPrlE(l1h6Sh#kd#UO?Fw9eKu2xRQp`R; zkY1m=n`Kmgmgv(W`QYr&cagq^g9AR#sn2b@)knA1X0r)XLd`0cTIf;;Z|(A$|GG20 zZ{nNK(z>33rt=u-gv*@&diBgZJ7;+RGFc7`E2q^8aSTm!ieRn}Jvxcv98fr2;v=%t z4n%n$Faq_KZ^k}H>#*0BZvRnije`jxNBmtWXnELL^-tRUhN`7~Hw6(q>QaFaP4vY( zwk>`^7lECo{w0_1>>(c$7-Xlusc~%$c)wQQC|s+QAJ}guSvQV-dT);3r^b7;Cwx`* z5-6&!7kPD0WTMSffe^~$c?L_Mn!4iLQD@Cl)7LN!PlaK-#@_}lE3Z$ad)X^wlzv~l zdJ>II%~Bx55A04f%&k2+4V!WZs^9rzzSQfzWl2<@^hBL**loy8x}r{x$J7UWPd}M2 zH5{h`e-RCUgM2zzi8;NV3|2x-jQdFUsZc)%cI=C{2U zb3HgzNzC;BFm>+nO!xo)uM~ATRgyDFadk2*_ItZNpU?O9`_FB=ZT8;#^?L1jcs%cq1=7yjIgT3x zX}#1xzOgT{i<)V2_!25Dt}MNAqEyyA3M)*Gv|+-a^R(C?jca2n zxFvSnZi$Xv)pQwGo5!hDrQvpxnHf~tw>NYIR}(!^?9g3Fn^n#QnOV_ytoQVCa8*7` zD2naNlY^Vui7JhA^P%&~TFkD$1y4XwtKBCrlSJ2wn8hAJ=kbWLK+@~!AxB%7%nw0q z%dUXvoUUCb)CN364c-l0BSEjMdUxwcYvfvs|H+Fg?ZH06^0v91$0af~^?~7@A8OvT z5r^&)Debjk&xeNdFHbgUUbJU)cn@v;RrV11eMBw8XmF9-h*QeUp;(l^#qr7BJQq?( zY%Ob4VY=1LibLezCByahFR0}WkD3{KtoK;)3h>agbiu2lX&Vuth5a^>$Xr}#I^6kS{;kz%Zr&!bfN-a!|6zF5qTE-N zt>;4;ZdzI*e6D^j@%Hg#1iUNR9ANtgsP8JjjJ^~o^|EnGEo>)iOUPcuUsxuj4hkO`=N5t@2Q_OrJTdNN<$tR9~gRj}4W~0|}2;rt-_Xfh6oF>;Sv{`uYwPOQ6)80A8#^=m1S`;e>`semHt{ zuf-(9D6*x-?6J6*`kEh`JHpINbIQ(#M@}5!O_XlsRYl57=c-@>!=>AOx%VC#->Zt4 z5SvfoKTeTxOBr@8HKvulp|hWg@qQU+7sAmKnu0{CMi168q+(B_nVW-~XBF#%7iy{~ zby^KqWebhL@kgYd+}T&M-px^Rbom?jOh3pEB9Xotdt^!4^96XVyGwZmBF z%CkWHxX&@u4%lD3B9N{V)SFi$tJd3!yU~-b;P6A_;vAnOS5dtV>V14$CmY&ak`E|b2D>2&#R_R-R+nRdI(8(uOqH+bEbgtEBKh(7ewQgy4|A1Qb`V>D5IF<3{vX+?jxabQ?L#)yA-n$p8HNj zb;@~%kLf>xT9=MmQB#^(&zMDDhvJ#6yjSBKTX;gGsVX9+=4cK)urH?X*u=@|7O7hFOTYcY*TV)EnRNLuz}+!8ae4-`(8D zHURQcSE=K{JHDLSRMJ4i7af9sODNXPA~KjYvaW>^I5W*CuZpX|tNXf#S*PB@k&Q=q zkMI_6E319PV$$cXp24fGVWglqW)jQv4ZMcCZ){2p*d04YBT&7c-gFk}W_LmPU3&yG zQz_GIN?`3}*L(AxU*n1-ud|!Ko^N?TIX$0YX#P8TOO``CF!meJ$pfemGjVR_+LmAr z2pWuyUMg?ploc7k6)1nq8_zL#va^~D-FYK5PIx!GrQRBCPH zh<48V_@BcU^u;7j**#oBV{jpj7Ea;>GEO38ud1LbS7AS_d(VfU& zyL-VVBXQBVyh)1nePoh$yPnQ$Xmz-2^b?=3Q!#c$ocm(11C|(QQGEkVlu&b9e}c1l zT!lLoW4VSr>(n=-B{#&|X9nCQAX`&`BiAH2`qXb6|1k6vE# zY9MW0U1Yb0d})gO+fT7F+Y8m6Bgq?bQt!!HxY2baO84v^QSeu@Pm`ZDx}0h^p4axE zo5JUhxEr2*D3qMa;B>dX=16i0sXIq)VD5vSY3AdBmcJkDd1&$&iIBi3eSEcMbnI*^ zNgpvk6-O9q#h+o2`yqs%cht2V*8Gi2;;~Mpn`|k}4>&nnP+IY>uu}kZ8Hcn%9XONd z)9=P?>l@8;oUhEttN6U0{*|z!yfz-4eoAa@$^^2I`7jlNvc(~Tfh8OM^sQRe>d8IP zb>qf0jOXr=!w&;PSl^x{dJ|Pb9z}*r8b$#(W6sc6qpJh9-$|61nv{W6k78hG{JOLS zgoYd>RKN4GS_zT)RF!ZJccU>#1Eq)teE$&-h@@wTAa z=rbOxJys3Sk55Uy+L2yLvFoLN`$H%vB&%k{3;ig&)fSa4ghz#r3J%P42lql7_ZxeK z&O#K78$PeD@3HIK>=)ic#mllEJ%26TGrY$$ee$1XlG`9Ki&~r+pVIz`PSGuU0&}}> z)puCBH@U-{+W-lK@Q(@y5@;YYhz8AJ=cY zr{;r#`tEJH?$gp$tjEQHD^4>EjTkGK8CX~cKsLd36#U`>KqUyQ8Kp;IZS~3Z=rGa! zjil~2U*1SlH;b_pa=RXMf~tbftX2M8tTA3Ly_k}XZmp}fzRO%RrUV9S@N*yo2kLC% zC~AAg@Z(UW`&)_^cbK|1`ph(p2nRg_HN5`LjkI9rJ=zYajYzI5bC&JjmkBe8$sPZOrKE71eH5E&l z>%G>Zw|Z+**UtCH_j^VVl6E+6`7Dv^wm14lo+vePG$@n#%2>5)Wuf3*?^@^Nw)@|n ze7;W+42tDfmwH$6<2u@NqO23bf96)5zGu}$ZX!F~emhc@kjy`SrPf<;@9!){fD-fo z$(E9mcFqMUA;t%Zmad6Uz2q%a{#Oe)l&Ov-?oWt%T%+*6gXggR7lm^f=fD+qHBCJ{LQYKaJ8-z zFl?uqOroAgAh%FqEHZw9u>OD+5aU_ zQKRkoXwShga@MK*S(G&}D>4E!|HmJh9CEc%cslS^t?~OOs-@I9ZlXUVq@ z`0ZjF`Q=HoSEJ9aUG~>!_qUXR>c}I$9~a4{c1B-*2M}&Atj>9c7*N}>C7SmxUH^m` zF=e18Cp&Rk?lQTErk#G%2YoPFRFv%vM89gwdPBL6i^xZ56&}Kp8bwcRRT~eGkMl7N zo;9=uRr_-P$k5F=ea(k0rf4UIoldZgjtDK%Ou(#@yU5j5Xu`#j4CUg_?*^`}$43I= zWYT;92~+j(6gjG2dbT(zGu^1x&~e45lQ`z%(Q`wPr|Zf}BEX@CX^^Z@rv&uPS7^qm zo0<>qC*Rjs<2?Cs_x4?)K`Z5>T^@Pe>s!6!pLTQgPuD`f4mQ&D%vxaQ+@B-jZ7Z`2 zilKE~!^#3qm7}#WZY;-CS5?kpHZzZ%(M!|xq$O)ou+Ppnkc(Rquw^HGe{5kJ#gK>n z9(&XJ&v-atDkt>d_}M0}zy%i_hY#P#)hl55ET)orxGxQ0XLxQX>|kOllYD6It^RMo ziQqb7yT?e{b# zXmIc$kPs=ISTHg&akhmhTF`eI{#^k&CPwbxHE-$juKKzjW2XFrysf^^0wg{Sh0i3o z`bXBoj84TwS*|he?W(>%k{yN@pOQ%&d0DtR+tnZE!+fdY1`nwqdQs)a;lsCyA8j#u zaQb1!e5mD4u+oFAK4wK*?`jJF);K?Y) zZzT`26}Nvl+k&zCXz%$0Gm|l#f^_71mJN(swKo%&Zqsu8AbIXHuk}YN4Fhs^Ae1TO zVPL-+x~(^poqTu)rVdV68t1ggfNgeB+|&?UB&M43A}a$jp^v~#w`8?8Vo-`m-s+S%tc)Rg9ERA)c##%E$g90M3gJBIEc zG9Dr=zSXxAQ|gK8!%aW`4F56FXaAzAkk)@b(Fnp+vi0!Pf%YTh+3)0Gug#fYPsTMA zV}#K2j%5_;}Zg3(dA4fFhebDO0| zhi;9?Mql1UpiqOfvQJ^*~N+ry|BA zM&OIzrj+#gAEeT)&X^<12j`5O*LHjy_Mb>i8Qw+^N>zg1vI=(c( zQ|dvit_QB_ne^PdOA0I082Nv0cz#n5dy?rtbK?MEpFxYir5Zw5?%AmUZgPXUHA^6F z-k9xs)xOHv`hv18^8oQuNQiCvMrc$pR``E(Rr4xZrRrq^sPq93=zaY#&>o7xQ3@A?|#%&YIOOE@Gg70#V9AHYh-n&$N!|4 zkYX9%&hbUv%jOm#V+c+vgl#-+23eHmXyN!0oN`%_B=3ohNLm2riVg@!&~+y!JZ6s4 zE0G(mK=_zGbLSW9`jrP;pUrj3p02vorb;?S4yr*6uH#Gs+f}Ko?TWDngQ56*pA>4F zD%B&@U#|_IX8Z`yO_aLo_PS|zfl={C(miEXg>FoY}94pS;o^Ao=3%7Mp0{udj1p$TAe<-v222x>G9(He!^$=EbVa%V;!1TME=Ft05!NrWZ6Ia z&;7B8^Lh`wHU8t~OiS#^?rp#rTx--D>LDNcxv8m}VH{avSk5D);NaZU($uw$wXiW) zw&Z_k0kTv2{3=t%4DW?wQX()fwZ690XclL2g0dT>w+s2FL(@Eo??p&Jen1oAf49lConH0U1{l3=>f3$kkH6v)yDyt{ij-|wmJnVQ4oTh9`Z z7Ck?r$7Jpq+&E&Pz3PW&NAaRD@wH`pZz-f>0#w|NON0b?wnXi5JoHX@;~CmC5$GV( zfkyN1#*w+3qkv4{)5Ygs?*W&W48Jylj!eryTJ2@~)KX&T5VrPyHcHooyZ-ts^c>k_ z-Om}y=8owU3C_lCfNBsPj0!3P;n#WjD)a(ilo4UIf zPNm}949(ip*No4Px#jUGA|to^8hAHT0u!P;Qj&v2mpZ-wa@$%;58j!Aw16(f9G%W} zZhDQJmqai+RA-O0XV2hAck3IIc9J{L!>*?oLbd@nOD*N3Ad@hz8 zXDo~Uq+eR_s&MEk&%nK5w^B;_K6}{BRbDWtdzSU$x-z0Fz_}^traq-eI0OhuQi36O zvB;IzwKdNvQ{>^Rs9}>rDy`~3REReH(pT5q@2SsTb9!-A)}*DbKmFXjgw;H|%rj9- zQVH44+3yr`s)uV-!X$GT<;To#+qJfPp%yo$m}5z@U5***Bwj~tsw< zH7iwdGb5*^skN`%(eo~)`v?}<-}f_r0GaO#82u90W36ws@ZVCHkrcT~a|pF`eC&r7&cWUom-2> z7I{F0af2=k=5H!%m=9{l566d8^LN@sKuq0x@G)Zsf>95Xd@kg^t%Li0xJsK(UO^0Z zux7%_2B#A}L+=`E6se7Jn4;4jhy@=W#a)=ejHE3(Fe#&KdY2P@RbpC(`ge}m_tkr6 zNsCN}u4)YtlDAS6gY5+2NFTXg9@k_}-7XD>Vu4`tBqKWNd2y*;MpB9W39@S|@stb0 zZG-H}_me=FKmc7v(5K4nO{zN8-UGA|?D{=jpHiYf5Y7DpWK!~C-;b;f%rX2h?&Gw> zRlXEb|qWb_q{dIKhdVrJutS7u-?nhWLCx8GMmK3a$V+<_aC4qT49p zGB{*<^8JHMDa%T!$>dIQH#Yd!wMx zL-16rieI?HE&QHiGkC!*&%q+dRl0;YDtovr{zNi1@ij4kG0-2~%}~_AGPl&PvTy3gIQ_lu|GE+6%#Ewd{`g z{yFXX7=bgDMCcXx|fD?p}cdwA00FuI+N!v>gJ+64_+76UG3Z%*`% z?iG)7Asxes_M>N;Bo2`<7#Y_`A^d5D?qWPz4n5%(^Dug9-opw`m~Ouwb5?{d{ea?5 zKy4f}EF4ma_bp{1m9G7wb=d3Tb%f2yhpD^Uq5&z?TwKO_@sx`>AqE zN76jV#~rzWK%-?OhN?Dlnm(DC53iaXX3CDNYxESCqP0e&)~@h4={6iW4U(;BM$3lN zsBtP^wDQP?Q=m~}%|b33xViUy^d%~jwbMQ8+@U(5*%z84K0 z>{6&)a=OeTm51WYmU7$Y<)!ck_D{nEyDbpwkJW+o6pu5@14@*iXqwKRR?ko>qt#Yx z_P9R?>kJLVIOJ$GM~Q3SU6i03eWwCq5}msIiieoBSKl*0M zQDK{-hF$W0UuJ2x1ZO|F|2G5F@MdGX0%s0nB#yK1c(sctiiK}^@hVb`Uo*5}P zR@fe%Tf~KaeamjW`%lxy+3r78bINbn8V69nL2?$H$^`ZGGZJ(3>NqB%X_@shZ;vVQ z39|)yIz%v>U{5-(G|+&4yP97fUY$x29ui1|3#55JXN7+Way)8Z2pQG7J=_{@g0NE7 zBVRNvu;B&b-#CL-^Bka)zx;FOBLA}<-VDiD z`A%_Pb@WV{|3?el3&|$WR$-|V|J*pi`mXf7x#kk5%0+=wL`TKdE&=^Mph(XgYP7S< z7;U{1k-YBSv3o=1@?ymi3Ioi(%sFJy=qXow;|GUCzB8%!M~OB{b-gU z5%Il!G1^^IquhN!KQQYb4b&vYe#GUB5S$<=a(lFv;^3AaKVoJRIQ&PcRTQmuTD3Iq z(w;Saus-;9?zr60$rZ_ot0j)XdhDay*(lc&sI?PyzpPq_N2=;HTBN8Sz}Eg>FeQ{ zs2;<})Y{XW_x$ftej>B?WwpN{=Qh6j{Z-%k>iY2$YjxMj#3v7sku?=J%WnOP_m|f- zb0K$Zgl)U_H;^`7eo-5o1mxMT>hS&sQ>_%DS~@ZkeRzcyi=1dBVp)jewVFdtr@yCm z>h*Q_T5x$&A-~-Eo>EgH13h_}PO4oV8$IPf6K_PX< zSeZPyv{YVft)mkG^Bkk)3`CbrG#fU>SMJF{>35G;y$TqmFUoWk<=*Jg$vve_<-T9I zn+bda9g8yEiT6Ui8{;nPeXjK@cyRB4*{xgMXUJzP#}8Uzkk2=)bk5H2W*~n-Pl_CW z#+3U#SRf$JkR}!$^;7h_I&!4d$GB@3E=;<&?c%Lz$y|9rzz#CI zkBg4FfQyo<6?U#}h*Njx2IJpEOuDYlcRX}@uXaVRiIx{DyOWKv{}P^wx{!c)z)3rP zeq4R(G`Qz5SPpE{U6hvPnc*q6`$O304o&i>UpTF3oPJ%-c^uMZGBf)a1X4YUjtU#D zur*I#j2ASPUAO%60D%1<}2Ed34|a z?`~e>vw#et5COVY1lKX;D(5$AE;W=Ss_E{e9SQc^=to7#tr&7mYh3WFqkRGp_rkd% zX8}b@)E}~3z|Fe%16Rp8IfnVVZvK4w&ogPk*{_gD$!mr zj#+qJEW%R5ik~d2uuC_IwdCrsi$BKu$V61oEx_&J7B+kyLp3c^aKCfModtGE9fy07 zq<-W3Cq}bjsjoRBhq?Q^UU4d`w5RRDRd)+f5aG;vyt-RYY7}#?pbPKumNb?5COl?! zFZy@A7jHnTm9xOt?REeZWP%B<+gs8qVrsftG>_y-LjSacXBUZPra)aWf=)?}C}epw zkOd8|A}u-s4=weAx;%o|8YtEd9+!l+W(nuKd0kqUP;1D1g&%0Cunq;e(M4%%82xMDl*#z0Zkcs4w;0~wmwrNl}OR5IDwyu)^%F*GjcF9jxi-B2xFFDP{jDa(z>tPv> z!z-F@joDJZ>oUcusx_Rc^tSyVSL9Ba9>A;&kh3hBUcx-i zF*A3vwRPF6#0%yf@klcxwol2r?PUtN_{#=@JyqOs4CocyObocmXIUx z_3>%aJ%?GJ*?M9GcNA;vCw++B5{FI1pMQ$JC)4Eli~allEwL9S<$z*}GH%??XPKpAr(xUEL=P+ix)kvZjuV%(oW_2{phwe`d3xiT*liGO!)RMx$a z^8MU+tb(f;LiiXWAg4h%P5OdNzm(`zM8-7Ct(*g1$ic--F-X5B=`lh;3QOzI1XlC= z@bBVGST=Dg&x`k#Eg}P*$Q=r^zz+ASdd=KI*O;UoG5wyhleM#?HgTxNY7(Me(3q9I zu>W9ia2vYi8_{;|I@y*y=QAt9BK>N9l}74J5=2<91~VsxrLgW-c2j30wM|1w^MkRzi?6xiQRuu*F4J!3daijTkQb_0O3)J2gl-T)F8LJ?U+zAhd3FTSW2G$Bf{9LpSq+2 zWeY+!NJ3c1a*`;4Ddftj)tr9J_%Z6`BhZbh8$%Wq&ql_qB&dhjjUJ(Hj35OMYk3P| zJTW}vEj(t}7m(L*Rna0Uu0SMk!N049yLK5CaU!l*U6{DR@{*4ycTno49wF6Qsq09? z8er%YOVzhRrx+rf{TV0C>R)=@S%AW1Mi%~@uu*glZN5WFQ@p!;YhH5XtZf^1Zx%9J z_Ceo3{||F}_sLOB;J6}QI<)b|cm0i@D(w$YMYnpC+OcTYKVb82&6gS>v1=kWGSt@* zxXP}Z3KK2hIhxu*RK4NVHKEpcxpjQ_UK~h#cMMw_Yop@e3?WSMjjs%xQ%L zi{L$NI?;*t5I`oVqj5j^1%D(tEGBPjqw!-=B)4;)^4Fw=a?7ZFtAHSY=|^HduN?!z zoLLX#(`bCwgw0-JOAR<=$naA2i=X{^6CppFsp2<(&Zh6j5nS9)`U6N1$MSwonIb>L z#7om@AtGTg{&?_VrDIzOGkCdpq+quJzSXog?*!XaoQ;y=@|}m*xy>O^~}(Uzt}&O-~i-oWWU7H^}Rrj2F!+pAFQiU2nFgm|N@M z1fizu17_2cwZMW&$7xk9y6#InmaZPkdOVgzikXmXeH_P>2lQ~pwCLZNc~(3tyqO)H z<4oN*ia77V8y7=8@_X3=aHex9fA1*dDBcQFptywVd)rg$vjbfej2l-NU~ltF>77k|NIhg=tM4m@_VRD(A~!R z^k$kcf4tYlHGQ#zn_VcH?C@JJhnv{NOke8k2`8>g30mQ&?C2Y3i>A4xjeY3;B-!a> z3#M->X|->R>EGTMSB1OK!dkbY*K;I_Ee4_*kJ)$QPy%0GC& z<21oW&CY?oJM`%GvKO?@5Y0kFy+9knQ&i@J%ZDT9eR++gyLbli+)h7iV6dV3_ol$% zvRk4ey}FUyk~|OGm6=apZU<6gfn|f9(_HU(kwq|(AXnG^+U;8zpp9aTUg~z5Cfk~* zKdoI%(R9##wswJ>3W>3uaua*pVl(dUT6M0?aCe3SW+B+FAqyUqOm3H*<`S2mM~h}< zrh$y>2%V{%2#*>)!eanRJ$UePGPla-$GiYiByy(uML9Ny7pnE_CCpLpLjw)k$nKv%)S*8zFy^$$cm6(nyt-b zu131raHVoKRzcm%R-)IhNlW;<>$MsM>GTcX{8Zt#?jpKXdpzVqMs8GsI%;+AXy}RF z*^z2X57xakWhaW$D9}r#Z-@HQMg}tC`;IPHO6VG*%T2>!bhPgv*wE zDywXQw*ID}>qZpHrX#n<)goSJvUYGU4?d!t*p+-)Y62_fj1J-0xHht#qN&(^W7U{; z;F5u_4+8&w5FXRLRZyBi5N|aE$Mn@SvI9C-9by3SCjt(&qOZS$F{^9mqy%Sm=)%?B zV=;5+tjPH3)PmSx>9!2gof1;4cii-udQ;IO4;WZ^4L35?tf~KANq-U26<>bl8Y?~W~K5|D-(Hh8_ZpfFYs zaS+|KQds>T$;Eu;ju68lGkdc zzov8MHv)Ag|Y6w)1Ui4@Lv9=2y(PL7C)p#=- zI)m0x-+D3Jz50egKEyq%AzSb4{+9}0|5pp}3i?^|({(lqAKuZIT)I@xofrvDxv-s{ zAgv}e**)Mo<==f?bHAtF1ozsHet0BBM!I_?eD~=3y?lk56y}Jub$n4aYgdjdBnK~Gfq&y-q1=EWa&^L>c`16%! zu8-LA3tGh@UO4WZ#zq_?K3~0oYhY6mbV6&<$ z56h&C+Qj{pH4oS3R)TUtK=#Vf3RzC{IMiTf6CrUV(APP2E29Ic2WRA7C_aJ^VoJ7P z*>{z0*^@4%`zM6CE80Z99NmgDvjIld=^ep13?a2RVX;%1CP2R6W9F`NOJ!(d^|C(Z zA$Zkk5Sahv-8>i$Yf`fOeL{=jw1e&t!aLrbm)@M`2|K0-ve>-Rjkk{#hyUD3sabq?Rk>2hMX+@ATLl?uGL+&je2S#4umE9Q zU+7TiS$wVc8IP6a=9#G_;zrAkyfeWw=!KhH!Bi?X5nDOV=MUVf{SvcqZam9AZe+;O?xKEwQL|C z6_JS(jl^Fc>@EED@cMf27VU~Tw(pg{*y3{RRoA3eVn8eLrtEYhvA&*YLA`I-4TiV+ z&^}c}kX2L|^P6PYUzn8?-73bu5}-Y4_81=0VrF&0(A%KL-4;uQ9r49b{Q%u%cS9^` z!6-kY<`uI=K~BxRe6ZTUi>tdASNltTIC8t&MiC!rp8UZWX9ST6f&LCZx)hA{ZrwxBGlK330= zNL}pIBTR(0nd3lG8lf3&8Yxy58J|F3tZ0I-Yv-%54w^=4l|?$;4Qf(3=(wk4#y zUSBGmUq>-sd;Gm*|J*N{=?v5!r2(BK6Q#4DWf0}CN61j(P)El@s4pUm6B&!`Q)E*bm_6HJYA*>pE5YpTT1E17hD< zJ24h0aRk1q9l={y!KF9z;tCsg)(5CCHof}`3U&&h4x61iWEz^XR;VC178w_aev(Cc z%*@QXj>>;Vct7Gn{`0HNT4mZB$m;dAIEAYVt~2K~AGQ(2qjwz^Gn z+lLS+14Yqd59J~)e2Qh476V`SJ!NtXt=AN#*oy*hyeJ+hD)(UMg1O zWg|12wu##vqS;#=QW;8kAXH})-H8o6wC+a+U^n7(b-!W0nroJ3+L0eGQNVD?1f7J)QyMj{p=?^Jj3mGJ;=z$T*Q+A zG`lCJ5_BZQWY;m1{r9|SrBHbg0!->#t5sv0DzH%;V1j&RiAuK3>jVh)vqP<(U%Q*5 zu+&h%`4J->q8Y~Et!SlOfQUDM+x8HWFedj`_GndvF=k+gb%69zm$CA`*}l1SJ)_XZ zg4}PI5oe;4tKa8Q4BY(~%z?u?ZC0(y2n+H&1#wq6JP8ASfCIkIHdH3TJ|tn>`f`=a z11-oCH525CW_2?M#wcng7j}H%24Is3VAg7`TkR90WV;`d!I_Na{{1@Wlp1@%Y$y3o zzgTX4+A~bi&N=LCf1@h}vZM7MBfA-mzh+;u$5n&-GLG5H95?ZNqs|D-HE!yR)ju3J znWo=dRAPrC%-r%`V3^O|QhXHPF-5J?{3!Mkxe-qxx>@D$_*D^Pzh~rGeM1n!BC_d3 zS@iFF2gxh5@U+&baVfp2iI|JrD+8XLt*K2fUjVw0G^=y}eg%K?bZvkg-~YjDHuqOh zHueMpy7|_gjDC+$SW0Rc!YgIi06``?sey}EnjYxQ0`9g5-%nl)=YdRZPkTz^C*Wzv zosW3>2#~p~^Bvi?cvKmeZBXv~0bZmm|Gl1^Lw8{COfa*2&RGxw?N44o&-`lqGL0B|sYRChz5GYI;y<9O=U5%xd9d3OSZXSZS=JTkF}TdSiq zDJrIItxmG~;_$LYQ~8@}dmvyQRQIgs9%!gG7mGI-pT!@2bm6Jp?=R&q70Fp6X6NiR z!vaw&BFw@}q1}Hv*C9Q-^_@ru>~e1U6vxcK%&EMMmRPJ4Zi4UnnEyq>!upqxIg1^n zf3$#=uF4|x;d6qTp~Q}Q#UD*u*bIB0zkF}+Rbsx?%=|5R>0pi@xR?$xa-nbkD0rmX zjBn~DQOQ{f0X{U}XWOCR_7Fp!{+pfDE<<_7yzA|3y^K#-H^FwR_>!3b z{xLc3LT{25!dw>N3W`3Q{Qh#f>#6@j0s-<*hWhu(P*sG-NvTbL)i?kJ1S<3LN{>J+ zCI%g@GFpKFV(Bf4k3j4Q_v5W~MYmN!V&wVU9X{@c=g6~VN94%F;O3;?_tMs~2EZ&H z7$3Uxa2UqH?$ah1J^^t1yPU1i3s_y9wiD+C9>h?kZST1=Dr#Of6W#ejvR}@?ztko6 zCT3_)c7^Dn#kTL_LH~#^>%eFycfA`u%P{;ofVr1D;C|P9~GQFsO-W~XXw$|}`rfruMJ zC~hHVtzUa*_bhg+(yhQS9RNc&_vF1mdfK32gaTVwVO6^T3qT2XET$H8ZUH^%L;L+x zjf9VQ@8watFAJ^jPY32`Lc~!42^cEK74Z6XRADc?KjZzcU+?BY&j__;jFw#>W<$u; znEgr3Puo?I7^Hk0)=3{5-umjyB2d*;V>>Hg+}#YoyTTY1K)vkkGZKcn`Lv!iIR2=M z1Rz&A^%NcJLz|N*%{dYK2$`-hYt%Nx(=zrvtw-1S5JsL=#u9$937{B%95yKuGW`<- z9I;^T1Y(D0Nh|^K&3RY0K0=^Me#AdDxg^38Rt0xxB{sF<-LE{7j(NeXW*TWq4`jq- z-BiF%!cz}k_QZeMO2C@zC>#W4{Miih{kUz($N~j7oEBS~lv*q!PESS3fewpDoo#}+ zCb@5V^L*VifSChg$FWtNPQ!HCT)SLSUeBW(a z^;M0rM16k!!_7Z;9w7NNK;wyEG+4wpc^cr^KOt@67>yJ}E3A`E8P zrL{dkn%@Aw8o8k8Q_JhN)7e$J-P?VN@MS~^Q^^I&l|J_0*oc?$RT^k-hCd{R!t+)N&%UN7=e208Mlrj7y{R?V^wf~ zwom~NG+>nfV*!lth}P$JU3`5q6z2e3rbi{?xOKSVV*(BP@=DAe>C0*cmSmm-SZFRK?dGk()u>+|aDGxI#);ng}0L@BOwSSkhs7(f@5G?S*^H$SQMwPl0F zfOK0_23ISY@b}q5<}}F z$NRtDcGs}9t8Bx7Db>O@)wlAVQsjqQt9TN6C%2#M^?09yq~S*adtA=4%iw?6X2XC9 z`!~;X#~ACsbX)zUU+BH?wa&;n($}wN=Vi1~o%y}~5Lv0z(TWFP_WL!$>;%8Fj=C|J z2g4RHgo$^{?yZ5xzggWMJXyq=qRieO?Zban4PCTt`!>mDm#wqW8!lE~Q}Z(UWMs9! zsy3n=1GF~py(W5h5_5n?W{tON*)4a`jg_8RSm1S!v(rwMKpyMU2{?hrm}hG~P1}P= zcN52O5ro-(`^J&uI_!{K2SSFyH?NQpT0`u!YyEo0)n?c98!Z?EW0?djK05a& z(L%O<2-N`yuN}x)D?|C~EI>kp#YUQ_Y>x5(GNc0x0fg+QGGMitQN_4Y%Ajxc@IiX4 z6lGn8n+u~N@Vo}YP#ietcIo=K6ZEedyubgyS^&PazaiQM&?DEF_ZHtVjP|0XCLsf~ zZUY;SF?!iQ_!+r+2}?CS0c?4k38WzvtG^FK9vRHJ&A28Ye`4HXyy|#P(l3ANa9Gq*eZ9%m9d&m(V9+Sh}Z**flBgSafGz%SW&b_bEKVvJP;4eHQDh zS4ZysyXhn5kQlj_gd_Lj%R2u66CyG$KK}xCjpARJlb5m^M3$*J#pTx&0s$~$FE1?jXtAm?cu*v*0$%!leS%c z-~Wf-6|}YOTDhDGkoror|Bp~_6>nkp8NL^GdP0i#-3IDBNwv0mvkCS;*$to?oX!6Q z`(L%t^GlN}2QXOw4fDT7Y>qhqrU`9vo1f{wh5@&v$WMTaxZ*H02YlfF0{U-#*_8JU z7F0C+@3B~(6uG|_Y2uu!#ee_f9|ASP6^J3>*;-}jna zn6}l2#%KM!DvqhsMxbSYi1Fn`d4#|j!(X~ECI!m=oc;o^(kRyzFcS(lw1CNwv=|K|ugrSJx}y`kmP#BJJKp8Lopzl6HS7mUO) zCN7`!#XSSvp$G*1%6H%oG2oAH|Fw+&wv@RBmc?Ze%%sS(13<8`%ZA|vcxi3{ryE+| zAq5*e&^3b*%bM>w#;ADUiZrJ-aVhQQHK327GaAd!PH>}v7_1sc7W^6VSRZ;wFt_!) z(@z4xR~@PI+6Hyzoe~Gi%)!T=#U3%1C9fWr4;#JLIL(e;#U6W=uvh=xt;)E+pS;WW z|G6FfOX7{Kk#v8y(r1`&XA!(YwheGUwfX`l01NUBSUmXTelk0{GLC>003uSe>U}$Q z*b{I^jCQ=bDtYlrK*hmdU>{ly?$_@AiLOt;OKu&K(qq;B?vL#x7ssT$OQF2<#}40DyP#MOmr#Kf(RG0fOx-k5LQ2m=CXVtjOYXUx@}J@4VCw zmlKAb)L=!xyUDO^hfR_!x>VI}V#_0KJ1@p^(%4@t7+!i0`?K0@UC$GpCzWpHas_vQ zoU51E-}~X|;KEdheL49e5CQS>x8xiMZcA7PjGqS(_eVnoZmpF1dZw4{_bnpf8hcm9 zh@WqZFFwTO%P}e2FPfh2q6>gJ-~B`ARWWu%+0=nd!Y~h8os4@VmULMc2;KONL~KS< zgzFDXa&J)1z{xKAFPdMVZk%8fo1CVFAMaBsbq?8EBOyA`bx&Sr^zBZb4>lOa>Hbv% zZ?5xH7p6R$CmdxgZadK8b^6El*W^Yhj&N6XRzQAY=9KL^by*%(1f)|d(7x$pfN1}& zcbh(or-u*K*I=xuvlkQrD6AJPnS=)rT9|NPOd!)QH{QBU__phb05BLIHhf~{=KUO06~7l<004-vhf+T?2B7BfmC;>?cL8_VQ5tY7WGS7;4oBB-jB%1&&6q9P_!)`Aj&QT33CYkvj*># zwHLoV>t!Z}Z;`$?V*0J-Ma}BR7a|TG79oK1^fd zp;>zu?jH4U{OYAHl#yyXh9InfGm-*+fM>zn5f9uKfM&fHn7m-!XR)_EA;f)xHhInl zh{r)x7-Os{BQ3UzG+H9ghd9Z*)MCUTth~Sty5oS+R@aMmp7HsEPisplrD#MlP`2qP z2#jp;j=W0ln8jxm zOGakvuvbk^XQU+|he6FnAEf^+{P-*DG=nOHFK?iS9}i$RU$a1mkwh zai8d|mksg#yY*RVKZ(V~7k{`?YL8yu6-n;>ShU@q-%#fdQwZ)bcelqRwW{~H5n2?U zcO58{3@9Y}sI!#7m93BpHmD@m89?>OI{P+Bw>|k&>T`PG>9hV{S#AU1%9!pU>#u`d zQhFKTx!+;G&z>WDH4u~4R>{Lv7#!ZK6vpDqSisaltGeI)f`t#oY7I30UsvZHPv!sr zeKA5FLu3XM93zrq*+N!2J~9?B{`S&zTo@Qnj&q=5;P)1(mjRg_*&N+ z@b>R6m1V=(amaQr$^-QKfJwuw@9U^gM@N7Cwg{Su4y!dM#v;p#WhB9FtxoOBn9jC= zOu9KTcI>#jHXTImeP3^+I2Q!9q4c()9UVa2w)?fKNxK7z0RfMG&R&Cf6lhIy15FTs zix6s@-a22}wHsI4(N3_L ziWoP_L7*t#VH=>_73SU)S}mXsn@r9+Sp2TDJGw(N2UNiB>Vu1{lRQZ>#lkN^t6_pL z$B8pE(!GDp(dX?#K*J{a)k#0Ib3g6A#-pM>@{D_iT0Q1f?H?yE`dedcz5ko0AL08{ zakZED6IUN+U4jDN`qqSXrA*#7)iZ6jnsa4J}u{x$)~l67jU&-sf=YF z3}*Q3K#yly71e?U2k1hchQg^{&mMS81E8a=<_oV~dBj@+T7JhMKoI$MN}JKZk*r4E zJODOnXoex$U2*kM)#^?sc+pn%-vB?G81#QPJfmOwC>3rP9Ls|2_V zKjq+WA_W9MH(1{8BR$~Z0a8q=b%H$*qnvE@5m@N-cUCI?BTp7{N5TB{b!(!YI+Qg* zbiZjbu_@8LiCNsL<(~F$(dt@UlASg0Gyo}H4Xr6F!IZ|&L?6c)sV22K{Dk!9vN!t_ zMOj%?NUXE&`>-C$p>1OpDaf<#A!L^)htujps>imUK|6<&`z$6^w@d>O_SdHhTIBLT zh@F-*6}q7$m~Nj*aKHPeGW4$3zYSfx zVo%sPQ>V7Zv8iL`nt(Or=&)U~NZV5r1JO9kdSAPx`>3F|8bklRejwhkjPflN9m9AC56 z-#o6mlNWDd2CU`P0>#p+i@SpsetLO;IO89tBg!Ch{8Zo!(E&+rc*Q8Rhei2f`zy92 zgR={0G^;+))Nq4KpdR$&k?;J0B{8P!Enadd0C!Z{Q7kNkIArW;nAxt*_Y1nT7g5KS ze9538N+g9F53J#U&him4FG5QrV-ufM@JFCwEz7GJEb zH0rEs=1#c-4h>R{u*Z1~Hq!2xpJxwM?zDhxU?;wSJ$|(GC4oxuPsk-lLIwiOU$__E zHIX_s!AYcS1D|;boOhnABID~T7shg$fVh70I#n&$)?)HX(D8CJzQums-E3X+T0>B* zVLe2l>hflJImicy9LLt@=-(YxdxOT@%$L>4PA&yo502>OsOL9J-5&_gYzsQ5Dy6bs zF8y-|ziPYu7AAntBR_d6MT%9Eo#O{mqV=0PD`*TG;r+8Dhx4zpCbP%cO#*dqB!D9+ zhfvB6Q#DDC^i^7}V>)6d6XUWqo)@?@OVJdndN)xy(!O)fonxb5onMPM2NQoVf4vRL%wlTy00t%?8S!tQIB1#K{BTe z6?b`9U1c?X%tiliE@0!^*EN6ocrz$q_yMl5<&{!DR$c{+mCyCRuQORb&H@lUC9RARPf+ z^67Z9ad5)m3U+B7k`DsVK31%kUKH^AKnOn`b`d8WFp==8=WRQ~o@J2v?@+>-gR!?+ z6nX^DAQr9r$9%FhFMa;SB%rmVZQu@v(BEFjtkr%S(PY$^~9?&!_ zn!IzqVLn!rMBTC2!e%lbY2Pl#K%pk%t?sJ6$}>SNvUxerf3k9Hj|kT`Q&}{UyLQm_ z(@4K1ZLPNPM+ehFzHjbGQEl`QwT@a+wXedEV-M=uXd#-4y1_pho>YESe=v@!$H$f< z9lRE#AN4wtGD3%+A%R+TEIX(rC#b1%yVOC}#!BzX^&da6`56uj5uRHdI-TR>tm>w- zJfYEAdBs_W2?$9LYYAq#u9V+h>-M?CfYs2F)k+cPo}j+C%%v;X6z#f!{X&leiJbY% z8eg85ypR+_c~smstK>Vl(bO&+hn@RHqzahl+L*93>m}g4220tr2DU1Vh4)Srnxi zIC+CqFKRGM5k=DrRs?+L8cXieQ3{m}oqoc=Gee#F2w}&P2~t!@7H2lVcWHg&pM7!T zXS=t`EiJbJKb-y8yJrh+?UGQgvSnODj4CySywCW)x zkiV$U7(c`zFx9(DfwZFoD<>^Ti80*~ZE{Y?;faB6hbKjawZ(2VZ^`H%mf^@pPT!B| zfhfQjm=OwJ>{d0}C%0`?q#ZASD0s%Fp^w# zS1j4tw4U8=n9od7ak6O<>E+Gxt}lE~-)x`c1XThW9b*Bq83kceDI}^@qi_1d)F-Ed zN=EbNFZW65vUEp3gXet?Z@xSJ8WHqemTR+sh~_?6X0wM);{&|l}sCk zUQGbpW%n88Z<>xw=kz=O86x;&3f0e-#u}%57m?Dfd&VgUoUfUFv*`KsV)q?Gp=;({ zBh=DMuoS2rgmgPc;vh*yyZ)d}rEQ|MkPio4VoG}$LTBJ*c5J^7pG~@&&-J70o8#ji z3G3&@m=$rZY<<*6-wO}S7I<28N@-=Rj5vV-}fROQg6_j%w11q^)dv|0BA$Ov_WWd%nle2HXpX|g5{Ic+`hPJym|cQ$PMpo zgl<|4gGvn?Im8~g%SM!jUU+4lPn-8$19<(1hLh_LBHaeeUmNS+KzV?MN~o*zLvUO8_l9|agQpNs1nwcc@_Rm+8Peh# zk-u7tL4KB^a&3@-9*Z?U5IRl7yp&jxk|{?=nyR%Y?uTTse}7>oQDVnr7D^9?Mv}OK zhY=g+E)%wnneQ~HTOaKV`e;y}@qF8R3>UVuMdV46okL~q3H-YA62%yQsM_hrN-HwE z=>jIGuMsaf4m{+_o)}Hs2)%y=a5sK=m{|I+$mtEaGDgk{D>FCLqcxH|=a7x6>bh-J64IP`0L{-Ja|zl_mt{d_{O1j@3+w{W6^%f4%M+g?N< z^9{ zO{JnuZ4lC(cOth@P3HC4tMK?$|Yl#TcIC*7aAE*`Uk34_Nk?vKd@{Lo3OoMAx-#TOP3~I%zP^~@F~OL`p7g5!0T=W=U0A$; zVax*7szt#9hXKF5pRUq+Xv zjk$&R8789%@Hy6Zm1of5Nc+=v0}$psKV;4I_$IY?dX2Ez}Uu4It;w2OPOZ`dAVY4FYM%N~m~I zsL(yuH3!(qa07tr$_~)$B6g2*l5l@}FDlV_y?qdJPhU*qBCfF@z*ptJBsVlJN=3b( zq>f%cEeN<&E)<>4ekk=!aEo`WtNPUL)%^(-eEYyxdrl9z@wc1jB?-zSYJPmBfW`bT zpIH%L7X}`MjTrpLZ<2!nFY9|p<^#fVYetGzk008U2O3$2un-O6?g{!>aS}Kzg}A(q z4{%*WES|gsjw*~~FuL=S08ZY|`PB~USOSeHacXb8N*|7C^VEJ_qIqHYOuE%u= zsrguUy8=?xRhAomcZC5;m60EqN`QiL2evi7wQG$NBq4jH?NM#N{FRLi`LKfu1|#w< zg%j|UA%!53GT2y(D7wwO+0RoW)^hM>#SuLTToAH^7Ezl4`NpSmWnZ1V25^(>wf-{? zx-0Ahe^Zague_h5XLL$R1lc9sXPsS&_kLm&qjRi{S`K{o!+ z10e~-48_Bxrj7N*eP@5s6Q36{=rhI7BjM4#>R-4d?H+xBclb7*TN4ifU=2y*#x2|I zJuO?50ghtB(%(eoDebxb2zdOVFYJ(m?aEeWq1X9H3;oEX;n0$sI=-2nWRI&`s)C?v zDG`Q(_;M=m#xIN{<`lzqd4RpWBN=|lG}znv8yy*iD!Ru=WrXInsX1@Y ze2>-an;t#$(xypi3I+_Z@qB@DEsjTnOC&i`Rz7>a+a^x1s^Xcz4n}& zvFYlmKLjMcR|so~!i2s`g;$pTZV$O(Hh8&(w2bFN!U`5{rP@MIF-oH><_(V5#Q0n* zHB>|NFx^>)pua%&D+!GC3Rr-I14qt@e{ym=A!kFRpq#J6`Uuc9`Mf#zidf|+je${{kKtOmM4Zru9K0akUQeAnFS54(UBgDJR1h{644p%>>Zy6)J<{orM zf4y-dVcDRu7fK$q+*XW6$VL^`i%&7Enm+wYY?OMfvaH1qK(Hy@~c-lrZH$zn`X&htB8m z)X}EN(FWzhX^`3J3(3iFvhKY;-JX$wUpELCIj5^m@vE{g>pN?`|1;xKo1%i>^@r-m zogs|5RyDIEzJ=(eV5)kfNFjxk5~Fgt7?PHL(;&(F6r{5`Fd3hue7Zeq;H zLzf6>>2^N?S~?X}cb{yG(+AO&(h}-H84n0(L$ zpZhN#Tt4?VwLSGcm`N>~tuCb$b6V5fp13B#{_QfYko;zNYFBg11*UNJ5;obFk0?G) z5$0bndJ-q=cdfXZQNHl;_c>ITYhX%2`l2LNC}U3HH+2vLMKI-kdPKItoW>EBdE=>* zUs^(^%W#&2yR2uS>Ez7C)8_p`g%|OEcp`U4DMFydhM&5QXN@-y=dtmWJvk!sLNzw< zukZn3o|9j;au6FkB?5!wicnMbAnAUi93c9wowK2XiktwhnXjv!M8Lnf0F%EzC)N4J z?ce>0plxgT+d(uPol;aXoBWVj>?b<%e*%AQCg9LBPvS}*dMq^|xr%}8^71#Iu?00D z1I&va6|2gNrU*BqYJ_HwoFaf#sJUy@)@DJ;EFj^#%1=XV>*6c~o>vpUcLu#z>&Hhm zot!17iPc!-S7#vra`jA3xF%a6yDb?e4F^H;6`N;&QMVXUC`Ax|MIuTH6Ge`g>?h># z;4KoCCCK*1XnHxmv(g1w_;^A*D5{s!mb-uqIuz0xv_UPQ;Bc30j2;PvWSh1zrj0~Q z+5E9~-66zz1Z(I949M;TP^kH@SRVIxiEG%JB%?F@m4jR zS{M4){^3yuHt?(WQ z_%5qmTv*7HrkV4yT&;=s-BGowT#2riDVl3N{Z5=`m=^A!bbmvqF2!Vy0*@hwXK_A~ zoIFO6w$IHncLLsEyvhfdg{BVnxfAz2M{a>M3~lV=o--YgSaN`0+zsk%*ypqihR;WS zZTu8q!-?HM5feZ2)PyV*a`5_Pn!tE=U#aT5|B)K0hw*Q}tsMKz$&tse+CNad#m1^hUi-?BcfN)xJa0j5 zbGg$R`rU{K!yNVHX;wO6>T!9$bvMseZBw27i|^;aB$^HLq~a_EMTEd5H_k85)!T-P z&G71tt#f7c@i3^BLuu-cT9OLRi0_9wSs8k4&vs)WzsCP!zG`%CX`E3V*RYzsY&o`n z&w9PY_cNvI`D4*)1MBZOHHqrT$))^VW)4$|upi+7OXF9#?uPHQk*EOLs8oOS)hh4A z_df1@*uJKw@rq0RptKJ?K1cnbE0rBhvFH3krzZb5rZJiG;b_`B19d7F1BjCXKQI2Xr7uyfS3?-a9bvmn-^RrU9x($h>r^y5^raPTmzy8TU`|Mg z>5D_$vfg(y%4bqA*ZuZX^K)z^Px2<7CdHV{y zJ?pjaFA~{!GpF@Q%h!(wp2(aDktF$25p+ICn6#7!y6U0_7v+wW3j=rj+@S7n5& z9beh78=ZR~lezda%|zB@3y+@dyJ}^P&0P9*^rafgMzQzW(Qd_THy;VVnJrTa#k65_ zEbT6~$(~pJ5YozEXQk@NT9xqQP7+q~t4NOiLiVq4BhH;>s4rhIT_Fu;mN$&Mgb7+3 z%g?0}h`5UzextgS+B2glGbbwXTzt+HT#oPWKlw1+A}E#QZSOQWI=qE{ZKJ-~nF-51&ucpxfG0ld7J+OMa)(hw*|F|ohZQQLU zmMFH4I>msJzIH~A+MxEg9iU0P72PE`b5OkXMDQlgeLSm5*0KJX?8`t?DJKLO+a(fv zLX7&tY@6poy@AC!(vlXC_4HFw0^L&G^@Yl|-;rZ;l`-^!cvr-V)=Zks6;*6REQs*u z9|5@E$GlLzs_2r?Z8a-Zao?VL7auso!9>iLut{N>;4iF&54OXMgt7)YSbJ4}hNEMk zBR2UC>?&uHnuzAiVD;A#0Qxv4ZJg`$bSeo# zH=67D$&Y)z_r3NgNO+-^hY;?XeUp!iuRiFZuEj~L7X|9$^+NIJR{FqfCdbMy@T~XQ z;!{i%r%h+7h`a=n5Ax8dAJ$qeOA4cdcQ0GLWIE@aQu>7HiuOZU8(wT=$$rzoAEf7p zCr#X_TzzJsIQ?^cw24TYz&%D^xAb36@p#6fg-%~1p5>QC(ktionp{w0WI@c4qI!{t zUCkr2abb7$eCU?BJy3!c`I|JTJF(K5)i$_S5-+TcU=1o!i4hvD*Wudqvj?VlaP1Z{ zmoYY1hAS~A#Oo&6^KFe>-RFbvf^*k%C+~cIukke30<0BrjxDwTYS#-jU3gVg_GH@; zYePlunJs8Oqe-lMQz8x$yn-P8iw3N^_Rr*E@k{wU-4VM~w|rq|HBYP}c|M}%PJ@s9 zl^2YZa#%fzLoVWs7N#nCypWyjA}H8{dr9%1^cpm{nYISb9K4Y4BZZ<`8WNYdO zs{=dkymjkvLtqzOIwXCDJU7^Z!a2JZ+{T5^nWZ9M{L`( z#vV2xrFJTTT?xLn`1z9V?Uk#wS8oi5TaarvQ`-hcB@b#oY92@P|p_ldptBG(S4u=M4knb@J zQ7@QB3aNZ6DZ{6R&HIA7gS%=HNYZ9O(MQDIfDR}el0N?qcAeG*@N0pG(2tsXiKqCH zFEWrzC_=2ISM&Vzx<`A_2sFF?FATW*Bf)oZ-P5T0c_c2c0ns8)io=)N9u+d8 zmj}4M2tswKC>^ddj>^9>PQlANZXiVZaB1YGufl&yPo&56h^&^kiM;K%2X1e5DRi-} zdnvq$wd4@xrHmM#6el8C{qb7`SNKpN>;|dFw_{msj@ZD!Q#uFhQL>H9zrN66g(wb} zf^x4T$}%eOFYL@=8Wg|an_4IrF*o{z5-ioTqF& z9LoLXLihDY)J#)kf6r9(9mvYmwNbbwD4z;Raj(+{yr4?U+0>z>K$Oe>`@tHrl;nGN zj5xNGztEDiAPXe&JqAh#7hW~E?z}j4S&Y&3Ak*k>EYfetqJzCFWsx6RD|*`|2W5jo zb`#>4^lOYh(IY$LX8QWFS>%7N-90rXI)R^A zFgbp>%}4TLi^qia$~`q1e-0#TXFwAhgYDF0^t!dC<+G|)>b&3P9zV`qc}BN;pTn-G z^PB0L2PfB{O|q?w31i&{qfW~)e#G79Z(gCw_EFPkP z#J&r`ba7$A2?7 zQ(Am|cZkC`h4^?!oLTND_Msv!S3UIUVAyewMBfZ+6BQDDBBKZ6EdBL^FD)JI+-QKA&2h>tB1-d4a!ua z`fPR5q}z1K%1d_Gpp8hswHV*Bog-`X@Wto9KekC)5N zY>(p>axX*1v&3GI!s!&cRWakPqFTv4!bknyAVtjG9S%n=ypk8QZSv!7UF*TDgmF+g z0JWvCo&%7;pWa$y+0l9dO;LG`J50aa94bTClQPwxY-9$zF4Oi=gD?5zlNx4s;qLfk ziw_z-Jf$r5{Z@v3%NMs?sCA3UBLzZ#u0qIu-RLmnqWplx;r%-Qf)t*ShU@X_x5>%) z^H1u^zUA+{DC3g+m8G^%qU<5tmU($QZ-Y3Q#=$H56Y-T@Q|>MEo-ikt`<3K0ojrFy zT|F%RnAj&L8^;U9^1gepQ6?wXU1c0oZN7)BncC_k5wJ=p1w@KBG#%=hxYezA)!n zyhy@v5(^USvR30xVwSx!6aBnnj^c-%<*xhPvjI>%XTGeOri?K8czgNq^!Om<_q~m7 z>Fl;hC(b0bJ4uRpFR7AVK9Nl9yAsG-P-@3^d0_|)%y~?t?TtxQif?Da%p=`nwfJhj zO+MJt&z?1#?e{OR^X#PeIKLR()mfxalkJd5So4yyrwcpy+;PUQmy11z{zkNYnmPX- z4%6mzuI3Aa53GV8Y z&C{N2U?x0=X*>#<%WHTVVsn#((9Yl;b){+uLTjB`T@&&vSyjjDGleRnGOfP@ht~tBT&e zMaJ>_HtrTjNZZ#_zk-O0oL6z%awb-)3E{#>-(vWidu;cg*~BOz%8Vj>pAEiPG(BjcsHEr8wT#-i5o@xyRfadUJ@<-JzPTRl`B1_Q zUhq7u$ceGMA#mB9dtY_Xd2!)LiHd{e6Yhxq(-U}}Z!z=3@k+@aF>s3!u@;OMUGLM; z;#<|d8v6Nb;-wsq)hdLq%`V7uI4W~w^&mET6MUQiT5#lC;tt50Z5UFMm1+HQqTnfN4w%_e94Ma}|7 zUxsnh*8X2F_SM7+M#VWjlB*xuhU{S!DOKDQ)?XO^^l1QBj${=Zzwdooz9bVBmv3Rc z8R}mS1{IwiVSMg&!NoH~K(mXis=x?Bl8-9s$Bf(NIrISh@>~>g1E1n^J>{xc-6avIXf6Z{9|%v+4TP zT0E!X_z;i141r+O)vsM6Ii+}6Pr@=TZMWx`epj1}9{g6%e^L?^EWxsk1aZ$e|i9tHmQz8gSOacHJ_IB~~OZuH(w3s`~nR7*cy+ zq1@pxi=JQ?g!Y+$Styg9_Ql;<&mJGlH7s4tJj@tc0HbUH#+_Gm?IB8cLRHDUZWk*~ z!X3}IQEhfqO}WYIyMh`}wKD&(py-ehwUOE)Vc3y;sh7(uB%|*Wi6|oZ$MLrJs$$0O zOdv7EkjC3f`QICFct$P0OhkIlsLak!DnM)(>S`8uSc!;;e*b>WO*06dkP&7q5~9ag W31}{D6bx)3QhcB+UwGf(-Two4D~C@2 literal 69227 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 RZcQ5M($$1RE7c!H{trL|#l8Rl From 0d8a10f0065453d0cb8d20495061a28d443de379 Mon Sep 17 00:00:00 2001 From: Stayer Date: Fri, 31 Jul 2026 17:35:19 +0800 Subject: [PATCH 09/31] docs: showcase project achievements and Pro release --- README.md | 35 +++++++++++++++++++++++++++++++++-- README_CN.md | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 457f07f9..0a8139bd 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,24 @@ 中文版

+

🏆 Achievements

+ +

+ + chaitin/SafeLine — #1 GitHub Trending Repository + + + chaitin/SafeLine — #1 Go Repository of the Day + +

+ + chaitin/SafeLine — #1 Go Repository of the Week + + + chaitin/SafeLine — #9 Go Repository of the Month + +

+ ## 👋 INTRODUCTION SafeLine is a self-hosted **`WAF(Web Application Firewall)`** to protect your web apps from attacks and exploits. @@ -99,10 +117,23 @@ to see [Configuration](https://docs.waf.chaitin.com/en/GetStarted/AddApplication Yes, SafeLine is production-ready. -- Over 180,000 installations worldwide +- Over [400,000 installations worldwide](https://cyberpress.org/safeline-waf-reaches-400000-global-deployments/) - Protecting over 1,000,000 Websites - Handling over 30,000,000,000 HTTP Requests Daily +#### 🌟 Community Recognition + +- Rated **10.0/10** by [HelloGitHub](https://hellogithub.com/en/repository/0d07cfe266af4c25ba3eadf2c3d06f50) +- Listed in [awesome-selfhosted](https://github.com/awesome-selfhosted/awesome-selfhosted-data/blob/master/software/safeline.yml) +- Featured on [Product Hunt](https://www.producthunt.com/products/safeline-waf/launches/safeline-waf) +- Recognized by [Cyber Press](https://cyberpress.org/safeline-waf-reaches-400000-global-deployments/) as the most popular open-source web firewall on GitHub + +#### 🔌 Ecosystem Integrations + +- [SafeLine MCP Server](./mcp_server/README.md) for AI-assisted management and control +- [Ingress-NGINX](./sdk/ingress-nginx/README.md) plugin for protecting Kubernetes ingress traffic +- [Kong Gateway](./sdk/kong/Readme.md) plugin for protecting APIs and services + #### 🙋‍♂️ Community Join our [Discord](https://discord.gg/SVnZGzHFvn) to get community support, the core team members are identified by the STAFF role in Discord. @@ -121,7 +152,7 @@ Several contact options exist for our community, the primary one being Discord. #### 💪 PRO Edition -Coming soon! +SafeLine PRO is now available! [Explore plans and pricing](https://cyberserval.tech/landing/safeline#safeline-price) or [start a 7-day trial](https://safeline.app.cyberserval.tech/). #### 📝 License diff --git a/README_CN.md b/README_CN.md index 4da0f5ac..199c2282 100644 --- a/README_CN.md +++ b/README_CN.md @@ -14,6 +14,24 @@ 国际版

+

🏆 项目荣誉

+ +

+ + chaitin/SafeLine — GitHub Trending 第一名 + + + chaitin/SafeLine — Go 项目日榜第一名 + +

+ + chaitin/SafeLine — Go 项目周榜第一名 + + + chaitin/SafeLine — Go 项目月榜第九名 + +

+ ## 👋 项目介绍 SafeLine,中文名 "雷池",是一款简单好用, 效果突出的 **`Web 应用防火墙(WAF)`**,可以保护 Web 服务不受黑客攻击。 @@ -94,10 +112,22 @@ SafeLine,中文名 "雷池",是一款简单好用, 效果突出的 **`Web 是的,已经有不少用户将雷池投入生产使用,截至目前 -- 全球累计装机量已超过 18 万台 +- [全球累计装机量已超过 40 万台](https://cyberpress.org/safeline-waf-reaches-400000-global-deployments/) - 防护的网站数量超过 100 万个 - 每天清洗 HTTP 请求超过 300 亿次 +#### 🌟 社区认可 + +- 获得 [HelloGitHub](https://hellogithub.com/en/repository/0d07cfe266af4c25ba3eadf2c3d06f50) **10.0/10** 评分 +- 被 [awesome-selfhosted](https://github.com/awesome-selfhosted/awesome-selfhosted-data/blob/master/software/safeline.yml) 收录 +- 被 [Cyber Press](https://cyberpress.org/safeline-waf-reaches-400000-global-deployments/) 评为 GitHub 上最受欢迎的开源 Web 应用防火墙 + +#### 🔌 生态集成 + +- [SafeLine MCP Server](./mcp_server/README.md):支持通过 AI 辅助管理和控制雷池 +- [Ingress-NGINX](./sdk/ingress-nginx/README.md):保护 Kubernetes Ingress 流量 +- [Kong Gateway](./sdk/kong/Readme.md):保护经过 Kong 的 API 和服务 + #### 🙋‍♂️ 用户社区 欢迎加入雷池 [社区微信群](/images/wechat.png) 进行技术交流。 @@ -112,4 +142,4 @@ SafeLine,中文名 "雷池",是一款简单好用, 效果突出的 **`Web #### 💪 专业版 -查看 [社区版 vs 专业版](https://waf-ce.chaitin.cn/version) +雷池专业版现已发布!查看 [社区版 vs 专业版](https://waf-ce.chaitin.cn/version),选择适合你的版本。 From 67bb915d9899fdf985938d8a4ac595eb4968a384 Mon Sep 17 00:00:00 2001 From: "jialong.wang" Date: Wed, 19 Aug 2026 10:29:56 +0800 Subject: [PATCH 10/31] fix: restore TLS certificate verification in installer Remove the check_hostname/verify_mode overrides in init_global_config() so ssl.create_default_context() keeps its secure defaults. Without verification, a MITM attacker could substitute content for downloads such as get-docker.sh, which is then executed with bash as root. --- scripts/manage.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/manage.py b/scripts/manage.py index 7bcaa2f7..142b255a 100644 --- a/scripts/manage.py +++ b/scripts/manage.py @@ -497,8 +497,6 @@ def init_global_config(): global REQUEST_CTX, LANG, DOMAIN, PRODUCT, DEBUG REQUEST_CTX = ssl.create_default_context() - REQUEST_CTX.check_hostname = False - REQUEST_CTX.verify_mode = ssl.CERT_NONE parser = argparse.ArgumentParser( prog='installer-management', From 9521d5488ad2f9991baf22106701a87c18866e7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8E=E9=91=AB?= Date: Mon, 24 Aug 2026 16:46:21 +0800 Subject: [PATCH 11/31] feat(mcp): add read-only multi-instance server --- mcp_server/.dockerignore | 7 + mcp_server/.gitignore | 5 + mcp_server/Dockerfile | 23 +- mcp_server/README.md | 353 +++++++----------- mcp_server/config.yaml | 49 +-- mcp_server/docker-compose.yml | 43 ++- mcp_server/go.mod | 11 +- mcp_server/go.sum | 33 +- .../internal/api/analyze/get_event_list.go | 62 ++- .../api/analyze/get_event_list_test.go | 96 +++++ .../internal/api/app/create_application.go | 34 -- mcp_server/internal/api/client.go | 166 +++----- mcp_server/internal/api/response.go | 48 ++- mcp_server/internal/api/rule/create_rule.go | 35 -- mcp_server/internal/api/service.go | 222 ++++++++--- mcp_server/internal/api/service_test.go | 186 +++++++++ mcp_server/internal/api/types.go | 50 --- mcp_server/internal/auth/auth.go | 292 +++++++++++++++ mcp_server/internal/auth/auth_test.go | 259 +++++++++++++ mcp_server/internal/config/config.go | 134 +++++-- mcp_server/internal/config/config_test.go | 106 ++++++ .../tools/analyze/get_atttack_events.go | 90 ++++- .../tools/analyze/get_atttack_events_test.go | 77 ++++ .../internal/tools/app/create_application.go | 41 -- mcp_server/internal/tools/example.go | 46 --- mcp_server/internal/tools/init.go | 10 - mcp_server/internal/tools/init_test.go | 13 + .../tools/rule/create_blacklist_rule.go | 66 ---- .../tools/rule/create_whitelist_rule.go | 66 ---- mcp_server/internal/tools/tool.go | 5 + mcp_server/main.go | 123 ++++-- mcp_server/main_test.go | 60 +++ mcp_server/pkg/config/config.go | 72 ---- mcp_server/pkg/mcp/mcp.go | 171 ++++----- mcp_server/pkg/mcp/mcp_test.go | 179 +++++++++ mcp_server/pkg/mcp/schema.go | 169 --------- mcp_server/pkg/mcp/schema_test.go | 227 ----------- 37 files changed, 2212 insertions(+), 1417 deletions(-) create mode 100644 mcp_server/.dockerignore create mode 100644 mcp_server/.gitignore create mode 100644 mcp_server/internal/api/analyze/get_event_list_test.go delete mode 100644 mcp_server/internal/api/app/create_application.go delete mode 100644 mcp_server/internal/api/rule/create_rule.go create mode 100644 mcp_server/internal/api/service_test.go delete mode 100644 mcp_server/internal/api/types.go create mode 100644 mcp_server/internal/auth/auth.go create mode 100644 mcp_server/internal/auth/auth_test.go create mode 100644 mcp_server/internal/config/config_test.go create mode 100644 mcp_server/internal/tools/analyze/get_atttack_events_test.go delete mode 100644 mcp_server/internal/tools/app/create_application.go delete mode 100644 mcp_server/internal/tools/example.go create mode 100644 mcp_server/internal/tools/init_test.go delete mode 100644 mcp_server/internal/tools/rule/create_blacklist_rule.go delete mode 100644 mcp_server/internal/tools/rule/create_whitelist_rule.go create mode 100644 mcp_server/main_test.go delete mode 100644 mcp_server/pkg/config/config.go create mode 100644 mcp_server/pkg/mcp/mcp_test.go delete mode 100644 mcp_server/pkg/mcp/schema.go delete mode 100644 mcp_server/pkg/mcp/schema_test.go diff --git a/mcp_server/.dockerignore b/mcp_server/.dockerignore new file mode 100644 index 00000000..6960136b --- /dev/null +++ b/mcp_server/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +secrets/ +state/ +*.log +mcp-server +coverage.out diff --git a/mcp_server/.gitignore b/mcp_server/.gitignore new file mode 100644 index 00000000..770444c1 --- /dev/null +++ b/mcp_server/.gitignore @@ -0,0 +1,5 @@ +/secrets/ +/state/ +/mcp-server +*.log +coverage.out diff --git a/mcp_server/Dockerfile b/mcp_server/Dockerfile index 3d966342..b4ec4665 100644 --- a/mcp_server/Dockerfile +++ b/mcp_server/Dockerfile @@ -1,28 +1,27 @@ -FROM golang:1.24-alpine AS builder +FROM golang:1.25.5-alpine AS builder -WORKDIR /app +WORKDIR /src RUN apk add --no-cache git COPY go.mod go.sum ./ - RUN go mod download COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mcp-server . -RUN CGO_ENABLED=0 go build -o mcp-server . - -FROM alpine:latest - -WORKDIR /app +FROM alpine:3.22 -RUN apk add --no-cache ca-certificates tzdata +RUN apk add --no-cache ca-certificates tzdata \ + && install -d -m 700 /var/lib/safeline-mcp \ + && install -d -m 755 /etc/safeline-mcp -COPY --from=builder /app/mcp-server . -COPY --from=builder /app/config.yaml . +COPY --from=builder /out/mcp-server /app/mcp-server +COPY config.yaml /etc/safeline-mcp/config.yaml ENV TZ=Asia/Shanghai EXPOSE 5678 -CMD ["./mcp-server"] \ No newline at end of file +ENTRYPOINT ["/app/mcp-server"] +CMD ["--config", "/etc/safeline-mcp/config.yaml"] diff --git a/mcp_server/README.md b/mcp_server/README.md index b88b1c4e..ba087fce 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -1,256 +1,187 @@ # SafeLine MCP Server -SafeLine MCP Server is an implementation of the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) that provides complete management and control capabilities for SafeLine WAF. - -[![Docker](https://img.shields.io/badge/Docker-Supported-2496ED?style=flat-square&logo=docker&logoColor=white)](docker-compose.yml) -[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat-square&logo=go&logoColor=white)](go.mod) - -## Use Cases - -- Automated management and control of SafeLine WAF instances -- WAF configuration and policy management through API -- Building AI-based security protection tools and applications - -## Prerequisites - -1. Install [Docker](https://www.docker.com/) (if running in container) -2. Configure SafeLine API Token (obtained from SafeLine console) - -## Features - -- Complete MCP (Management Control Protocol) server implementation -- Support for SafeLine WAF instance management and control -- Flexible configuration system supporting file configuration and environment variables -- Docker containerization support -- Secure API communication +SafeLine MCP Server provides read-only, multi-instance access to SafeLine CE through the Model Context Protocol. This release intentionally exposes one Tool and one SafeLine API: + +| MCP Tool | SafeLine request | Effect | +|---|---|---| +| `get_attack_events` | `GET /api/open/events` | Read only | + +The original site-creation, blacklist, and whitelist Tools have been removed. The downstream client has no generic write method and cannot issue SafeLine `POST`, `PUT`, `PATCH`, or `DELETE` requests. + +## Protocol + +- MCP SDK: `mcp-go v1.0.0-beta.1` +- Go: `1.25.5` or newer +- Transport: Streamable HTTP +- Endpoint: `POST /mcp` +- Latest supported MCP revision: `2026-07-28` +- Removed legacy routes: `/sse` and `/message` + +The server supports the 2026-07-28 stateless protocol and retains Streamable HTTP compatibility with older MCP clients on the same `/mcp` endpoint. + +## Configuration + +Instances are deployment configuration, not MCP Tools. Each Tool call must specify an `instance_id`; the server never guesses an instance or reuses another instance's credential. + +```yaml +server: + name: "SafeLine MCP Server" + version: "2.0.0" + host: "127.0.0.1" + port: 5678 + +logger: + level: "info" + file_path: "" + console: true + caller: false + development: false + +instances: + - id: "production-a" + display_name: "Production A" + base_url: "https://10.0.0.10:9443" + token_file: "/run/secrets/production-a.token" + timeout: 30 + debug: false + insecure_skip_verify: true + + - id: "production-b" + display_name: "Production B" + base_url: "https://10.0.0.11:9443" + token_file: "/run/secrets/production-b.token" + timeout: 30 + debug: false + insecure_skip_verify: true +``` -## Quick Start +`LISTEN_ADDRESS` and `LISTEN_PORT` override listener settings. There is deliberately no global `SAFELINE_API_TOKEN`: every instance uses a separate token file. -### Environment Variables +SafeLine CE does not yet provide a dedicated read-only service credential for all required management APIs. This release therefore uses an administrator API Token per instance, while the MCP process enforces the fixed read-only API allowlist. -| Environment Variable | Description | Default Value | Required | -|---------|------|--------|-----| -| LISTEN_PORT | Service listening port | 5678 | No | -| LISTEN_ADDRESS | Service listening address | 0.0.0.0 | No | -| SAFELINE_SECRET | SSE server secret | - | No | -| SAFELINE_ADDRESS | SafeLine API address | - | Yes | -| SAFELINE_API_TOKEN | SafeLine API authentication token | - | Yes | +## AI client authentication -### Using Docker +MCP client authentication is independent from the downstream SafeLine API Tokens. -#### Method 1: Using docker run +### Authentication disabled -```bash -docker run -d \ - --name safeline-mcp \ - -p 5678:5678 \ - -e SAFELINE_API_TOKEN="your_api_token" \ - -e SAFELINE_ADDRESS="https://your.safeline.com" \ - -e LISTEN_PORT=5678 \ - -e LISTEN_ADDRESS="0.0.0.0" \ - chaitin/safeline-mcp:latest +```text +MCP_AUTH_ENABLED=false ``` -#### Method 2: Using docker-compose +No MCP client token is generated or required. -```bash -# 1. Clone repository -git clone https://github.com/chaitin/safeline-mcp.git -cd safeline-mcp - -# 2. Edit docker-compose.yml to configure environment variables -# Example docker-compose.yml: -# version: '3' -# services: -# mcp: -# image: chaitin/safeline-mcp:latest -# container_name: safeline-mcp -# ports: -# - "5678:5678" -# environment: -# - SAFELINE_API_TOKEN=your_api_token -# - SAFELINE_ADDRESS=https://your.safeline.com -# - LISTEN_PORT=5678 -# - LISTEN_ADDRESS=0.0.0.0 - -# 3. Start service -docker compose -f docker-compose.yml up -d -``` +### Authentication enabled -#### Method 3: Using Go +Generate the MCP client Bearer Token once during deployment initialization: ```bash -# 1. Clone repository -git clone https://github.com/chaitin/SafeLine.git -cd safeline-mcp - -# 2. Install dependencies -go mod download - -# 3. Configure config.yaml -cp config.yaml.example config.yaml -# Edit config.yaml with necessary configurations - -# 4. Run service -go run main.go +export MCP_AUTH_ENABLED=true +docker compose run --rm mcp_server auth init ``` -For more API details, please refer to the [API Documentation](https://demo.waf.chaitin.com:9443/swagger/index.html). - -## Tools - -### Application Management - -- **create_application** +The command uses `crypto/rand` to generate a 256-bit token. It displays the plaintext once and stores only its SHA-256 hash and creation time in `state/auth.json` with mode `0600`. A normal service restart only reads that state and never changes or prints the token. -### Rule Management -- **create_blacklist_rule** -- **create_whitelist_rule** +Clients send the generated token on every MCP request: -### Analyze -- **get_attack_events** - -## Development Guide - -The Go API in this project is currently under development, and APIs may change. If you have specific requirements, please submit an Issue for discussion. - -### Directory Structure - -``` -internal/ -├── api/ # API implementation -│ ├── app/ # Application-related APIs -│ │ └── create_application.go -│ └── rule/ # Rule-related APIs -│ └── create_rule.go -└── tools/ # MCP tool implementation - ├── app/ # Application-related tools - │ └── create_application.go - └── rule/ # Rule-related tools - └── create_rule.go +```http +Authorization: Bearer slmcp_... ``` -### Adding New Tools +The built-in listener is plain HTTP. Docker Compose publishes it on +`127.0.0.1` by default so the reusable Bearer Token does not cross a network +in cleartext. For access from another machine, put the endpoint behind a +trusted HTTPS-terminating gateway or keep it on an equivalently protected +private transport. Do not set `MCP_PUBLISH_ADDRESS=0.0.0.0` on an untrusted +network without TLS termination. -1. **Create Tool File** - - Create corresponding directory and file under `internal/tools` - - File name should match tool name - - Use separate file for each tool - - Example: `internal/tools/app/create_application.go` +If authentication is enabled but state has not been initialized, the server refuses to start. Explicit rotation is available locally: -2. **Tool Implementation Template** -```go -package app - -type ToolName struct{} +```bash +docker compose run --rm mcp_server auth rotate +``` -type ToolParams struct { - // Parameter definitions - Param1 string `json:"param1" desc:"parameter description" required:"true"` - Param2 int `json:"param2" desc:"parameter description" required:"false"` -} +After the service restarts, rotation invalidates the previous token. This +static Bearer gate is intended for self-hosted deployment; it is not presented +as a complete MCP OAuth 2.1 implementation. +Run `auth rotate` as a single serialized deployment operation; concurrent +rotation commands are not supported. -type ToolResult struct { - Field1 string `json:"field1"` -} +## Docker deployment -func (t *ToolName) Name() string { - return "tool_name" -} +1. Build the image. The included `.dockerignore` excludes `secrets/` and + `state/` from the build context even if local fallback directories exist: -func (t *ToolName) Description() string { - return "tool description" -} + ```bash + docker compose build + ``` -func (t *ToolName) Validate(params ToolParams) error { - // Parameter validation logic - return nil -} +2. Prefer protected deployment directories outside the source checkout, then + create one SafeLine token file per instance: -func (t *ToolName) Execute(ctx context.Context, params ToolParams) (result ToolResult, err error) { - // Tool execution logic - return result, nil -} -``` + ```bash + install -d -m 700 /etc/safeline-mcp/secrets /var/lib/safeline-mcp + install -m 600 /path/to/production-a.token /etc/safeline-mcp/secrets/production-a.token + install -m 600 /path/to/production-b.token /etc/safeline-mcp/secrets/production-b.token + export SAFELINE_MCP_SECRETS_DIR=/etc/safeline-mcp/secrets + export SAFELINE_MCP_STATE_DIR=/var/lib/safeline-mcp + ``` -3. **[Optional]Create API Implementation** + For local-only development, `./secrets` and `./state` are accepted and are + excluded by both `.gitignore` and `.dockerignore`; create them with the same + permissions before running Compose. -If you need to use some APIs that have not been implemented yet, you need to create corresponding files in the api directory for implementation - - Create same directory structure under `internal/api` - - File name should match tool func - - Example: `internal/api/app/create_application.go` +3. Edit `config.yaml` so each `token_file` matches its file under `/run/secrets`. -**API Implementation Template** -```go -package app +4. If MCP authentication is enabled, run the one-time `auth init` command and save the displayed token. -type RequestType struct { - // Request parameter definitions - Param1 string `json:"param1"` - Param2 int `json:"param2"` -} +5. Start the service: -func APIName(ctx context.Context, req *RequestType) (ResultType, error) { - if req == nil { - return nil, errors.New("request is required") - } + ```bash + docker compose up -d + ``` - var resp api.Response[ResultType] - err := api.Service().Post(ctx, "/api/path", req, &resp) - if err != nil { - return nil, errors.Wrap(err, "failed to execute") - } +Compose refuses to auto-create missing bind-mount paths. Its host port is +loopback-only unless `MCP_PUBLISH_ADDRESS` is explicitly changed. - if resp.Err != nil { - return nil, errors.New(resp.Msg) - } +## Running with Go - return resp.Data, nil -} +```bash +go test ./... +go build -o mcp-server . +MCP_AUTH_ENABLED=false ./mcp-server --config ./config.yaml ``` -4. **Tool Registration (init.go)** - -The tool registration file `internal/tools/init.go` is used to centrally manage all tool registrations - - Register all tools uniformly in the `init()` function - - Use the `AppendTool()` method for registration - - Example: - ```go - // Register create application tool - AppendTool(&app.CreateApp{}) - - // Register create blacklist rule tool - AppendTool(&rule.CreateBlacklistRule{}) - ``` - -### Development Standards - -1. **Naming Conventions** - - Use lowercase letters and underscores for tool names - - File names should match tool names -2. **Directory Organization** - - Divide directories by functional modules (e.g., app, rule, etc.) - - Maintain consistent structure between tools and api directories - - Keep related functionality in the same directory +For an authenticated local deployment, choose a persistent state file before initialization: -3. **Code Standards** - - Follow Go standard code conventions - - Add necessary parameter validation - - Use unified error handling approach - - Add appropriate logging - -4. **Documentation Requirements** - - Provide clear functional description in tool Description - - Add detailed description for parameters - - Update API toolkit documentation in README - -### Example +```bash +export MCP_AUTH_ENABLED=true +export MCP_AUTH_STATE_FILE="$PWD/state/auth.json" +./mcp-server auth init +./mcp-server --config ./config.yaml +``` -Refer to the implementation of the `create_application` tool: -- Tool implementation: `internal/tools/app/create_application.go` -- API implementation: `internal/api/app/create_application.go` +## Tool contract +`get_attack_events` accepts: +| Field | Type | Required | Constraint | +|---|---|---|---| +| `instance_id` | string | Yes | Must match a configured instance | +| `page` | integer | No | Default `1`, minimum `1` | +| `page_size` | integer | No | Default `10`, range `1..100` | +| `ip` | string | No | Source IP, CIDR, or partial-IP filter | +| `host` | string | No | Protected host filter | +| `port` | integer | No | Range `1..65535` | +| `start` | integer | No | Unix milliseconds, non-negative | +| `end` | integer | No | Unix milliseconds, non-negative and not before `start` | +Unknown input fields and fractional integers are rejected by server-side JSON Schema validation. Successful results include both text fallback content and `structuredContent` with: +- `source.instance_id` +- `source.display_name` +- `page` and `page_size` +- `nodes` +- `total` +SafeLine HTTP failures, HTTP-200 error envelopes, unknown instances, and input validation failures are returned as MCP Tool Execution Errors with `result.isError=true`; they are never converted into an empty successful event list. diff --git a/mcp_server/config.yaml b/mcp_server/config.yaml index 87b5b78c..d7d45d77 100644 --- a/mcp_server/config.yaml +++ b/mcp_server/config.yaml @@ -1,27 +1,32 @@ -# Server Configuration server: name: "SafeLine MCP Server" - version: "1.0.0" - # Can be overridden by environment variable LISTEN_PORT + version: "2.0.0" + # LISTEN_ADDRESS and LISTEN_PORT can override these listener values. + host: "127.0.0.1" port: 5678 - # Can be overridden by environment variable LISTEN_ADDRESS - host: "0.0.0.0" - # Can be overridden by environment variable SAFELINE_SECRET - secret: "" # Secret for SSE server -# Logger Configuration + logger: - level: "info" # Log level: debug, info, warn, error - file_path: "" # Log file path - console: true # Whether to output to console - caller: false # Whether to record caller information - development: true # Whether to use development mode + level: "info" + file_path: "" + console: true + caller: false + development: false + +# SafeLine API Tokens remain administrator credentials in this release. Keep +# one token file per instance; the MCP server never returns them to clients. +instances: + - id: "safeline-a" + display_name: "SafeLine A" + base_url: "https://safeline-a.example:9443" + token_file: "/run/secrets/safeline-a.token" + timeout: 30 + debug: false + insecure_skip_verify: true -# API Configuration -api: - # Can be overridden by environment variable SAFELINE_ADDRESS - base_url: "" # API service address - # Can be overridden by environment variable SAFELINE_API_TOKEN - token: "" # Authentication token - timeout: 30 # Timeout in seconds - debug: false # Whether to enable debug mode - insecure_skip_verify: true # Whether to skip certificate verification + - id: "safeline-b" + display_name: "SafeLine B" + base_url: "https://safeline-b.example:9443" + token_file: "/run/secrets/safeline-b.token" + timeout: 30 + debug: false + insecure_skip_verify: true diff --git a/mcp_server/docker-compose.yml b/mcp_server/docker-compose.yml index 2ffdafe1..c9fb3457 100644 --- a/mcp_server/docker-compose.yml +++ b/mcp_server/docker-compose.yml @@ -1,15 +1,36 @@ -version: '3.8' - services: mcp_server: - image: chaitin/safeline-mcp:latest - container_name: mcp_server - restart: always + build: . + image: safeline-mcp:local + container_name: safeline-mcp + restart: unless-stopped ports: - - "5678:5678" + - "${MCP_PUBLISH_ADDRESS:-127.0.0.1}:${LISTEN_PORT:-5678}:${LISTEN_PORT:-5678}" environment: - - SAFELINE_SECRET=your_secret_key # optional, if you want to use secret key to authenticate - - SAFELINE_ADDRESS=https://your_safeline_ip:9443 # required, your SafeLine WAF address - - SAFELINE_API_TOKEN=your_safeline_api_token # required, your SafeLine WAF api token - - LISTEN_PORT=5678 # optional, default is 5678 - - LISTEN_ADDRESS=0.0.0.0 # optional, default is 0.0.0.0 \ No newline at end of file + LISTEN_ADDRESS: "0.0.0.0" + LISTEN_PORT: "${LISTEN_PORT:-5678}" + MCP_AUTH_ENABLED: "${MCP_AUTH_ENABLED:-false}" + MCP_AUTH_STATE_FILE: "/var/lib/safeline-mcp/auth.json" + read_only: true + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp:size=16m,mode=1777 + volumes: + - type: bind + source: ${SAFELINE_MCP_CONFIG_FILE:-./config.yaml} + target: /etc/safeline-mcp/config.yaml + read_only: true + bind: + create_host_path: false + - type: bind + source: ${SAFELINE_MCP_SECRETS_DIR:-./secrets} + target: /run/secrets + read_only: true + bind: + create_host_path: false + - type: bind + source: ${SAFELINE_MCP_STATE_DIR:-./state} + target: /var/lib/safeline-mcp + bind: + create_host_path: false diff --git a/mcp_server/go.mod b/mcp_server/go.mod index 7b836231..ed2a711d 100644 --- a/mcp_server/go.mod +++ b/mcp_server/go.mod @@ -1,13 +1,20 @@ module github.com/chaitin/SafeLine/mcp_server -go 1.24.1 +go 1.25.5 require ( - github.com/mark3labs/mcp-go v0.18.0 + github.com/mark3labs/mcp-go v1.0.0-beta.1 go.uber.org/zap v1.27.0 gopkg.in/yaml.v3 v3.0.1 ) +require ( + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/spf13/cast v1.7.1 // indirect + golang.org/x/text v0.14.0 // indirect +) + require ( github.com/google/uuid v1.6.0 // indirect github.com/mcuadros/go-defaults v1.2.0 diff --git a/mcp_server/go.sum b/mcp_server/go.sum index 5c31060f..d80a0602 100644 --- a/mcp_server/go.sum +++ b/mcp_server/go.sum @@ -1,18 +1,36 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/mark3labs/mcp-go v0.18.0 h1:YuhgIVjNlTG2ZOwmrkORWyPTp0dz1opPEqvsPtySXao= -github.com/mark3labs/mcp-go v0.18.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mark3labs/mcp-go v1.0.0-beta.1 h1:hrPZlyUCfykLdAyO8Yr6Dkk0alPbpeLjkeS0BuNBGK8= +github.com/mark3labs/mcp-go v1.0.0-beta.1/go.mod h1:r2fW4o3wsoJ7IMsx1Wuq5xeP8PRGXPDfNveoGAYbb/s= github.com/mcuadros/go-defaults v1.2.0 h1:FODb8WSf0uGaY8elWJAkoLL0Ri6AlZ1bFlenk56oZtc= github.com/mcuadros/go-defaults v1.2.0/go.mod h1:WEZtHEVIGYVDqkKSWBdWKUVdRyKlMfulPaGDWIVeCWY= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -21,8 +39,13 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mcp_server/internal/api/analyze/get_event_list.go b/mcp_server/internal/api/analyze/get_event_list.go index 29eac72a..51113404 100644 --- a/mcp_server/internal/api/analyze/get_event_list.go +++ b/mcp_server/internal/api/analyze/get_event_list.go @@ -2,20 +2,32 @@ package analyze import ( "context" - "fmt" + "net/url" + "strconv" "github.com/chaitin/SafeLine/mcp_server/internal/api" ) type GetEventListRequest struct { - Page int `json:"page"` - PageSize int `json:"page_size"` - IP string `json:"ip"` - Start int64 `json:"start"` - End int64 `json:"end"` + InstanceID string `json:"instance_id"` + Page int `json:"page"` + PageSize int `json:"page_size"` + IP string `json:"ip"` + Host string `json:"host"` + Port int `json:"port"` + Start int64 `json:"start"` + End int64 `json:"end"` } type GetEventListResponse struct { + Source api.InstanceSource `json:"source"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Nodes []Event `json:"nodes"` + Total int64 `json:"total"` +} + +type eventListData struct { Nodes []Event `json:"nodes"` Total int64 `json:"total"` } @@ -38,10 +50,42 @@ type Event struct { } func GetEventList(ctx context.Context, req *GetEventListRequest) (*GetEventListResponse, error) { - var resp api.Response[GetEventListResponse] - err := api.Service().Get(ctx, fmt.Sprintf("/api/open/events?page=%d&page_size=%d&ip=%s&start=%d&end=%d", req.Page, req.PageSize, req.IP, req.Start, req.End), &resp) + client, err := api.ClientFor(req.InstanceID) if err != nil { return nil, err } - return &resp.Data, nil + + query := make(url.Values) + query.Set("page", strconv.Itoa(req.Page)) + query.Set("page_size", strconv.Itoa(req.PageSize)) + if req.IP != "" { + query.Set("ip", req.IP) + } + if req.Host != "" { + query.Set("host", req.Host) + } + if req.Port != 0 { + query.Set("port", strconv.Itoa(req.Port)) + } + if req.Start != 0 { + query.Set("start", strconv.FormatInt(req.Start, 10)) + } + if req.End != 0 { + query.Set("end", strconv.FormatInt(req.End, 10)) + } + + var resp api.Response[eventListData] + if err := client.GetAttackEvents(ctx, query, &resp); err != nil { + return nil, err + } + if err := resp.Validate(); err != nil { + return nil, err + } + return &GetEventListResponse{ + Source: client.Source(), + Page: req.Page, + PageSize: req.PageSize, + Nodes: resp.Data.Nodes, + Total: resp.Data.Total, + }, nil } diff --git a/mcp_server/internal/api/analyze/get_event_list_test.go b/mcp_server/internal/api/analyze/get_event_list_test.go new file mode 100644 index 00000000..e5355a55 --- /dev/null +++ b/mcp_server/internal/api/analyze/get_event_list_test.go @@ -0,0 +1,96 @@ +package analyze + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/chaitin/SafeLine/mcp_server/internal/api" + "github.com/chaitin/SafeLine/mcp_server/internal/config" + "github.com/chaitin/SafeLine/mcp_server/pkg/logger" +) + +func TestGetEventListUsesCurrentSafeLineQueryAndReturnsSource(t *testing.T) { + if err := logger.Init(&logger.Config{Level: "error"}); err != nil { + t.Fatal(err) + } + tokenPath := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenPath, []byte("instance-token\n"), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/open/events" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("X-SLCE-API-TOKEN"); got != "instance-token" { + t.Errorf("token header = %q", got) + } + wantQuery := map[string]string{ + "page": "2", "page_size": "25", "ip": "10.0.0.0/24", + "host": "example.test&a=b", "port": "9443", "start": "1000", "end": "2000", + } + for key, want := range wantQuery { + if got := r.URL.Query().Get(key); got != want { + t.Errorf("query[%q] = %q, want %q", key, got, want) + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "data": { + "nodes": [{ + "id": 7, + "ip": "10.0.0.8", + "protocol": 1, + "host": "example.test", + "dst_port": 9443, + "updated_at": 1800, + "start_at": 1100, + "end_at": 1700, + "deny_count": 3, + "pass_count": 0, + "finished": true, + "country": "CN", + "province": "Beijing", + "city": "Beijing" + }], + "total": 1 + }, + "err": null, + "msg": "" + }`)) + })) + defer server.Close() + + if err := api.InitInstances([]*config.InstanceConfig{{ + ID: "dev152", DisplayName: "Development 152", BaseURL: server.URL, TokenFile: tokenPath, + }}); err != nil { + t.Fatalf("InitInstances() error = %v", err) + } + + response, err := GetEventList(context.Background(), &GetEventListRequest{ + InstanceID: "dev152", + Page: 2, + PageSize: 25, + IP: "10.0.0.0/24", + Host: "example.test&a=b", + Port: 9443, + Start: 1000, + End: 2000, + }) + if err != nil { + t.Fatalf("GetEventList() error = %v", err) + } + if response.Source.InstanceID != "dev152" || response.Source.DisplayName != "Development 152" { + t.Errorf("source = %#v", response.Source) + } + if response.Page != 2 || response.PageSize != 25 || response.Total != 1 { + t.Errorf("pagination/total = page %d, size %d, total %d", response.Page, response.PageSize, response.Total) + } + if len(response.Nodes) != 1 || response.Nodes[0].ID != 7 || response.Nodes[0].DstPort != 9443 { + t.Fatalf("nodes = %#v", response.Nodes) + } +} diff --git a/mcp_server/internal/api/app/create_application.go b/mcp_server/internal/api/app/create_application.go deleted file mode 100644 index 3d7d0419..00000000 --- a/mcp_server/internal/api/app/create_application.go +++ /dev/null @@ -1,34 +0,0 @@ -package app - -import ( - "context" - - "github.com/chaitin/SafeLine/mcp_server/internal/api" - "github.com/chaitin/SafeLine/mcp_server/pkg/errors" -) - -type CreateAppRequest struct { - ServerNames []string `json:"server_names"` - Ports []string `json:"ports"` - Upstreams []string `json:"upstreams"` - Comment string `json:"comment"` -} - -// CreateApp Create new website or app -func CreateApp(ctx context.Context, req *CreateAppRequest) (int64, error) { - if req == nil { - return 0, errors.New("request is required") - } - - var resp api.Response[int64] - err := api.Service().Post(ctx, "/api/open/site", req, &resp) - if err != nil { - return 0, errors.Wrap(err, "failed to create app") - } - - if resp.Err != nil { - return 0, errors.New(resp.Msg) - } - - return resp.Data, nil -} diff --git a/mcp_server/internal/api/client.go b/mcp_server/internal/api/client.go index 79781725..f528f1fa 100644 --- a/mcp_server/internal/api/client.go +++ b/mcp_server/internal/api/client.go @@ -1,106 +1,76 @@ package api import ( - "bytes" "context" "crypto/tls" "encoding/json" "fmt" "io" "net/http" + "net/url" + "strings" "time" "github.com/chaitin/SafeLine/mcp_server/pkg/errors" "github.com/chaitin/SafeLine/mcp_server/pkg/logger" ) -// Client API client +const ( + maxErrorBodySize = 4 << 10 + maxResponseBodySize = 4 << 20 +) + +// Client is the transport used by a single SafeLine instance. Its request +// method is intentionally unexported; APIClient exposes only approved read-only +// operations. type Client struct { - baseURL string + baseURL *url.URL httpClient *http.Client - headers map[string]string + headers http.Header } -// ClientOption Client configuration options -type ClientOption func(*Client) - -// WithTimeout Set timeout duration -func WithTimeout(timeout time.Duration) ClientOption { - return func(c *Client) { - c.httpClient.Timeout = timeout - } -} - -// WithHeader Set request header -func WithHeader(key, value string) ClientOption { - return func(c *Client) { - c.headers[key] = value +func newClient(baseURL string, timeout time.Duration, insecureSkipVerify bool, token string) (*Client, error) { + parsedURL, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil { + return nil, errors.Wrap(err, "parse base_url failed") } -} - -// WithBaseURL Set base URL -func WithBaseURL(baseURL string) ClientOption { - return func(c *Client) { - c.baseURL = baseURL + if (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" { + return nil, errors.New("base_url must be an absolute http or https URL") } -} - -// WithInsecureSkipVerify Set whether to skip certificate verification -func WithInsecureSkipVerify(skip bool) ClientOption { - return func(c *Client) { - if transport, ok := c.httpClient.Transport.(*http.Transport); ok { - if transport.TLSClientConfig == nil { - transport.TLSClientConfig = &tls.Config{} - } - transport.TLSClientConfig.InsecureSkipVerify = skip - } + if parsedURL.RawQuery != "" || parsedURL.Fragment != "" { + return nil, errors.New("base_url must not contain a query or fragment") } -} + parsedURL.Path = strings.TrimRight(parsedURL.Path, "/") -// NewClient Create new API client -func NewClient(opts ...ClientOption) *Client { transport := &http.Transport{ - TLSClientConfig: &tls.Config{}, + TLSClientConfig: &tls.Config{InsecureSkipVerify: insecureSkipVerify}, // #nosec G402 -- deployment-controlled compatibility setting } - - c := &Client{ + client := &Client{ + baseURL: parsedURL, httpClient: &http.Client{ - Timeout: 30 * time.Second, + Timeout: timeout, Transport: transport, }, - headers: make(map[string]string), + headers: make(http.Header), } - - for _, opt := range opts { - opt(c) - } - - return c + client.headers.Set("Accept", "application/json") + client.headers.Set("User-Agent", "SafeLine-MCP/1.0") + client.headers.Set("X-SLCE-API-TOKEN", token) + return client, nil } -// Request Send request -func (c *Client) Request(ctx context.Context, method, path string, body interface{}, result interface{}) error { - reqURL := fmt.Sprintf("%s%s", c.baseURL, path) +func (c *Client) get(ctx context.Context, path string, query url.Values, result any) error { + endpoint := *c.baseURL + endpoint.Path = strings.TrimRight(endpoint.Path, "/") + path + endpoint.RawPath = "" + endpoint.RawQuery = query.Encode() - var bodyReader io.Reader - if body != nil { - bodyBytes, err := json.Marshal(body) - if err != nil { - return errors.Wrap(err, "marshal request body failed") - } - bodyReader = bytes.NewReader(bodyBytes) - } - logger.With("url", reqURL).Debug("request url") - req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader) + logger.With("url", endpoint.String()).Debug("request url") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { return errors.Wrap(err, "create request failed") } - - // Set common headers - req.Header.Set("Content-Type", "application/json") - for k, v := range c.headers { - req.Header.Set(k, v) - } + req.Header = c.headers.Clone() resp, err := c.httpClient.Do(req) if err != nil { @@ -108,50 +78,38 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac } defer resp.Body.Close() - // Read response body - respBody, err := io.ReadAll(resp.Body) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodySize+1)) if err != nil { return errors.Wrap(err, "read response body failed") } - - // Check status code - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return errors.New(fmt.Sprintf("request failed with status %d: %s", resp.StatusCode, string(respBody))) + if len(respBody) > maxResponseBodySize { + return errors.New("SafeLine response body exceeds the 4 MiB limit") } - // Parse response - if result != nil { - if err := json.Unmarshal(respBody, result); err == nil { - return nil - } - var respData map[string]interface{} - if err := json.Unmarshal(respBody, &respData); err != nil { - return errors.Wrap(err, "unmarshal response failed") + var envelope struct { + Err any `json:"err"` + Msg string `json:"msg"` + } + envelopeDecoded := json.Unmarshal(respBody, &envelope) == nil + if envelopeDecoded { + if err := responseError(envelope.Err, envelope.Msg); err != nil { + return err } - if respData["err"] != nil || respData["msg"] != nil { - return errors.New(respData["msg"].(string)) + } + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + body := strings.TrimSpace(string(respBody)) + if len(body) > maxErrorBodySize { + body = body[:maxErrorBodySize] + "..." } + return errors.New(fmt.Sprintf("SafeLine request failed with status %d: %s", resp.StatusCode, body)) } + if result == nil { + return nil + } + if err := json.Unmarshal(respBody, result); err != nil { + return errors.Wrap(err, "unmarshal response failed") + } return nil } - -// Get Send GET request -func (c *Client) Get(ctx context.Context, path string, result interface{}) error { - return c.Request(ctx, http.MethodGet, path, nil, result) -} - -// Post Send POST request -func (c *Client) Post(ctx context.Context, path string, body interface{}, result interface{}) error { - return c.Request(ctx, http.MethodPost, path, body, result) -} - -// Put Send PUT request -func (c *Client) Put(ctx context.Context, path string, body interface{}, result interface{}) error { - return c.Request(ctx, http.MethodPut, path, body, result) -} - -// Delete Send DELETE request -func (c *Client) Delete(ctx context.Context, path string, result interface{}) error { - return c.Request(ctx, http.MethodDelete, path, nil, result) -} diff --git a/mcp_server/internal/api/response.go b/mcp_server/internal/api/response.go index bfa2993b..98a2237c 100644 --- a/mcp_server/internal/api/response.go +++ b/mcp_server/internal/api/response.go @@ -1,11 +1,45 @@ package api -// Response Common API response structure +import ( + "fmt" + "strings" +) + +// Response is the envelope returned by SafeLine's management API. SafeLine can +// report an application error with HTTP 200, so callers must not infer success +// from the HTTP status alone. type Response[T any] struct { - // Response data - Data T `json:"data"` - // Error message - Err any `json:"err"` - // Prompt message - Msg string `json:"msg"` + Data T `json:"data"` + Err any `json:"err"` + Msg string `json:"msg"` +} + +// ResponseError represents an error carried in a SafeLine response envelope. +type ResponseError struct { + Code any + Message string +} + +func (e *ResponseError) Error() string { + message := strings.TrimSpace(e.Message) + if message == "" { + message = "request failed" + } + return fmt.Sprintf("SafeLine API error %v: %s", e.Code, message) +} + +// Validate returns an error whenever the envelope contains a non-empty err +// value, including responses that otherwise decoded successfully. +func (r *Response[T]) Validate() error { + return responseError(r.Err, r.Msg) +} + +func responseError(code any, message string) error { + if code == nil { + return nil + } + if text, ok := code.(string); ok && strings.TrimSpace(text) == "" { + return nil + } + return &ResponseError{Code: code, Message: message} } diff --git a/mcp_server/internal/api/rule/create_rule.go b/mcp_server/internal/api/rule/create_rule.go deleted file mode 100644 index 6eb88439..00000000 --- a/mcp_server/internal/api/rule/create_rule.go +++ /dev/null @@ -1,35 +0,0 @@ -package rule - -import ( - "context" - - "github.com/chaitin/SafeLine/mcp_server/internal/api" - "github.com/chaitin/SafeLine/mcp_server/pkg/errors" -) - -type CreateRuleRequest struct { - Name string `json:"name"` - IP []string `json:"ip"` - IsEnabled bool `json:"is_enabled"` - Pattern [][]api.Pattern `json:"pattern"` - Action int `json:"action"` -} - -// CreateRule Create new rule -func CreateRule(ctx context.Context, req *CreateRuleRequest) (int64, error) { - if req == nil { - return 0, errors.New("request is required") - } - - var resp api.Response[int64] - err := api.Service().Post(ctx, "/api/open/policy", req, &resp) - if err != nil { - return 0, errors.Wrap(err, "failed to create policy rule") - } - - if resp.Err != nil { - return 0, errors.New(resp.Msg) - } - - return resp.Data, nil -} diff --git a/mcp_server/internal/api/service.go b/mcp_server/internal/api/service.go index 2c0a62a6..1d14fb77 100644 --- a/mcp_server/internal/api/service.go +++ b/mcp_server/internal/api/service.go @@ -2,6 +2,12 @@ package api import ( "context" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" "sync" "time" @@ -10,91 +16,197 @@ import ( "github.com/chaitin/SafeLine/mcp_server/pkg/logger" ) -// APIClient API client implementation +const attackEventsPath = "/api/open/events" +const maxTokenFileSize = 16 << 10 + +// InstanceSource is safe to include in tool output and identifies the exact +// SafeLine instance from which the data was read. +type InstanceSource struct { + InstanceID string `json:"instance_id"` + DisplayName string `json:"display_name"` +} + +// APIClient is an independently authenticated client for one SafeLine instance. type APIClient struct { client *Client - config *config.APIConfig + source InstanceSource } -var ( - instance *APIClient - once sync.Once -) +// ClientRegistry maps stable instance IDs to independently configured clients. +// Credentials are loaded once per instance and are never shared across entries. +type ClientRegistry struct { + clients map[string]*APIClient + order []string +} + +// NewClientRegistry constructs a registry without mutating global state. +func NewClientRegistry(configs []*config.InstanceConfig) (*ClientRegistry, error) { + if len(configs) == 0 { + return nil, errors.New("at least one SafeLine instance is required") + } + + registry := &ClientRegistry{ + clients: make(map[string]*APIClient, len(configs)), + order: make([]string, 0, len(configs)), + } + for index, cfg := range configs { + if cfg == nil { + return nil, errors.New(fmt.Sprintf("instances[%d] is required", index)) + } + if strings.TrimSpace(cfg.ID) == "" { + return nil, errors.New(fmt.Sprintf("instances[%d].id is required", index)) + } + if _, exists := registry.clients[cfg.ID]; exists { + return nil, errors.New(fmt.Sprintf("duplicate instance id %q", cfg.ID)) + } -// Init Initialize API service -func Init(cfg *config.APIConfig) error { - var err error - once.Do(func() { - instance, err = newAPIClient(cfg) + client, err := newAPIClient(cfg) if err != nil { - logger.With("error", err).Error("failed to initialize API service") - return + return nil, errors.Wrap(err, fmt.Sprintf("initialize instance %q failed", cfg.ID)) } - logger.Info("API service initialized successfully") - }) - return err + registry.clients[cfg.ID] = client + registry.order = append(registry.order, cfg.ID) + } + return registry, nil +} + +// Get returns the client for instanceID without falling back to another +// instance. This prevents a typo from accidentally querying with the wrong +// instance credential. +func (r *ClientRegistry) Get(instanceID string) (*APIClient, error) { + if r == nil { + return nil, errors.New("API service not initialized") + } + client, ok := r.clients[instanceID] + if !ok { + return nil, errors.New(fmt.Sprintf("unknown SafeLine instance %q", instanceID)) + } + return client, nil } -// Service Get API service instance -func Service() *APIClient { - if instance == nil { - logger.Error("API service not initialized") - panic("API service not initialized") +// Sources lists configured instances without exposing addresses or credentials. +func (r *ClientRegistry) Sources() []InstanceSource { + if r == nil { + return nil + } + sources := make([]InstanceSource, 0, len(r.order)) + for _, id := range r.order { + sources = append(sources, r.clients[id].source) } - return instance + return sources } -// newAPIClient Create new API client -func newAPIClient(config *config.APIConfig) (*APIClient, error) { - if config == nil { - return nil, errors.New("config is required") +var ( + registryMu sync.RWMutex + activeRegistry *ClientRegistry +) + +// InitInstances initializes the runtime registry used by MCP tools. +func InitInstances(configs []*config.InstanceConfig) error { + registry, err := NewClientRegistry(configs) + if err != nil { + logger.With("error", err).Error("failed to initialize API service") + return err } - if config.BaseURL == "" { + registryMu.Lock() + activeRegistry = registry + registryMu.Unlock() + logger.With("instances", len(configs)).Info("API service initialized successfully") + return nil +} + +// ClientFor returns the explicitly selected instance client. +func ClientFor(instanceID string) (*APIClient, error) { + registryMu.RLock() + registry := activeRegistry + registryMu.RUnlock() + return registry.Get(instanceID) +} + +func newAPIClient(cfg *config.InstanceConfig) (*APIClient, error) { + if strings.TrimSpace(cfg.BaseURL) == "" { return nil, errors.New("base_url is required") } + token, err := loadToken(cfg) + if err != nil { + return nil, err + } timeout := 30 - if config.Timeout > 0 { - timeout = config.Timeout + if cfg.Timeout > 0 { + timeout = cfg.Timeout } - - opts := []ClientOption{ - WithBaseURL(config.BaseURL), - WithTimeout(time.Duration(timeout) * time.Second), - WithHeader("User-Agent", "SafeLine-MCP/1.0"), - WithInsecureSkipVerify(config.InsecureSkipVerify), + client, err := newClient(cfg.BaseURL, time.Duration(timeout)*time.Second, cfg.InsecureSkipVerify, token) + if err != nil { + return nil, err } - // If token is configured, add authentication header - if config.Token != "" { - opts = append(opts, WithHeader("X-SLCE-API-TOKEN", config.Token)) + displayName := strings.TrimSpace(cfg.DisplayName) + if displayName == "" { + displayName = cfg.ID } - - client := NewClient(opts...) - return &APIClient{ client: client, - config: config, + source: InstanceSource{InstanceID: cfg.ID, DisplayName: displayName}, }, nil } -// Post Send POST request -func (c *APIClient) Post(ctx context.Context, path string, body interface{}, result interface{}) error { - return c.client.Request(ctx, "POST", path, body, result) -} +func loadToken(cfg *config.InstanceConfig) (string, error) { + if cfg.TokenFile != "" { + dirInfo, err := os.Lstat(filepath.Dir(cfg.TokenFile)) + if err != nil { + return "", errors.Wrap(err, "inspect token_file directory failed") + } + if dirInfo.Mode()&os.ModeSymlink != 0 || !dirInfo.IsDir() || dirInfo.Mode().Perm()&0o022 != 0 { + return "", errors.New("token_file directory must be a real directory that is not group or world writable") + } + + pathInfo, err := os.Lstat(cfg.TokenFile) + if err != nil { + return "", errors.Wrap(err, "inspect token_file failed") + } + if pathInfo.Mode()&os.ModeSymlink != 0 || !pathInfo.Mode().IsRegular() || pathInfo.Mode().Perm()&0o077 != 0 { + return "", errors.New("token_file must be a regular file with no group or other permissions") + } + + file, err := os.Open(cfg.TokenFile) + if err != nil { + return "", errors.Wrap(err, "open token_file failed") + } + defer file.Close() + openedInfo, err := file.Stat() + if err != nil { + return "", errors.Wrap(err, "inspect opened token_file failed") + } + if !os.SameFile(pathInfo, openedInfo) || !openedInfo.Mode().IsRegular() || openedInfo.Mode().Perm()&0o077 != 0 { + return "", errors.New("token_file changed while opening") + } + + contents, err := io.ReadAll(io.LimitReader(file, maxTokenFileSize+1)) + if err != nil { + return "", errors.Wrap(err, "read token_file failed") + } + if len(contents) > maxTokenFileSize { + return "", errors.New("token_file exceeds the maximum size") + } + token := strings.TrimSpace(string(contents)) + if token == "" { + return "", errors.New("token_file is empty") + } + return token, nil + } -// Get Send GET request -func (c *APIClient) Get(ctx context.Context, path string, result interface{}) error { - return c.client.Request(ctx, "GET", path, nil, result) + return "", errors.New("token_file is required") } -// Put Send PUT request -func (c *APIClient) Put(ctx context.Context, path string, body interface{}, result interface{}) error { - return c.client.Request(ctx, "PUT", path, body, result) +// Source returns the non-sensitive identity included in tool results. +func (c *APIClient) Source() InstanceSource { + return c.source } -// Delete Send DELETE request -func (c *APIClient) Delete(ctx context.Context, path string, result interface{}) error { - return c.client.Request(ctx, "DELETE", path, nil, result) +// GetAttackEvents is the only SafeLine management request exposed by the +// client. The path and HTTP method are fixed rather than supplied by a tool. +func (c *APIClient) GetAttackEvents(ctx context.Context, query url.Values, result any) error { + return c.client.get(ctx, attackEventsPath, query, result) } diff --git a/mcp_server/internal/api/service_test.go b/mcp_server/internal/api/service_test.go new file mode 100644 index 00000000..01270270 --- /dev/null +++ b/mcp_server/internal/api/service_test.go @@ -0,0 +1,186 @@ +package api + +import ( + "context" + stderrors "errors" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/chaitin/SafeLine/mcp_server/internal/config" + "github.com/chaitin/SafeLine/mcp_server/pkg/logger" +) + +func TestMain(m *testing.M) { + _ = logger.Init(&logger.Config{Level: "error"}) + os.Exit(m.Run()) +} + +func writeTokenFile(t *testing.T, token string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "api_token") + if err := os.WriteFile(path, []byte(token+"\n"), 0o600); err != nil { + t.Fatalf("write token file: %v", err) + } + return path +} + +func TestClientRegistryKeepsInstanceCredentialsIsolated(t *testing.T) { + type observedRequest struct { + method string + path string + token string + query url.Values + } + requests := make(chan observedRequest, 2) + newServer := func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- observedRequest{ + method: r.Method, + path: r.URL.Path, + token: r.Header.Get("X-SLCE-API-TOKEN"), + query: r.URL.Query(), + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"nodes":[],"total":0},"err":null,"msg":""}`)) + })) + } + one := newServer() + defer one.Close() + two := newServer() + defer two.Close() + + registry, err := NewClientRegistry([]*config.InstanceConfig{ + {ID: "dev152", DisplayName: "Development 152", BaseURL: one.URL, TokenFile: writeTokenFile(t, "token-152")}, + {ID: "dev180", DisplayName: "Development 180", BaseURL: two.URL, TokenFile: writeTokenFile(t, "token-180")}, + }) + if err != nil { + t.Fatalf("NewClientRegistry() error = %v", err) + } + + for _, id := range []string{"dev152", "dev180"} { + client, err := registry.Get(id) + if err != nil { + t.Fatalf("Get(%q) error = %v", id, err) + } + var result Response[map[string]any] + if err := client.GetAttackEvents(context.Background(), url.Values{"page": {"1"}}, &result); err != nil { + t.Fatalf("GetAttackEvents(%q) error = %v", id, err) + } + } + + seenTokens := map[string]bool{} + for range 2 { + request := <-requests + if request.method != http.MethodGet || request.path != attackEventsPath { + t.Errorf("request = %s %s, want GET %s", request.method, request.path, attackEventsPath) + } + if request.query.Get("page") != "1" { + t.Errorf("page query = %q", request.query.Get("page")) + } + seenTokens[request.token] = true + } + if !seenTokens["token-152"] || !seenTokens["token-180"] || len(seenTokens) != 2 { + t.Fatalf("observed tokens = %#v, want isolated instance tokens", seenTokens) + } + + sources := registry.Sources() + if len(sources) != 2 || sources[0].InstanceID != "dev152" || sources[1].InstanceID != "dev180" { + t.Fatalf("Sources() = %#v", sources) + } +} + +func TestClientReturnsHTTP200EnvelopeError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{},"err":"token_invalid","msg":"invalid API token"}`)) + })) + defer server.Close() + + client, err := newAPIClient(&config.InstanceConfig{ + ID: "broken", BaseURL: server.URL, TokenFile: writeTokenFile(t, "bad-token"), + }) + if err != nil { + t.Fatalf("newAPIClient() error = %v", err) + } + err = client.GetAttackEvents(context.Background(), nil, &Response[map[string]any]{}) + var responseErr *ResponseError + if !stderrors.As(err, &responseErr) { + t.Fatalf("GetAttackEvents() error = %T %v, want *ResponseError", err, err) + } + if responseErr.Code != "token_invalid" || responseErr.Message != "invalid API token" { + t.Fatalf("ResponseError = %#v", responseErr) + } +} + +func TestClientRegistryRejectsUnknownInstanceAndEmptyToken(t *testing.T) { + registry, err := NewClientRegistry([]*config.InstanceConfig{{ + ID: "known", BaseURL: "https://known.example.test", TokenFile: writeTokenFile(t, "token"), + }}) + if err != nil { + t.Fatalf("NewClientRegistry() error = %v", err) + } + if _, err := registry.Get("missing"); err == nil || !strings.Contains(err.Error(), "unknown SafeLine instance") { + t.Fatalf("Get(missing) error = %v", err) + } + + _, err = NewClientRegistry([]*config.InstanceConfig{{ + ID: "empty", BaseURL: "https://empty.example.test", TokenFile: writeTokenFile(t, ""), + }}) + if err == nil || !strings.Contains(err.Error(), "token_file is empty") { + t.Fatalf("empty token error = %v", err) + } +} + +func TestClientRejectsUnsafeTokenFiles(t *testing.T) { + dir := t.TempDir() + tokenPath := filepath.Join(dir, "api_token") + if err := os.WriteFile(tokenPath, []byte("token"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(tokenPath, 0o644); err != nil { + t.Fatal(err) + } + _, err := NewClientRegistry([]*config.InstanceConfig{{ + ID: "broad-mode", BaseURL: "https://example.test", TokenFile: tokenPath, + }}) + if err == nil || !strings.Contains(err.Error(), "no group or other permissions") { + t.Fatalf("broad token mode error = %v", err) + } + + if err := os.Chmod(tokenPath, 0o600); err != nil { + t.Fatal(err) + } + symlinkPath := filepath.Join(dir, "token-link") + if err := os.Symlink(tokenPath, symlinkPath); err != nil { + t.Fatal(err) + } + _, err = NewClientRegistry([]*config.InstanceConfig{{ + ID: "symlink", BaseURL: "https://example.test", TokenFile: symlinkPath, + }}) + if err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("symlink token error = %v", err) + } +} + +func TestClientLimitsSafeLineResponseBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(strings.Repeat("x", maxResponseBodySize+1))) + })) + defer server.Close() + + client, err := newAPIClient(&config.InstanceConfig{ + ID: "oversized", BaseURL: server.URL, TokenFile: writeTokenFile(t, "token"), + }) + if err != nil { + t.Fatal(err) + } + err = client.GetAttackEvents(context.Background(), nil, &Response[map[string]any]{}) + if err == nil || !strings.Contains(err.Error(), "4 MiB limit") { + t.Fatalf("oversized response error = %v", err) + } +} diff --git a/mcp_server/internal/api/types.go b/mcp_server/internal/api/types.go deleted file mode 100644 index 5113ce2a..00000000 --- a/mcp_server/internal/api/types.go +++ /dev/null @@ -1,50 +0,0 @@ -package api - -type PolicyRuleAction int - -const ( - PolicyRuleActionAllow PolicyRuleAction = iota - PolicyRuleActionDeny - PolicyRuleActionMax -) - -type Key = string - -const ( - KeySrcIP Key = "src_ip" - KeyURI Key = "uri" - KeyURINoQuery Key = "uri_no_query" - KeyHost Key = "host" - KeyMethod Key = "method" - KeyReqHeader Key = "req_header" - KeyReqBody Key = "req_body" - KeyGetParam Key = "get_param" - KeyPostParam Key = "post_param" -) - -type Op = string - -const ( - OpEq Op = "eq" // equal - OpNotEq Op = "not_eq" // not equal - OpMatch Op = "match" // match - OpCIDR Op = "cidr" // cidr - OpHas Op = "has" // has - OpNotHas Op = "not_has" // not has - OpPrefix Op = "prefix" // prefix - OpRe Op = "re" // regex - OpIn Op = "in" // in - OpNotIn Op = "not_in" // not in - OpNotCIDR Op = "not_cidr" // not cidr - OpExist Op = "exist" // exist - OpNotExist Op = "not_exist" // not exist - OpGeoEq Op = "geo_eq" // geo equal - OpGeoNotEq Op = "geo_not_eq" // geo not equal -) - -type Pattern struct { - K Key `json:"k"` - Op Op `json:"op"` - V []string `json:"v"` - SubK string `json:"sub_k"` -} diff --git a/mcp_server/internal/auth/auth.go b/mcp_server/internal/auth/auth.go new file mode 100644 index 00000000..002cbe56 --- /dev/null +++ b/mcp_server/internal/auth/auth.go @@ -0,0 +1,292 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + EnabledEnv = "MCP_AUTH_ENABLED" + StateFileEnv = "MCP_AUTH_STATE_FILE" + + DefaultStateFile = "/var/lib/safeline-mcp/auth.json" + stateVersion = 1 + tokenBytes = 32 + maxStateFileSize = 16 << 10 +) + +var ErrInvalidState = errors.New("invalid MCP authentication state") + +type state struct { + Version int `json:"version"` + TokenHash string `json:"token_hash"` + CreatedAt time.Time `json:"created_at"` +} + +// Authenticator verifies the deployment-scoped bearer token without retaining +// the plaintext token after initialization. +type Authenticator struct { + tokenHash [sha256.Size]byte +} + +func EnabledFromEnv() (bool, error) { + raw := strings.TrimSpace(os.Getenv(EnabledEnv)) + if raw == "" { + return false, nil + } + + enabled, err := strconv.ParseBool(raw) + if err != nil { + return false, fmt.Errorf("parse %s: %w", EnabledEnv, err) + } + return enabled, nil +} + +func StateFileFromEnv() string { + if path := strings.TrimSpace(os.Getenv(StateFileEnv)); path != "" { + return path + } + return DefaultStateFile +} + +// Initialize creates the authentication state once. The returned plaintext +// token must be shown to the deployer exactly once and is never written to disk. +func Initialize(path string) (token string, created bool, err error) { + if strings.TrimSpace(path) == "" { + return "", false, errors.New("authentication state file path is required") + } + + if _, err := os.Lstat(path); err == nil { + if _, err := Load(path); err != nil { + return "", false, fmt.Errorf("existing authentication state is invalid: %w", err) + } + return "", false, nil + } else if !errors.Is(err, os.ErrNotExist) { + return "", false, fmt.Errorf("check authentication state: %w", err) + } + + token, s, err := newState() + if err != nil { + return "", false, err + } + created, err = writeState(path, s, false) + if err != nil { + return "", false, err + } + if !created { + // Another initializer won the no-replace install race. Validate its + // fully synced state and never expose this process's discarded token. + if _, err := Load(path); err != nil { + return "", false, fmt.Errorf("concurrently created authentication state is invalid: %w", err) + } + return "", false, nil + } + return token, true, nil +} + +// Rotate replaces an existing, valid token state. Initialization is an +// explicit separate operation so a typo cannot overwrite an unrelated path. +func Rotate(path string) (string, error) { + if _, err := Load(path); err != nil { + return "", fmt.Errorf("load existing authentication state before rotation: %w", err) + } + token, s, err := newState() + if err != nil { + return "", err + } + if _, err := writeState(path, s, true); err != nil { + return "", err + } + return token, nil +} + +func newState() (token string, s state, err error) { + token, err = generateToken() + if err != nil { + return "", state{}, err + } + + digest := sha256.Sum256([]byte(token)) + now := time.Now().UTC() + s = state{ + Version: stateVersion, + TokenHash: hex.EncodeToString(digest[:]), + CreatedAt: now, + } + return token, s, nil +} + +func generateToken() (string, error) { + random := make([]byte, tokenBytes) + if _, err := rand.Read(random); err != nil { + return "", fmt.Errorf("generate bearer token: %w", err) + } + return "slmcp_" + base64.RawURLEncoding.EncodeToString(random), nil +} + +// writeState writes a fully synced temporary file before installation. When +// replace is false, a hard-link install provides atomic create-if-absent +// semantics: concurrent initializers cannot overwrite the winning state. +func writeState(path string, s state, replace bool) (bool, error) { + if strings.TrimSpace(path) == "" { + return false, errors.New("authentication state file path is required") + } + + dir := filepath.Dir(path) + info, err := os.Lstat(dir) + if errors.Is(err, os.ErrNotExist) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return false, fmt.Errorf("create authentication state directory: %w", err) + } + if err := os.Chmod(dir, 0o700); err != nil { + return false, fmt.Errorf("secure authentication state directory: %w", err) + } + info, err = os.Lstat(dir) + } else if err != nil { + return false, fmt.Errorf("inspect authentication state directory: %w", err) + } + if err != nil { + return false, fmt.Errorf("inspect authentication state directory: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return false, errors.New("authentication state directory path must be a real directory") + } + if info.Mode().Perm()&0o077 != 0 { + return false, errors.New("authentication state directory must have no group or other permissions") + } + + tmp, err := os.CreateTemp(dir, ".auth-*.tmp") + if err != nil { + return false, fmt.Errorf("create authentication state: %w", err) + } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + }() + + if err := tmp.Chmod(0o600); err != nil { + return false, fmt.Errorf("secure authentication state: %w", err) + } + encoder := json.NewEncoder(tmp) + encoder.SetIndent("", " ") + if err := encoder.Encode(s); err != nil { + return false, fmt.Errorf("encode authentication state: %w", err) + } + if err := tmp.Sync(); err != nil { + return false, fmt.Errorf("sync authentication state: %w", err) + } + if err := tmp.Close(); err != nil { + return false, fmt.Errorf("close authentication state: %w", err) + } + + if replace { + if err := os.Rename(tmpPath, path); err != nil { + return false, fmt.Errorf("install authentication state: %w", err) + } + return true, nil + } + + if err := os.Link(tmpPath, path); err != nil { + if errors.Is(err, os.ErrExist) { + return false, nil + } + return false, fmt.Errorf("install authentication state without replacement: %w", err) + } + return true, nil +} + +func Load(path string) (*Authenticator, error) { + dirInfo, err := os.Lstat(filepath.Dir(path)) + if err != nil { + return nil, fmt.Errorf("inspect authentication state directory: %w", err) + } + if dirInfo.Mode()&os.ModeSymlink != 0 || !dirInfo.IsDir() || dirInfo.Mode().Perm()&0o077 != 0 { + return nil, fmt.Errorf("%w: state directory must be a real directory with no group or other permissions", ErrInvalidState) + } + + pathInfo, err := os.Lstat(path) + if err != nil { + return nil, fmt.Errorf("inspect authentication state: %w", err) + } + if pathInfo.Mode()&os.ModeSymlink != 0 || !pathInfo.Mode().IsRegular() || pathInfo.Mode().Perm()&0o077 != 0 { + return nil, fmt.Errorf("%w: state file must be a regular file with no group or other permissions", ErrInvalidState) + } + + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open authentication state: %w", err) + } + defer file.Close() + openedInfo, err := file.Stat() + if err != nil { + return nil, fmt.Errorf("inspect opened authentication state: %w", err) + } + if !os.SameFile(pathInfo, openedInfo) || !openedInfo.Mode().IsRegular() || openedInfo.Mode().Perm()&0o077 != 0 { + return nil, fmt.Errorf("%w: authentication state changed while opening", ErrInvalidState) + } + contents, err := io.ReadAll(io.LimitReader(file, maxStateFileSize+1)) + if err != nil { + return nil, fmt.Errorf("read authentication state: %w", err) + } + if len(contents) > maxStateFileSize { + return nil, fmt.Errorf("%w: state file exceeds the 16 KiB limit", ErrInvalidState) + } + + var s state + if err := json.Unmarshal(contents, &s); err != nil { + return nil, fmt.Errorf("%w: decode state: %v", ErrInvalidState, err) + } + if s.Version != stateVersion || s.CreatedAt.IsZero() { + return nil, ErrInvalidState + } + digest, err := hex.DecodeString(s.TokenHash) + if err != nil || len(digest) != sha256.Size { + return nil, ErrInvalidState + } + + a := &Authenticator{} + copy(a.tokenHash[:], digest) + return a, nil +} + +func (a *Authenticator) Verify(token string) bool { + if a == nil || token == "" { + return false + } + digest := sha256.Sum256([]byte(token)) + return subtle.ConstantTimeCompare(a.tokenHash[:], digest[:]) == 1 +} + +func (a *Authenticator) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok := bearerToken(r.Header.Get("Authorization")) + if !ok || !a.Verify(token) { + w.Header().Set("WWW-Authenticate", `Bearer realm="safeline-mcp"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func bearerToken(header string) (string, bool) { + parts := strings.Fields(header) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" { + return "", false + } + return parts[1], true +} diff --git a/mcp_server/internal/auth/auth_test.go b/mcp_server/internal/auth/auth_test.go new file mode 100644 index 00000000..028436aa --- /dev/null +++ b/mcp_server/internal/auth/auth_test.go @@ -0,0 +1,259 @@ +package auth + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func TestInitializePersistsOnlyHashAndKeepsTokenAcrossLoads(t *testing.T) { + path := filepath.Join(t.TempDir(), "state", "auth.json") + + token, created, err := Initialize(path) + if err != nil { + t.Fatalf("Initialize() error = %v", err) + } + if !created || !strings.HasPrefix(token, "slmcp_") { + t.Fatalf("Initialize() = (%q, %v), want newly generated prefixed token", token, created) + } + + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(contents), token) { + t.Fatal("authentication state contains the plaintext token") + } + var persisted state + if err := json.Unmarshal(contents, &persisted); err != nil { + t.Fatal(err) + } + if persisted.TokenHash == "" { + t.Fatal("authentication state does not contain a token hash") + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("state mode = %o, want 600", got) + } + + a, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if !a.Verify(token) || a.Verify(token+"wrong") { + t.Fatal("loaded authenticator did not verify exactly the initialized token") + } + + second, secondCreated, err := Initialize(path) + if err != nil { + t.Fatalf("second Initialize() error = %v", err) + } + if second != "" || secondCreated { + t.Fatalf("second Initialize() = (%q, %v), want no rotation", second, secondCreated) + } +} + +func TestRotateInvalidatesPreviousToken(t *testing.T) { + path := filepath.Join(t.TempDir(), "state", "auth.json") + oldToken, _, err := Initialize(path) + if err != nil { + t.Fatal(err) + } + newToken, err := Rotate(path) + if err != nil { + t.Fatal(err) + } + if oldToken == newToken { + t.Fatal("Rotate() returned the previous token") + } + + a, err := Load(path) + if err != nil { + t.Fatal(err) + } + if a.Verify(oldToken) || !a.Verify(newToken) { + t.Fatal("rotation did not replace the accepted token") + } +} + +func TestInitializeIsAtomicAcrossConcurrentCallers(t *testing.T) { + path := filepath.Join(t.TempDir(), "state", "auth.json") + const callers = 64 + + type initializeResult struct { + token string + created bool + err error + } + start := make(chan struct{}) + results := make(chan initializeResult, callers) + var group sync.WaitGroup + for range callers { + group.Add(1) + go func() { + defer group.Done() + <-start + token, created, err := Initialize(path) + results <- initializeResult{token: token, created: created, err: err} + }() + } + close(start) + group.Wait() + close(results) + + var winningToken string + createdCount := 0 + for result := range results { + if result.err != nil { + t.Fatalf("Initialize() error = %v", result.err) + } + if result.created { + createdCount++ + winningToken = result.token + } else if result.token != "" { + t.Fatalf("losing initializer exposed token %q", result.token) + } + } + if createdCount != 1 { + t.Fatalf("created count = %d, want exactly 1", createdCount) + } + a, err := Load(path) + if err != nil { + t.Fatal(err) + } + if !a.Verify(winningToken) { + t.Fatal("persisted state does not accept the one exposed token") + } +} + +func TestInitializeRejectsExistingInvalidState(t *testing.T) { + dir := filepath.Join(t.TempDir(), "state") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "auth.json") + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + if token, created, err := Initialize(path); err == nil { + t.Fatalf("Initialize() = (%q, %v, nil), want invalid-state error", token, created) + } +} + +func TestRotateRequiresExistingValidState(t *testing.T) { + dir := filepath.Join(t.TempDir(), "state") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "missing.json") + if token, err := Rotate(path); err == nil { + t.Fatalf("Rotate() = (%q, nil), want missing-state error", token) + } + + if err := os.WriteFile(path, []byte("not-json"), 0o600); err != nil { + t.Fatal(err) + } + if token, err := Rotate(path); err == nil { + t.Fatalf("Rotate(invalid) = (%q, nil), want invalid-state error", token) + } +} + +func TestLoadRejectsOverlyBroadStatePermissions(t *testing.T) { + path := filepath.Join(t.TempDir(), "state", "auth.json") + if _, _, err := Initialize(path); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("Load() accepted authentication state readable by other users") + } +} + +func TestLoadRejectsOversizedStateFile(t *testing.T) { + dir := filepath.Join(t.TempDir(), "state") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "auth.json") + if err := os.WriteFile(path, []byte(strings.Repeat("x", maxStateFileSize+1)), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil || !strings.Contains(err.Error(), "16 KiB limit") { + t.Fatalf("Load(oversized) error = %v", err) + } +} + +func TestInitializeRejectsOverlyBroadStateDirectoryPermissions(t *testing.T) { + dir := t.TempDir() + if err := os.Chmod(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "auth.json") + if _, _, err := Initialize(path); err == nil { + t.Fatal("Initialize() accepted a state directory accessible by group or other users") + } +} + +func TestMiddlewareRequiresBearerToken(t *testing.T) { + path := filepath.Join(t.TempDir(), "state", "auth.json") + token, _, err := Initialize(path) + if err != nil { + t.Fatal(err) + } + a, err := Load(path) + if err != nil { + t.Fatal(err) + } + + handler := a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + for name, authorization := range map[string]string{ + "missing": "", + "wrong scheme": "Basic " + token, + "wrong token": "Bearer wrong", + } { + t.Run(name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + req.Header.Set("Authorization", authorization) + response := httptest.NewRecorder() + handler.ServeHTTP(response, req) + if response.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", response.Code) + } + }) + } + + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + req.Header.Set("Authorization", "Bearer "+token) + response := httptest.NewRecorder() + handler.ServeHTTP(response, req) + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", response.Code) + } +} + +func TestEnabledFromEnv(t *testing.T) { + t.Setenv(EnabledEnv, "true") + enabled, err := EnabledFromEnv() + if err != nil || !enabled { + t.Fatalf("EnabledFromEnv() = (%v, %v), want true", enabled, err) + } + + t.Setenv(EnabledEnv, "not-a-bool") + if _, err := EnabledFromEnv(); err == nil { + t.Fatal("EnabledFromEnv() accepted an invalid boolean") + } +} diff --git a/mcp_server/internal/config/config.go b/mcp_server/internal/config/config.go index 30a3dd7b..8dd113a7 100644 --- a/mcp_server/internal/config/config.go +++ b/mcp_server/internal/config/config.go @@ -1,44 +1,46 @@ package config import ( + "bytes" + "fmt" "os" + "path/filepath" "strconv" + "strings" "github.com/chaitin/SafeLine/mcp_server/pkg/errors" "gopkg.in/yaml.v3" ) -// Config Global configuration structure +// Config is the complete MCP server configuration. type Config struct { - Server *ServerConfig `yaml:"server"` - Logger *LoggerConfig `yaml:"logger"` - API *APIConfig `yaml:"api"` + Server *ServerConfig `yaml:"server"` + Logger *LoggerConfig `yaml:"logger"` + Instances []*InstanceConfig `yaml:"instances"` } -// APIConfig API configuration -type APIConfig struct { - // API base URL - BaseURL string `yaml:"base_url"` - // API token - Token string `yaml:"token"` - // API timeout - Timeout int `yaml:"timeout"` - // API debug mode - Debug bool `yaml:"debug"` - // API insecure skip verify - InsecureSkipVerify bool `yaml:"insecure_skip_verify"` +// InstanceConfig identifies one independently authenticated SafeLine instance. +// The API token is deliberately referenced through a file instead of being +// embedded in the main configuration file. +type InstanceConfig struct { + ID string `yaml:"id"` + DisplayName string `yaml:"display_name"` + BaseURL string `yaml:"base_url"` + TokenFile string `yaml:"token_file"` + Timeout int `yaml:"timeout"` + Debug bool `yaml:"debug"` + InsecureSkipVerify bool `yaml:"insecure_skip_verify"` } -// ServerConfig Server configuration +// ServerConfig is the MCP server listener configuration. type ServerConfig struct { Name string `yaml:"name"` Version string `yaml:"version"` Port int `yaml:"port"` Host string `yaml:"host"` - Secret string `yaml:"secret"` } -// LoggerConfig Logger configuration +// LoggerConfig is the logging configuration. type LoggerConfig struct { Level string `yaml:"level"` FilePath string `yaml:"file_path"` @@ -49,7 +51,6 @@ type LoggerConfig struct { var config *Config -// getEnvString Get string value from environment variable, return default value if not exists func getEnvString(key, defaultValue string) string { if value, exists := os.LookupEnv(key); exists { return value @@ -57,7 +58,6 @@ func getEnvString(key, defaultValue string) string { return defaultValue } -// getEnvInt Get integer value from environment variable, return default value if not exists or cannot be parsed func getEnvInt(key string, defaultValue int) int { if value, exists := os.LookupEnv(key); exists { if intValue, err := strconv.Atoi(value); err == nil { @@ -67,33 +67,95 @@ func getEnvInt(key string, defaultValue int) int { return defaultValue } -// Load Load configuration file +// Load reads and validates the configuration file. Relative token_file paths +// are resolved from the directory containing the configuration file so that a +// deployment does not depend on the server process working directory. func Load(path string) error { data, err := os.ReadFile(path) if err != nil { return errors.Wrap(err, "read config file failed") } - config = &Config{} - if err := yaml.Unmarshal(data, config); err != nil { + next := &Config{} + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(next); err != nil { return errors.Wrap(err, "unmarshal config failed") } - // Override configuration from environment variables - if config.Server != nil { - config.Server.Host = getEnvString("LISTEN_ADDRESS", config.Server.Host) - config.Server.Port = getEnvInt("LISTEN_PORT", config.Server.Port) + if next.Server != nil { + next.Server.Host = getEnvString("LISTEN_ADDRESS", next.Server.Host) + next.Server.Port = getEnvInt("LISTEN_PORT", next.Server.Port) } - if config.API != nil { - config.API.BaseURL = getEnvString("SAFELINE_ADDRESS", config.API.BaseURL) - config.API.Token = getEnvString("SAFELINE_API_TOKEN", config.API.Token) + if next.Server == nil { + return errors.New("server configuration is required") + } + if strings.TrimSpace(next.Server.Name) == "" { + return errors.New("server.name is required") + } + if strings.TrimSpace(next.Server.Version) == "" { + return errors.New("server.version is required") + } + if strings.TrimSpace(next.Server.Host) == "" { + return errors.New("server.host is required") + } + if next.Server.Port < 1 || next.Server.Port > 65535 { + return errors.New("server.port must be between 1 and 65535") + } + if next.Logger == nil { + return errors.New("logger configuration is required") + } + + if err := normalizeInstances(next, filepath.Dir(path)); err != nil { + return err + } + + config = next + return nil +} + +func normalizeInstances(cfg *Config, configDir string) error { + if len(cfg.Instances) == 0 { + return errors.New("at least one SafeLine instance is required") + } + + seen := make(map[string]struct{}, len(cfg.Instances)) + for index, instance := range cfg.Instances { + if instance == nil { + return errors.New(fmt.Sprintf("instances[%d] is required", index)) + } + + instance.ID = strings.TrimSpace(instance.ID) + instance.DisplayName = strings.TrimSpace(instance.DisplayName) + instance.BaseURL = strings.TrimSpace(instance.BaseURL) + instance.TokenFile = strings.TrimSpace(instance.TokenFile) + if instance.ID == "" { + return errors.New(fmt.Sprintf("instances[%d].id is required", index)) + } + if _, ok := seen[instance.ID]; ok { + return errors.New(fmt.Sprintf("duplicate instance id %q", instance.ID)) + } + seen[instance.ID] = struct{}{} + + if instance.DisplayName == "" { + instance.DisplayName = instance.ID + } + if instance.BaseURL == "" { + return errors.New(fmt.Sprintf("instances[%d].base_url is required", index)) + } + if instance.TokenFile == "" { + return errors.New(fmt.Sprintf("instances[%d].token_file is required", index)) + } + if instance.TokenFile != "" && !filepath.IsAbs(instance.TokenFile) { + instance.TokenFile = filepath.Clean(filepath.Join(configDir, instance.TokenFile)) + } } return nil } -// GetServer Get server configuration +// GetServer returns the listener configuration. func GetServer() *ServerConfig { if config == nil { return nil @@ -101,7 +163,7 @@ func GetServer() *ServerConfig { return config.Server } -// GetLogger Get logger configuration +// GetLogger returns the logging configuration. func GetLogger() *LoggerConfig { if config == nil { return nil @@ -109,10 +171,10 @@ func GetLogger() *LoggerConfig { return config.Logger } -// GetAPI Get API configuration -func GetAPI() *APIConfig { +// GetInstances returns all configured SafeLine instances. +func GetInstances() []*InstanceConfig { if config == nil { return nil } - return config.API + return config.Instances } diff --git a/mcp_server/internal/config/config_test.go b/mcp_server/internal/config/config_test.go new file mode 100644 index 00000000..7238a4b6 --- /dev/null +++ b/mcp_server/internal/config/config_test.go @@ -0,0 +1,106 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeConfigFile(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} + +func TestLoadMultiInstanceConfiguration(t *testing.T) { + path := writeConfigFile(t, ` +server: + name: SafeLine MCP + version: 1.0.0 + host: 127.0.0.1 + port: 5678 +logger: + level: info +instances: + - id: dev152 + display_name: Development 152 + base_url: https://dev152:9443 + token_file: secrets/dev152_token + timeout: 12 + insecure_skip_verify: true + - id: dev180 + base_url: https://dev180:9443 + token_file: /run/secrets/dev180_token +`) + + if err := Load(path); err != nil { + t.Fatalf("Load() error = %v", err) + } + instances := GetInstances() + if len(instances) != 2 { + t.Fatalf("len(GetInstances()) = %d, want 2", len(instances)) + } + if instances[0].ID != "dev152" || instances[0].DisplayName != "Development 152" { + t.Fatalf("first instance = %#v", instances[0]) + } + wantRelativePath := filepath.Join(filepath.Dir(path), "secrets", "dev152_token") + if instances[0].TokenFile != wantRelativePath { + t.Errorf("relative token_file = %q, want %q", instances[0].TokenFile, wantRelativePath) + } + if instances[1].DisplayName != "dev180" { + t.Errorf("default display_name = %q, want dev180", instances[1].DisplayName) + } + if instances[1].TokenFile != "/run/secrets/dev180_token" { + t.Errorf("absolute token_file = %q", instances[1].TokenFile) + } +} + +func TestLoadRejectsDuplicateInstanceIDs(t *testing.T) { + path := writeConfigFile(t, ` +server: {name: SafeLine MCP, version: 1.0.0, host: 127.0.0.1, port: 5678} +logger: {level: info} +instances: + - id: duplicate + base_url: https://one.example.test + token_file: one_token + - id: duplicate + base_url: https://two.example.test + token_file: two_token +`) + + err := Load(path) + if err == nil || !strings.Contains(err.Error(), "duplicate instance id") { + t.Fatalf("Load() error = %v, want duplicate instance id", err) + } +} + +func TestLoadRejectsInlineInstanceToken(t *testing.T) { + path := writeConfigFile(t, ` +server: {name: SafeLine MCP, version: 1.0.0, host: 127.0.0.1, port: 5678} +logger: {level: info} +instances: + - id: unsafe + base_url: https://unsafe.example.test + token: must-not-be-accepted +`) + + err := Load(path) + if err == nil || !strings.Contains(err.Error(), "field token not found") { + t.Fatalf("Load() error = %v, want unknown inline token field", err) + } +} + +func TestLoadRequiresAtLeastOneInstance(t *testing.T) { + path := writeConfigFile(t, ` +server: {name: SafeLine MCP, version: 1.0.0, host: 127.0.0.1, port: 5678} +logger: {level: info} +`) + err := Load(path) + if err == nil || !strings.Contains(err.Error(), "at least one SafeLine instance") { + t.Fatalf("Load() error = %v, want missing instance error", err) + } +} diff --git a/mcp_server/internal/tools/analyze/get_atttack_events.go b/mcp_server/internal/tools/analyze/get_atttack_events.go index a0ec1b30..20e0750c 100644 --- a/mcp_server/internal/tools/analyze/get_atttack_events.go +++ b/mcp_server/internal/tools/analyze/get_atttack_events.go @@ -2,17 +2,24 @@ package analyze import ( "context" + "fmt" + "strings" "github.com/chaitin/SafeLine/mcp_server/internal/api/analyze" + "github.com/chaitin/SafeLine/mcp_server/pkg/errors" "github.com/chaitin/SafeLine/mcp_server/pkg/logger" + markmcp "github.com/mark3labs/mcp-go/mcp" ) type GetAttackEventsParams struct { - IP string `json:"ip" desc:"ip" required:"false"` - Page int `json:"page" desc:"page" required:"false" default:"1"` - PageSize int `json:"page_size" desc:"page size" required:"false" default:"10"` - Start int64 `json:"start" desc:"start unix timestamp in milliseconds" required:"false"` - End int64 `json:"end" desc:"end unix timestamp in milliseconds" required:"false"` + InstanceID string `json:"instance_id" jsonschema_description:"Configured SafeLine instance ID"` + Page int `json:"page,omitempty" jsonschema_description:"Page number, starting at 1" default:"1"` + PageSize int `json:"page_size,omitempty" jsonschema_description:"Events per page, from 1 through 100" default:"10"` + IP string `json:"ip,omitempty" jsonschema_description:"Source IP address, CIDR, or partial IP filter"` + Host string `json:"host,omitempty" jsonschema_description:"Protected host name filter"` + Port int `json:"port,omitempty" jsonschema_description:"Protected destination port filter, from 1 through 65535"` + Start int64 `json:"start,omitempty" jsonschema_description:"Start Unix timestamp in milliseconds"` + End int64 `json:"end,omitempty" jsonschema_description:"End Unix timestamp in milliseconds"` } type GetAttackEvents struct{} @@ -22,23 +29,82 @@ func (t *GetAttackEvents) Name() string { } func (t *GetAttackEvents) Description() string { - return "get attack events" + return "Query aggregated attack events from one configured SafeLine instance" +} + +func (t *GetAttackEvents) InputSchemaOptions() []markmcp.ToolOption { + return []markmcp.ToolOption{ + markmcp.WithString("instance_id", + markmcp.Required(), + markmcp.MinLength(1), + markmcp.Description("Configured SafeLine instance ID"), + ), + markmcp.WithInteger("page", + markmcp.Min(1), + markmcp.DefaultNumber(1), + markmcp.Description("Page number, starting at 1"), + ), + markmcp.WithInteger("page_size", + markmcp.Min(1), + markmcp.Max(100), + markmcp.DefaultNumber(10), + markmcp.Description("Events per page, from 1 through 100"), + ), + markmcp.WithString("ip", markmcp.Description("Source IP address, CIDR, or partial IP filter")), + markmcp.WithString("host", markmcp.Description("Protected host name filter")), + markmcp.WithInteger("port", + markmcp.Min(1), + markmcp.Max(65535), + markmcp.Description("Protected destination port filter, from 1 through 65535"), + ), + markmcp.WithInteger("start", + markmcp.Min(0), + markmcp.Description("Start Unix timestamp in milliseconds"), + ), + markmcp.WithInteger("end", + markmcp.Min(0), + markmcp.Description("End Unix timestamp in milliseconds"), + ), + markmcp.WithSchemaAdditionalProperties(false), + } } func (t *GetAttackEvents) Validate(params GetAttackEventsParams) error { + if strings.TrimSpace(params.InstanceID) == "" { + return errors.New("instance_id is required") + } + if params.Page < 1 { + return errors.New("page must be at least 1") + } + if params.PageSize < 1 || params.PageSize > 100 { + return errors.New("page_size must be between 1 and 100") + } + if params.Port < 0 || params.Port > 65535 { + return errors.New("port must be between 1 and 65535 when provided") + } + if params.Start < 0 || params.End < 0 { + return errors.New("start and end must be non-negative Unix timestamps") + } + if params.Start > 0 && params.End > 0 && params.Start > params.End { + return errors.New("start must be less than or equal to end") + } return nil } func (t *GetAttackEvents) Execute(ctx context.Context, params GetAttackEventsParams) (analyze.GetEventListResponse, error) { resp, err := analyze.GetEventList(ctx, &analyze.GetEventListRequest{ - IP: params.IP, - PageSize: params.PageSize, - Page: params.Page, - Start: params.Start, - End: params.End, + InstanceID: strings.TrimSpace(params.InstanceID), + Page: params.Page, + PageSize: params.PageSize, + IP: strings.TrimSpace(params.IP), + Host: strings.TrimSpace(params.Host), + Port: params.Port, + Start: params.Start, + End: params.End, }) if err != nil { - return analyze.GetEventListResponse{}, err + logger.With("instance_id", strings.TrimSpace(params.InstanceID)).With("error", err).Error("SafeLine event query failed") + return analyze.GetEventListResponse{}, fmt.Errorf("SafeLine query failed for instance %q", strings.TrimSpace(params.InstanceID)) } logger.With("total", resp.Total).Info("get attack events") return *resp, nil diff --git a/mcp_server/internal/tools/analyze/get_atttack_events_test.go b/mcp_server/internal/tools/analyze/get_atttack_events_test.go new file mode 100644 index 00000000..d825156d --- /dev/null +++ b/mcp_server/internal/tools/analyze/get_atttack_events_test.go @@ -0,0 +1,77 @@ +package analyze + +import ( + "encoding/json" + "testing" + + markmcp "github.com/mark3labs/mcp-go/mcp" +) + +func TestGetAttackEventsValidate(t *testing.T) { + tool := &GetAttackEvents{} + valid := GetAttackEventsParams{InstanceID: "dev152", Page: 1, PageSize: 10} + if err := tool.Validate(valid); err != nil { + t.Fatalf("Validate(valid) error = %v", err) + } + + tests := []struct { + name string + mutate func(*GetAttackEventsParams) + }{ + {name: "missing instance", mutate: func(p *GetAttackEventsParams) { p.InstanceID = " " }}, + {name: "page zero", mutate: func(p *GetAttackEventsParams) { p.Page = 0 }}, + {name: "page size zero", mutate: func(p *GetAttackEventsParams) { p.PageSize = 0 }}, + {name: "page size too large", mutate: func(p *GetAttackEventsParams) { p.PageSize = 101 }}, + {name: "negative port", mutate: func(p *GetAttackEventsParams) { p.Port = -1 }}, + {name: "port too large", mutate: func(p *GetAttackEventsParams) { p.Port = 65536 }}, + {name: "negative start", mutate: func(p *GetAttackEventsParams) { p.Start = -1 }}, + {name: "negative end", mutate: func(p *GetAttackEventsParams) { p.End = -1 }}, + {name: "reversed range", mutate: func(p *GetAttackEventsParams) { p.Start, p.End = 2, 1 }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + params := valid + test.mutate(¶ms) + if err := tool.Validate(params); err == nil { + t.Fatal("Validate() error = nil, want validation error") + } + }) + } +} + +func TestGetAttackEventsV1InputSchema(t *testing.T) { + implementation := &GetAttackEvents{} + tool := markmcp.NewTool("get_attack_events", implementation.InputSchemaOptions()...) + var schema struct { + Properties map[string]map[string]any `json:"properties"` + Required []string `json:"required"` + AdditionalProperties bool `json:"additionalProperties"` + } + encoded, err := json.Marshal(tool.InputSchema) + if err != nil { + t.Fatalf("marshal input schema: %v", err) + } + if err := json.Unmarshal(encoded, &schema); err != nil { + t.Fatalf("unmarshal input schema: %v", err) + } + if len(schema.Required) != 1 || schema.Required[0] != "instance_id" { + t.Fatalf("required = %#v, want only instance_id", schema.Required) + } + for _, field := range []string{"page", "page_size", "port", "start", "end"} { + if got := schema.Properties[field]["type"]; got != "integer" { + t.Errorf("%s type = %v, want integer", field, got) + } + } + if got := schema.Properties["page"]["minimum"]; got != float64(1) { + t.Errorf("page minimum = %v, want 1", got) + } + if got := schema.Properties["page_size"]["maximum"]; got != float64(100) { + t.Errorf("page_size maximum = %v, want 100", got) + } + if got := schema.Properties["port"]["maximum"]; got != float64(65535) { + t.Errorf("port maximum = %v, want 65535", got) + } + if schema.AdditionalProperties { + t.Error("additionalProperties = true, want false") + } +} diff --git a/mcp_server/internal/tools/app/create_application.go b/mcp_server/internal/tools/app/create_application.go deleted file mode 100644 index 96809ab2..00000000 --- a/mcp_server/internal/tools/app/create_application.go +++ /dev/null @@ -1,41 +0,0 @@ -package app - -import ( - "context" - - "github.com/chaitin/SafeLine/mcp_server/internal/api/app" - "github.com/chaitin/SafeLine/mcp_server/pkg/logger" -) - -type CreateApp struct{} - -type CreateAppParams struct { - ServerNames []string `json:"server_names" desc:"domain list" required:"true"` - Ports []string `json:"ports" desc:"port list" required:"true"` - Upstreams []string `json:"upstreams" desc:"upstream list" required:"true"` -} - -func (t *CreateApp) Name() string { - return "create_http_application" -} - -func (t *CreateApp) Description() string { - return "create a new website or app" -} - -func (t *CreateApp) Validate(params CreateAppParams) error { - return nil -} - -func (t *CreateApp) Execute(ctx context.Context, params CreateAppParams) (int64, error) { - id, err := app.CreateApp(ctx, &app.CreateAppRequest{ - ServerNames: params.ServerNames, - Ports: params.Ports, - Upstreams: params.Upstreams, - }) - if err != nil { - return 0, err - } - logger.Info("create app success", logger.Int64("id", id)) - return id, nil -} diff --git a/mcp_server/internal/tools/example.go b/mcp_server/internal/tools/example.go deleted file mode 100644 index d482bc2c..00000000 --- a/mcp_server/internal/tools/example.go +++ /dev/null @@ -1,46 +0,0 @@ -package tools - -import ( - "context" - - "github.com/chaitin/SafeLine/mcp_server/pkg/errors" - "github.com/chaitin/SafeLine/mcp_server/pkg/logger" -) - -type CalculateSum struct{} - -func (t *CalculateSum) Name() string { - return "calculate_sum" -} - -func (t *CalculateSum) Description() string { - return "Add two numbers together" -} - -type MyToolInput struct { - A int `json:"a" desc:"number a" required:"true"` - B int `json:"b" desc:"number b" required:"true"` -} - -type MyToolOutput struct { - C int `json:"c"` -} - -func (t *CalculateSum) Validate(params MyToolInput) error { - return nil -} - -func (t *CalculateSum) Execute(ctx context.Context, params MyToolInput) (MyToolOutput, error) { - logger.With("a", params.A). - With("b", params.B). - Debug("Executing calculation") - - result := MyToolOutput{ - C: params.A + params.B, - } - - logger.With("result", result.C). - Debug("Calculation completed") - - return result, errors.New("test error") -} diff --git a/mcp_server/internal/tools/init.go b/mcp_server/internal/tools/init.go index 03f850c9..a8a14825 100644 --- a/mcp_server/internal/tools/init.go +++ b/mcp_server/internal/tools/init.go @@ -2,18 +2,8 @@ package tools import ( "github.com/chaitin/SafeLine/mcp_server/internal/tools/analyze" - "github.com/chaitin/SafeLine/mcp_server/internal/tools/app" - "github.com/chaitin/SafeLine/mcp_server/internal/tools/rule" ) func init() { - // app - AppendTool(&app.CreateApp{}) - - // rule - AppendTool(&rule.CreateBlacklistRule{}) - AppendTool(&rule.CreateWhitelistRule{}) - - // analyze AppendTool(&analyze.GetAttackEvents{}) } diff --git a/mcp_server/internal/tools/init_test.go b/mcp_server/internal/tools/init_test.go new file mode 100644 index 00000000..e22e832b --- /dev/null +++ b/mcp_server/internal/tools/init_test.go @@ -0,0 +1,13 @@ +package tools + +import "testing" + +func TestOnlyReadOnlyAttackEventsToolIsRegistered(t *testing.T) { + registered := Tools() + if len(registered) != 1 { + t.Fatalf("registered tool count = %d, want 1", len(registered)) + } + if got := registered[0].Name(); got != "get_attack_events" { + t.Fatalf("registered tool = %q, want get_attack_events", got) + } +} diff --git a/mcp_server/internal/tools/rule/create_blacklist_rule.go b/mcp_server/internal/tools/rule/create_blacklist_rule.go deleted file mode 100644 index 3e7b81b6..00000000 --- a/mcp_server/internal/tools/rule/create_blacklist_rule.go +++ /dev/null @@ -1,66 +0,0 @@ -package rule - -import ( - "context" - - "github.com/chaitin/SafeLine/mcp_server/internal/api" - "github.com/chaitin/SafeLine/mcp_server/internal/api/rule" - "github.com/chaitin/SafeLine/mcp_server/pkg/logger" -) - -type CreateBlacklistRule struct{} - -type CreateBlacklistRuleParams struct { - Name string `json:"name" desc:"name" required:"true"` - IP []string `json:"ip" desc:"ip" required:"false"` - URINoQuery []string `json:"uri_no_query" desc:"uri_no_query" required:"false"` -} - -func (t *CreateBlacklistRule) Name() string { - return "create_blacklist_rule" -} - -func (t *CreateBlacklistRule) Description() string { - return "create a new blacklist rule" -} - -func (t *CreateBlacklistRule) Validate(params CreateBlacklistRuleParams) error { - return nil -} - -func (t *CreateBlacklistRule) Execute(ctx context.Context, params CreateBlacklistRuleParams) (int64, error) { - var pattern [][]api.Pattern - if len(params.IP) > 0 { - pattern = append(pattern, []api.Pattern{ - { - K: api.KeySrcIP, - Op: api.OpEq, - V: params.IP, - SubK: "", - }, - }) - } - if len(params.URINoQuery) > 0 { - pattern = append(pattern, []api.Pattern{ - { - K: api.KeyURINoQuery, - Op: api.OpEq, - V: params.URINoQuery, - SubK: "", - }, - }) - } - - id, err := rule.CreateRule(ctx, &rule.CreateRuleRequest{ - Name: params.Name, - IP: params.IP, - IsEnabled: true, - Action: int(api.PolicyRuleActionDeny), - Pattern: pattern, - }) - if err != nil { - return 0, err - } - logger.With("id", id).Info("create blacklist rule success") - return id, nil -} diff --git a/mcp_server/internal/tools/rule/create_whitelist_rule.go b/mcp_server/internal/tools/rule/create_whitelist_rule.go deleted file mode 100644 index 4b83f842..00000000 --- a/mcp_server/internal/tools/rule/create_whitelist_rule.go +++ /dev/null @@ -1,66 +0,0 @@ -package rule - -import ( - "context" - - "github.com/chaitin/SafeLine/mcp_server/internal/api" - "github.com/chaitin/SafeLine/mcp_server/internal/api/rule" - "github.com/chaitin/SafeLine/mcp_server/pkg/logger" -) - -type CreateWhitelistRule struct{} - -type CreateWhitelistRuleParams struct { - Name string `json:"name" desc:"name" required:"true"` - IP []string `json:"ip" desc:"ip" required:"false"` - URINoQuery []string `json:"uri_no_query" desc:"uri_no_query" required:"false"` -} - -func (t *CreateWhitelistRule) Name() string { - return "create_whitelist_rule" -} - -func (t *CreateWhitelistRule) Description() string { - return "create a new whitelist rule" -} - -func (t *CreateWhitelistRule) Validate(params CreateWhitelistRuleParams) error { - return nil -} - -func (t *CreateWhitelistRule) Execute(ctx context.Context, params CreateWhitelistRuleParams) (int64, error) { - var pattern [][]api.Pattern - if len(params.IP) > 0 { - pattern = append(pattern, []api.Pattern{ - { - K: api.KeySrcIP, - Op: api.OpEq, - V: params.IP, - SubK: "", - }, - }) - } - if len(params.URINoQuery) > 0 { - pattern = append(pattern, []api.Pattern{ - { - K: api.KeyURINoQuery, - Op: api.OpEq, - V: params.URINoQuery, - SubK: "", - }, - }) - } - - id, err := rule.CreateRule(ctx, &rule.CreateRuleRequest{ - Name: params.Name, - IP: params.IP, - IsEnabled: true, - Action: int(api.PolicyRuleActionAllow), - Pattern: pattern, - }) - if err != nil { - return 0, err - } - logger.With("id", id).Info("create whitelist rule success") - return id, nil -} diff --git a/mcp_server/internal/tools/tool.go b/mcp_server/internal/tools/tool.go index 6a57c420..ad88eada 100644 --- a/mcp_server/internal/tools/tool.go +++ b/mcp_server/internal/tools/tool.go @@ -11,6 +11,7 @@ import ( // Each Tool is wrapped in a toolWrapper that knows its concrete type, // allowing correct passing of generic parameters during registration. type ToolWrapper interface { + Name() string Register(s *mcp.MCPServer) error } @@ -32,6 +33,10 @@ type toolWrapper[T any, R any] struct { tool mcp.Tool[T, R] } +func (w *toolWrapper[T, R]) Name() string { + return w.tool.Name() +} + func (w *toolWrapper[T, R]) Register(s *mcp.MCPServer) error { logger.Info("Registering tool", logger.String("name", w.tool.Name()), diff --git a/mcp_server/main.go b/mcp_server/main.go index 9a8dcb09..85609479 100644 --- a/mcp_server/main.go +++ b/mcp_server/main.go @@ -3,20 +3,90 @@ package main import ( "flag" "fmt" + "io" + "os" + "strings" "github.com/chaitin/SafeLine/mcp_server/internal/api" + "github.com/chaitin/SafeLine/mcp_server/internal/auth" "github.com/chaitin/SafeLine/mcp_server/internal/config" "github.com/chaitin/SafeLine/mcp_server/internal/tools" "github.com/chaitin/SafeLine/mcp_server/pkg/logger" - "github.com/chaitin/SafeLine/mcp_server/pkg/mcp" + mcpserver "github.com/chaitin/SafeLine/mcp_server/pkg/mcp" ) func main() { - configPath := flag.String("config", "config.yaml", "path to config file") - flag.Parse() + if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(args []string, stdout, stderr io.Writer) error { + if len(args) > 0 && args[0] == "auth" { + return runAuth(args[1:], stdout, stderr) + } + return runServer(args, stderr) +} + +func runAuth(args []string, stdout, stderr io.Writer) error { + if len(args) == 0 { + return fmt.Errorf("usage: mcp-server auth [--state-file path]") + } + + command := args[0] + flags := flag.NewFlagSet("auth "+command, flag.ContinueOnError) + flags.SetOutput(stderr) + stateFile := flags.String("state-file", auth.StateFileFromEnv(), "authentication state file") + if err := flags.Parse(args[1:]); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected auth arguments: %s", strings.Join(flags.Args(), " ")) + } + + switch command { + case "init": + token, created, err := auth.Initialize(*stateFile) + if err != nil { + return err + } + if !created { + _, _ = fmt.Fprintf(stdout, "MCP authentication is already initialized at %s; token was not changed.\n", *stateFile) + return nil + } + printNewToken(stdout, token) + return nil + case "rotate": + token, err := auth.Rotate(*stateFile) + if err != nil { + return err + } + printNewToken(stdout, token) + return nil + default: + return fmt.Errorf("unknown auth command %q; expected init or rotate", command) + } +} + +func printNewToken(output io.Writer, token string) { + _, _ = fmt.Fprintln(output, "MCP bearer token (shown once; save it now):") + _, _ = fmt.Fprintln(output, token) +} + +func runServer(args []string, stderr io.Writer) error { + flags := flag.NewFlagSet("mcp-server", flag.ContinueOnError) + flags.SetOutput(stderr) + configPath := flags.String("config", "config.yaml", "path to config file") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " ")) + } if err := config.Load(*configPath); err != nil { - panic(fmt.Errorf("failed to load config: %v", err)) + return fmt.Errorf("load config: %w", err) } logConfig := config.GetLogger() @@ -27,36 +97,39 @@ func main() { Caller: logConfig.Caller, Development: logConfig.Development, }); err != nil { - panic(fmt.Errorf("failed to init logger: %v", err)) + return fmt.Errorf("initialize logger: %w", err) } - logger.With("base_url", config.GetAPI().BaseURL).Info("Initializing API service...") - if err := api.Init(config.GetAPI()); err != nil { - panic(fmt.Errorf("failed to init API service: %v", err)) + instances := config.GetInstances() + logger.With("instances", len(instances)).Info("initializing SafeLine API clients") + if err := api.InitInstances(instances); err != nil { + return fmt.Errorf("initialize SafeLine API clients: %w", err) } - logger.Info("Starting MCP Server...") - serverConfig := config.GetServer() - s := mcp.NewMCPServer( - serverConfig.Name, - serverConfig.Version, - serverConfig.Secret, - ) + authEnabled, err := auth.EnabledFromEnv() + if err != nil { + return err + } + var middleware []mcpserver.Middleware + if authEnabled { + stateFile := auth.StateFileFromEnv() + authenticator, err := auth.Load(stateFile) + if err != nil { + return fmt.Errorf("MCP authentication is enabled but not initialized; run `mcp-server auth init --state-file %s`: %w", stateFile, err) + } + middleware = append(middleware, mcpserver.Middleware(authenticator.Middleware)) + } + logger.With("enabled", authEnabled).Info("MCP bearer authentication configured") - logger.Info("Registering tools...") + serverConfig := config.GetServer() + s := mcpserver.NewMCPServer(serverConfig.Name, serverConfig.Version) for _, tool := range tools.Tools() { if err := tool.Register(s); err != nil { - logger.With("error", err). - Error("Failed to register tool") - panic(err) + return fmt.Errorf("register tool: %w", err) } } addr := fmt.Sprintf("%s:%d", serverConfig.Host, serverConfig.Port) - logger.With("addr", addr).Info("Starting server") - if err := s.Start(addr); err != nil { - logger.With("error", err). - Error("Server failed to start") - panic(err) - } + logger.With("addr", addr).With("endpoint", mcpserver.EndpointPath).Info("starting MCP server") + return s.Start(addr, middleware...) } diff --git a/mcp_server/main_test.go b/mcp_server/main_test.go new file mode 100644 index 00000000..b89611f8 --- /dev/null +++ b/mcp_server/main_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/chaitin/SafeLine/mcp_server/internal/auth" +) + +func TestAuthInitIsOneTimeAndRotateIsExplicit(t *testing.T) { + stateFile := filepath.Join(t.TempDir(), "state", "auth.json") + var first bytes.Buffer + if err := run([]string{"auth", "init", "--state-file", stateFile}, &first, &bytes.Buffer{}); err != nil { + t.Fatalf("auth init error = %v", err) + } + firstToken := lastLine(first.String()) + if !strings.HasPrefix(firstToken, "slmcp_") { + t.Fatalf("init output did not contain generated token: %q", first.String()) + } + + contents, err := os.ReadFile(stateFile) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(contents), firstToken) { + t.Fatal("state file contains plaintext token") + } + + var second bytes.Buffer + if err := run([]string{"auth", "init", "--state-file", stateFile}, &second, &bytes.Buffer{}); err != nil { + t.Fatalf("second auth init error = %v", err) + } + if strings.Contains(second.String(), "slmcp_") { + t.Fatalf("second init exposed or generated a token: %q", second.String()) + } + + var rotated bytes.Buffer + if err := run([]string{"auth", "rotate", "--state-file", stateFile}, &rotated, &bytes.Buffer{}); err != nil { + t.Fatalf("auth rotate error = %v", err) + } + rotatedToken := lastLine(rotated.String()) + if rotatedToken == firstToken || !strings.HasPrefix(rotatedToken, "slmcp_") { + t.Fatalf("rotate output token = %q", rotatedToken) + } + a, err := auth.Load(stateFile) + if err != nil { + t.Fatal(err) + } + if a.Verify(firstToken) || !a.Verify(rotatedToken) { + t.Fatal("explicit rotation did not replace the accepted token") + } +} + +func lastLine(output string) string { + lines := strings.Split(strings.TrimSpace(output), "\n") + return lines[len(lines)-1] +} diff --git a/mcp_server/pkg/config/config.go b/mcp_server/pkg/config/config.go deleted file mode 100644 index a4d93f13..00000000 --- a/mcp_server/pkg/config/config.go +++ /dev/null @@ -1,72 +0,0 @@ -package config - -import ( - "os" - - "gopkg.in/yaml.v3" -) - -// Config Global configuration structure -type Config struct { - Server ServerConfig `yaml:"server"` - Logger LoggerConfig `yaml:"logger"` -} - -// ServerConfig Server configuration -type ServerConfig struct { - Name string `yaml:"name"` - Version string `yaml:"version"` - Port int `yaml:"port"` - Host string `yaml:"host"` - Secret string `yaml:"secret"` -} - -// LoggerConfig Logger configuration -type LoggerConfig struct { - Level string `yaml:"level"` - FilePath string `yaml:"file_path"` - Console bool `yaml:"console"` - Caller bool `yaml:"caller"` - Development bool `yaml:"development"` -} - -var ( - globalConfig *Config -) - -// Load Load configuration from file -func Load(filename string) error { - data, err := os.ReadFile(filename) - if err != nil { - return err - } - - config := &Config{} - if err := yaml.Unmarshal(data, config); err != nil { - return err - } - - globalConfig = config - return nil -} - -// Get Get global configuration -func Get() *Config { - return globalConfig -} - -// GetServer Get server configuration -func GetServer() ServerConfig { - if globalConfig == nil { - return ServerConfig{} - } - return globalConfig.Server -} - -// GetLogger Get logger configuration -func GetLogger() LoggerConfig { - if globalConfig == nil { - return LoggerConfig{} - } - return globalConfig.Logger -} diff --git a/mcp_server/pkg/mcp/mcp.go b/mcp_server/pkg/mcp/mcp.go index 55be4e4b..a7361a6d 100644 --- a/mcp_server/pkg/mcp/mcp.go +++ b/mcp_server/pkg/mcp/mcp.go @@ -2,18 +2,21 @@ package mcp import ( "context" - "encoding/json" "fmt" "net/http" - "strconv" + "time" - "github.com/chaitin/SafeLine/mcp_server/pkg/errors" "github.com/chaitin/SafeLine/mcp_server/pkg/logger" - "github.com/mark3labs/mcp-go/mcp" + markmcp "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/mcuadros/go-defaults" ) +const ( + EndpointPath = "/mcp" + maxRequestBodySize = 1 << 20 +) + type Tool[T any, R any] interface { Name() string @@ -24,125 +27,113 @@ type Tool[T any, R any] interface { Validate(params T) error } -type SSEServer struct { - sse *server.SSEServer - secret string +// InputSchemaProvider lets a tool declare constraints that cannot be inferred +// from Go types alone, such as numeric ranges and documented defaults. +type InputSchemaProvider interface { + InputSchemaOptions() []markmcp.ToolOption } -func (s *SSEServer) Start(addr string) error { - srv := &http.Server{ - Addr: addr, - Handler: s, - } - return srv.ListenAndServe() -} - -func (s *SSEServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if s.secret == "" { - s.sse.ServeHTTP(w, r) - return - } - messagePath := s.sse.CompleteMessagePath() - if messagePath != "" && r.URL.Path == messagePath { - secret := r.Header.Get("Secret") - if secret != s.secret { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - } - s.sse.ServeHTTP(w, r) -} +type Middleware func(http.Handler) http.Handler type MCPServer struct { - server *server.MCPServer - sse *SSEServer + server *server.MCPServer + transport *server.StreamableHTTPServer } -func NewMCPServer(name, version string, secret string) *MCPServer { +func NewMCPServer(name, version string) *MCPServer { s := server.NewMCPServer( name, version, - server.WithLogging(), + server.WithToolCapabilities(false), + server.WithInputSchemaValidation(), + server.WithStrictInputSchemaDefault(), + server.WithOutputSchemaValidation(), ) - return &MCPServer{ - server: s, - sse: &SSEServer{sse: server.NewSSEServer(s), secret: secret}, - } + transport := server.NewStreamableHTTPServer( + s, + server.WithEndpointPath(EndpointPath), + server.WithStateLess(true), + ) + return &MCPServer{server: s, transport: transport} } -func (s *MCPServer) Start(addr string) error { - return s.sse.Start(addr) +// Handler exposes exactly one Streamable HTTP endpoint. Legacy /sse and +// /message routes are intentionally not mounted. +func (s *MCPServer) Handler() http.Handler { + mux := http.NewServeMux() + mux.Handle(EndpointPath, http.MaxBytesHandler(s.transport, maxRequestBodySize)) + return mux } -func handleToolCall[T any, R any](ctx context.Context, request mcp.CallToolRequest, tool Tool[T, R]) (result *mcp.CallToolResult, err error) { +func (s *MCPServer) Start(addr string, middleware ...Middleware) error { + var handler http.Handler = s.Handler() + for i := len(middleware) - 1; i >= 0; i-- { + handler = middleware[i](handler) + } + + srv := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 60 * time.Second, + } + return srv.ListenAndServe() +} + +func handleToolCall[T any, R any](ctx context.Context, request markmcp.CallToolRequest, tool Tool[T, R]) (result *markmcp.CallToolResult, err error) { defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("panic recovered: %v", r) + if recovered := recover(); recovered != nil { + logger.With("tool", tool.Name()).Error("tool execution panicked") + result = markmcp.NewToolResultError("tool execution failed") + err = nil } }() - var raw []byte - raw, err = json.Marshal(request.Params.Arguments) - if err != nil { - return nil, errors.Wrap(err, "marshal arguments failed") - } var params T defaults.SetDefaults(¶ms) - if err = json.Unmarshal(raw, ¶ms); err != nil { - return nil, errors.Wrap(err, "unmarshal parameters failed") + if err := request.BindArguments(¶ms); err != nil { + return markmcp.NewToolResultError(fmt.Sprintf("invalid arguments: %v", err)), nil } - - if err = tool.Validate(params); err != nil { - return nil, err + if err := tool.Validate(params); err != nil { + return markmcp.NewToolResultError(fmt.Sprintf("invalid arguments: %v", err)), nil } - var execResult R - execResult, err = tool.Execute(ctx, params) + execResult, err := tool.Execute(ctx, params) if err != nil { - return nil, err + logger.With("tool", tool.Name()).With("error", err).Error("tool execution failed") + return markmcp.NewToolResultError(err.Error()), nil } - v := any(execResult) - switch v := v.(type) { - case string: - return mcp.NewToolResultText(v), nil - case []byte: - return mcp.NewToolResultText(string(v)), nil - case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: - return mcp.NewToolResultText(json.Number(fmt.Sprint(v)).String()), nil - case bool: - return mcp.NewToolResultText(strconv.FormatBool(v)), nil - default: - bytes, err := json.Marshal(v) - if err != nil { - return nil, errors.New("invalid result type") - } - return mcp.NewToolResultText(string(bytes)), nil + result, err = markmcp.NewToolResultJSON(execResult) + if err != nil { + logger.With("tool", tool.Name()).With("error", err).Error("tool result serialization failed") + return markmcp.NewToolResultError("tool result serialization failed"), nil } + return result, nil } func RegisterTool[T any, R any](s *MCPServer, tool Tool[T, R]) error { - var v T - opts, err := SchemaToOptions(v) - if err != nil { - return err + options := []markmcp.ToolOption{ + markmcp.WithDescription(tool.Description()), } - opts = append(opts, mcp.WithDescription(tool.Description())) - t := mcp.NewTool(tool.Name(), - opts..., + if provider, ok := any(tool).(InputSchemaProvider); ok { + options = append(options, provider.InputSchemaOptions()...) + } else { + options = append(options, markmcp.WithInputSchema[T]()) + } + options = append(options, + markmcp.WithOutputSchema[R](), + markmcp.WithReadOnlyHintAnnotation(true), + markmcp.WithDestructiveHintAnnotation(false), + markmcp.WithIdempotentHintAnnotation(true), + markmcp.WithOpenWorldHintAnnotation(true), ) + t := markmcp.NewTool(tool.Name(), options...) - s.server.AddTool(t, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - result, err := handleToolCall(ctx, request, tool) - if err != nil { - logger.With("error", err).Error("handle tool call failed") - if wrapped, ok := err.(*errors.Error); ok { - return nil, wrapped.Unwrap() - } - return nil, err - } - return result, nil + s.server.AddTool(t, func(ctx context.Context, request markmcp.CallToolRequest) (*markmcp.CallToolResult, error) { + return handleToolCall(ctx, request, tool) }) - return nil } diff --git a/mcp_server/pkg/mcp/mcp_test.go b/mcp_server/pkg/mcp/mcp_test.go new file mode 100644 index 00000000..a8dcec25 --- /dev/null +++ b/mcp_server/pkg/mcp/mcp_test.go @@ -0,0 +1,179 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/chaitin/SafeLine/mcp_server/pkg/logger" + markmcp "github.com/mark3labs/mcp-go/mcp" +) + +type probeParams struct { + Count int `json:"count" jsonschema:"Number of results to return"` +} + +type probeResult struct { + Count int `json:"count" jsonschema:"Returned result count"` +} + +type probeTool struct{} + +func (probeTool) Name() string { return "probe" } +func (probeTool) Description() string { return "Protocol test tool" } +func (probeTool) Validate(params probeParams) error { + if params.Count < 1 { + return errors.New("count must be at least 1") + } + return nil +} +func (probeTool) Execute(_ context.Context, params probeParams) (probeResult, error) { + if params.Count == 13 { + return probeResult{}, errors.New("downstream failed") + } + return probeResult{Count: params.Count}, nil +} + +func TestModernStreamableHTTPAndToolErrorSemantics(t *testing.T) { + if err := logger.Init(&logger.Config{Level: "error"}); err != nil { + t.Fatal(err) + } + s := NewMCPServer("test", "1.0.0") + if err := RegisterTool[probeParams, probeResult](s, probeTool{}); err != nil { + t.Fatal(err) + } + httpServer := httptest.NewServer(s.Handler()) + defer httpServer.Close() + + discover := postModern(t, httpServer.URL+EndpointPath, markmcp.MethodServerDiscover, map[string]any{}) + result := requireRPCResult(t, discover) + versions, ok := result["supportedVersions"].([]any) + if !ok || len(versions) == 0 || versions[0] != markmcp.ProtocolVersion20260728 { + t.Fatalf("supportedVersions = %#v, want %s first", result["supportedVersions"], markmcp.ProtocolVersion20260728) + } + + valid := postModern(t, httpServer.URL+EndpointPath, markmcp.MethodToolsCall, map[string]any{ + "name": "probe", + "arguments": map[string]any{"count": 2}, + }) + validResult := requireRPCResult(t, valid) + if validResult["isError"] == true { + t.Fatalf("valid tool result = %#v", validResult) + } + structured := validResult["structuredContent"].(map[string]any) + if structured["count"] != float64(2) { + t.Fatalf("structuredContent = %#v", structured) + } + + for name, arguments := range map[string]map[string]any{ + "decimal integer": {"count": 1.5}, + "unknown field": {"count": 1, "unexpected": true}, + "validation": {"count": 0}, + "downstream": {"count": 13}, + } { + t.Run(name, func(t *testing.T) { + response := postModern(t, httpServer.URL+EndpointPath, markmcp.MethodToolsCall, map[string]any{ + "name": "probe", + "arguments": arguments, + }) + toolResult := requireRPCResult(t, response) + if toolResult["isError"] != true { + t.Fatalf("tool result = %#v, want isError=true", toolResult) + } + }) + } + + for _, path := range []string{"/sse", "/message"} { + response, err := http.Get(httpServer.URL + path) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusNotFound { + t.Fatalf("GET %s status = %d, want 404", path, response.StatusCode) + } + } +} + +func TestHandlerRejectsOversizedRequestBody(t *testing.T) { + s := NewMCPServer("test", "1.0.0") + body := strings.NewReader(strings.Repeat("x", maxRequestBodySize+1)) + req := httptest.NewRequest(http.MethodPost, EndpointPath, body) + req.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + + s.Handler().ServeHTTP(response, req) + if !strings.Contains(response.Body.String(), "request body too large") { + t.Fatalf("oversized response = status %d body %q", response.Code, response.Body.String()) + } +} + +func postModern(t *testing.T, url string, method markmcp.MCPMethod, params map[string]any) *http.Response { + t.Helper() + params["_meta"] = map[string]any{ + markmcp.MetaKeyProtocolVersion: markmcp.ProtocolVersion20260728, + markmcp.MetaKeyClientInfo: map[string]any{ + "name": "safeline-mcp-test", + "version": "1.0.0", + }, + markmcp.MetaKeyClientCapabilities: map[string]any{}, + } + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }) + if err != nil { + t.Fatal(err) + } + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set(markmcp.HeaderProtocolVersion, markmcp.ProtocolVersion20260728) + req.Header.Set(markmcp.HeaderMethod, string(method)) + if name, ok := params["name"].(string); ok { + encoded, valid := markmcp.EncodeHeaderValue(name) + if !valid { + t.Fatalf("invalid MCP name %q", name) + } + req.Header.Set(markmcp.HeaderName, encoded) + } + + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = response.Body.Close() }) + if response.StatusCode != http.StatusOK { + var payload any + _ = json.NewDecoder(response.Body).Decode(&payload) + t.Fatalf("POST %s status = %d, payload = %#v", method, response.StatusCode, payload) + } + return response +} + +func requireRPCResult(t *testing.T, response *http.Response) map[string]any { + t.Helper() + var rpc map[string]any + if err := json.NewDecoder(response.Body).Decode(&rpc); err != nil { + t.Fatal(err) + } + result, ok := rpc["result"].(map[string]any) + if !ok { + t.Fatalf("JSON-RPC response = %#v", rpc) + } + if result["resultType"] != string(markmcp.ResultTypeComplete) { + t.Fatalf("resultType = %#v, want %q", result["resultType"], markmcp.ResultTypeComplete) + } + return result +} diff --git a/mcp_server/pkg/mcp/schema.go b/mcp_server/pkg/mcp/schema.go deleted file mode 100644 index 7461563b..00000000 --- a/mcp_server/pkg/mcp/schema.go +++ /dev/null @@ -1,169 +0,0 @@ -package mcp - -import ( - "encoding/json" - "reflect" - "strconv" - "strings" - - "github.com/mark3labs/mcp-go/mcp" -) - -// SchemaToOptions Convert struct to MCP ToolOption list -func SchemaToOptions(schema any) ([]mcp.ToolOption, error) { - t := reflect.TypeOf(schema) - if t.Kind() == reflect.Ptr { - t = t.Elem() - } - - var options []mcp.ToolOption - for i := 0; i < t.NumField(); i++ { - field := t.Field(i) - jsonTag := field.Tag.Get("json") - if jsonTag == "" { - continue - } - - desc := field.Tag.Get("desc") - required := field.Tag.Get("required") == "true" - enumTag := field.Tag.Get("enum") - defaultTag := field.Tag.Get("default") - minTag := field.Tag.Get("min") - maxTag := field.Tag.Get("max") - opts := []mcp.PropertyOption{} - - if desc != "" { - opts = append(opts, mcp.Description(desc)) - } - if required { - opts = append(opts, mcp.Required()) - } - if enumTag != "" && field.Type.Kind() == reflect.String { - enumValues := strings.Split(enumTag, ",") - opts = append(opts, mcp.Enum(enumValues...)) - } - - switch field.Type.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64: - if defaultTag != "" { - if defaultValue, err := strconv.Atoi(defaultTag); err == nil { - opts = append(opts, mcp.DefaultNumber(float64(defaultValue))) - } - } - if minTag != "" { - if minValue, err := strconv.Atoi(minTag); err == nil { - opts = append(opts, mcp.Min(float64(minValue))) - } - } - if maxTag != "" { - if maxValue, err := strconv.Atoi(maxTag); err == nil { - opts = append(opts, mcp.Max(float64(maxValue))) - } - } - options = append(options, mcp.WithNumber(jsonTag, opts...)) - case reflect.Bool: - if defaultTag != "" { - if defaultValue, err := strconv.ParseBool(defaultTag); err == nil { - opts = append(opts, mcp.DefaultBool(defaultValue)) - } - } - options = append(options, mcp.WithBoolean(jsonTag, opts...)) - case reflect.String: - if defaultTag != "" { - opts = append(opts, mcp.DefaultString(defaultTag)) - } - options = append(options, mcp.WithString(jsonTag, opts...)) - case reflect.Struct: - subSchema := reflect.New(field.Type).Interface() - subOptions, err := SchemaToOptions(subSchema) - if err != nil { - return nil, err - } - - // Create a temporary Tool to get JSON Schema of sub-struct - tempTool := mcp.NewTool("temp", subOptions...) - tempJSON, _ := tempTool.MarshalJSON() - var tempMap map[string]any - if err := json.Unmarshal(tempJSON, &tempMap); err != nil { - continue - } - - // Extract properties from temporary Tool - if inputSchema, ok := tempMap["inputSchema"].(map[string]any); ok { - if properties, ok := inputSchema["properties"].(map[string]any); ok { - // Check if there are required fields - if required, ok := inputSchema["required"].([]any); ok { - // Add required field information to corresponding properties - for _, req := range required { - if reqStr, ok := req.(string); ok { - if prop, ok := properties[reqStr].(map[string]any); ok { - prop["required"] = true - } - } - } - } - opts = append(opts, mcp.Properties(properties)) - } - } - options = append(options, mcp.WithObject(jsonTag, opts...)) - - case reflect.Slice: - elemType := field.Type.Elem() - var items map[string]any - - switch elemType.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64: - items = map[string]any{ - "type": "number", - } - case reflect.Bool: - items = map[string]any{ - "type": "boolean", - } - case reflect.String: - items = map[string]any{ - "type": "string", - } - case reflect.Struct: - subSchema := reflect.New(elemType).Interface() - subOptions, err := SchemaToOptions(subSchema) - if err != nil { - return nil, err - } - - // Create a temporary Tool to get JSON Schema of sub-struct - tempTool := mcp.NewTool("temp", subOptions...) - tempJSON, _ := tempTool.MarshalJSON() - var tempMap map[string]any - if err := json.Unmarshal(tempJSON, &tempMap); err != nil { - continue - } - - // Extract properties from temporary Tool - if inputSchema, ok := tempMap["inputSchema"].(map[string]any); ok { - if properties, ok := inputSchema["properties"].(map[string]any); ok { - // Check if there are required fields - if required, ok := inputSchema["required"].([]any); ok { - // Add required field information to corresponding properties - for _, req := range required { - if reqStr, ok := req.(string); ok { - if prop, ok := properties[reqStr].(map[string]any); ok { - prop["required"] = true - } - } - } - } - items = map[string]any{ - "type": "object", - "properties": properties, - } - } - } - } - opts = append(opts, mcp.Items(items)) - options = append(options, mcp.WithArray(jsonTag, opts...)) - } - } - - return options, nil -} diff --git a/mcp_server/pkg/mcp/schema_test.go b/mcp_server/pkg/mcp/schema_test.go deleted file mode 100644 index 6025f74f..00000000 --- a/mcp_server/pkg/mcp/schema_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package mcp - -import ( - "reflect" - "testing" - - "github.com/mark3labs/mcp-go/mcp" -) - -func TestSchemaToOptions(t *testing.T) { - tests := []struct { - name string - args any - want mcp.Tool - }{ - { - name: "test number", - args: struct { - A int `json:"a" desc:"number a" required:"true"` - }{}, - want: mcp.NewTool("test number", - mcp.WithNumber("a", mcp.Required(), mcp.Description("number a")), - ), - }, - { - name: "test number int64", - args: struct { - A int64 `json:"a" desc:"number a" required:"true"` - }{}, - want: mcp.NewTool("test number int64", - mcp.WithNumber("a", mcp.Required(), mcp.Description("number a")), - ), - }, - { - name: "test number float64", - args: struct { - A float64 `json:"a" desc:"number a" required:"true"` - }{}, - want: mcp.NewTool("test number float64", - mcp.WithNumber("a", mcp.Required(), mcp.Description("number a")), - ), - }, - { - name: "test number default", - args: struct { - A int `json:"a" desc:"number a" required:"true" default:"10"` - }{}, - want: mcp.NewTool("test number default", - mcp.WithNumber("a", mcp.Required(), mcp.Description("number a"), mcp.DefaultNumber(10)), - ), - }, - { - name: "test number min max", - args: struct { - A int `json:"a" desc:"number a" required:"true" min:"10" max:"20"` - }{}, - want: mcp.NewTool("test number min max", - mcp.WithNumber("a", mcp.Required(), mcp.Description("number a"), mcp.Min(10), mcp.Max(20)), - ), - }, - { - name: "test number optional", - args: struct { - A int `json:"a" desc:"number a"` - }{}, - want: mcp.NewTool("test number optional", - mcp.WithNumber("a", mcp.Description("number a")), - ), - }, - { - name: "test boolean", - args: struct { - A bool `json:"a" desc:"boolean a" required:"true"` - }{}, - want: mcp.NewTool("test boolean", - mcp.WithBoolean("a", mcp.Required(), mcp.Description("boolean a")), - ), - }, - { - name: "test string", - args: struct { - A string `json:"a" desc:"string a" required:"true"` - }{}, - want: mcp.NewTool("test string", - mcp.WithString("a", mcp.Required(), mcp.Description("string a")), - ), - }, - { - name: "test string default", - args: struct { - A string `json:"a" desc:"string a" required:"true" default:"hello"` - }{}, - want: mcp.NewTool("test string default", - mcp.WithString("a", mcp.Required(), mcp.Description("string a"), mcp.DefaultString("hello")), - ), - }, - { - name: "test string enum", - args: struct { - A string `json:"a" desc:"string a" required:"true" enum:"1,2,3"` - }{}, - want: mcp.NewTool("test string enum", - mcp.WithString("a", mcp.Required(), mcp.Description("string a"), mcp.Enum("1", "2", "3")), - ), - }, - { - name: "test object", - args: struct { - A struct { - B int `json:"b" desc:"number b" required:"true"` - } `json:"a" desc:"object a" required:"true"` - }{}, - want: mcp.NewTool("test object", - mcp.WithObject("a", mcp.Required(), mcp.Description("object a"), - mcp.Properties(map[string]any{ - "b": map[string]any{ - "type": "number", - "description": "number b", - "required": true, - }, - }), - ), - ), - }, - { - name: "test object optional", - args: struct { - A struct { - B int `json:"b" desc:"number b" required:"true"` - C int `json:"c" desc:"number c"` - } `json:"a" desc:"object a" required:"true"` - }{}, - want: mcp.NewTool("test object optional", - mcp.WithObject("a", mcp.Required(), mcp.Description("object a"), - mcp.Properties(map[string]any{ - "b": map[string]any{ - "type": "number", - "description": "number b", - "required": true, - }, - "c": map[string]any{ - "type": "number", - "description": "number c", - }, - }), - ), - ), - }, - { - name: "test nested object", - args: struct { - A struct { - B struct { - C int `json:"c" desc:"number c" required:"true"` - } `json:"b" desc:"object b" required:"true"` - } `json:"a" desc:"object a" required:"true"` - }{}, - want: mcp.NewTool("test nested object", - mcp.WithObject("a", mcp.Required(), mcp.Description("object a"), - mcp.Properties(map[string]any{ - "b": map[string]any{ - "type": "object", - "description": "object b", - "required": true, - "properties": map[string]any{ - "c": map[string]any{ - "type": "number", - "description": "number c", - "required": true, - }, - }, - }, - }), - ), - ), - }, - { - name: "test array", - args: struct { - A []int `json:"a" desc:"array a" required:"true"` - }{}, - want: mcp.NewTool("test array", - mcp.WithArray("a", mcp.Required(), mcp.Description("array a"), - mcp.Items(map[string]any{ - "type": "number", - }), - ), - ), - }, - { - name: "test array of object", - args: struct { - A []struct { - B int `json:"b" desc:"number b" required:"true"` - } `json:"a" desc:"array of object a" required:"true"` - }{}, - want: mcp.NewTool("test array of object", - mcp.WithArray("a", mcp.Required(), mcp.Description("array of object a"), - mcp.Items(map[string]any{ - "type": "object", - "properties": map[string]any{ - "b": map[string]any{ - "type": "number", - "description": "number b", - "required": true, - }, - }, - }), - ), - ), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := SchemaToOptions(tt.args) - if err != nil { - t.Errorf("SchemaToOptions() error = %v", err) - return - } - s1, _ := mcp.NewTool(tt.name, got...).MarshalJSON() - s2, _ := tt.want.MarshalJSON() - if !reflect.DeepEqual(s1, s2) { - t.Errorf("\n got %v\n want %v", string(s1), string(s2)) - } - }) - } -} From e6e1a71c9e631f18728eceb073adb5841b814163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8E=E9=91=AB?= Date: Wed, 26 Aug 2026 04:34:39 +0800 Subject: [PATCH 12/31] fix(mcp): upgrade Go toolchain to 1.25.14 --- mcp_server/Dockerfile | 4 +++- mcp_server/README.md | 2 +- mcp_server/go.mod | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/mcp_server/Dockerfile b/mcp_server/Dockerfile index b4ec4665..3eaf0072 100644 --- a/mcp_server/Dockerfile +++ b/mcp_server/Dockerfile @@ -1,4 +1,6 @@ -FROM golang:1.25.5-alpine AS builder +FROM golang:1.25.14-alpine3.23 AS builder + +ARG GOPROXY WORKDIR /src diff --git a/mcp_server/README.md b/mcp_server/README.md index ba087fce..7ec1c87a 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -11,7 +11,7 @@ The original site-creation, blacklist, and whitelist Tools have been removed. Th ## Protocol - MCP SDK: `mcp-go v1.0.0-beta.1` -- Go: `1.25.5` or newer +- Go: `1.25.14` or newer - Transport: Streamable HTTP - Endpoint: `POST /mcp` - Latest supported MCP revision: `2026-07-28` diff --git a/mcp_server/go.mod b/mcp_server/go.mod index ed2a711d..97db03b3 100644 --- a/mcp_server/go.mod +++ b/mcp_server/go.mod @@ -1,6 +1,6 @@ module github.com/chaitin/SafeLine/mcp_server -go 1.25.5 +go 1.25.14 require ( github.com/mark3labs/mcp-go v1.0.0-beta.1 From c18b2c9cbad5c2f1b650281491512da035ee175a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8E=E9=91=AB?= Date: Wed, 26 Aug 2026 04:51:40 +0800 Subject: [PATCH 13/31] feat(mcp): publish friendly instance mappings --- mcp_server/README.md | 4 +- mcp_server/internal/config/config.go | 24 +++++++++ mcp_server/internal/config/config_test.go | 42 +++++++++++++++ mcp_server/main.go | 17 +++++- mcp_server/main_test.go | 38 ++++++++++++++ mcp_server/pkg/mcp/mcp.go | 3 +- mcp_server/pkg/mcp/mcp_test.go | 64 ++++++++++++++++++++++- 7 files changed, 187 insertions(+), 5 deletions(-) diff --git a/mcp_server/README.md b/mcp_server/README.md index 7ec1c87a..e436e1ce 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -57,6 +57,8 @@ instances: `LISTEN_ADDRESS` and `LISTEN_PORT` override listener settings. There is deliberately no global `SAFELINE_API_TOKEN`: every instance uses a separate token file. +Each `display_name` must be unique, ignoring letter case, and must not match another instance's `id`. During server discovery (or legacy initialization), the server publishes only the `display_name` to `instance_id` mappings as Server Instructions. This lets a user refer to a friendly name while the AI still calls `get_attack_events` with the stable, explicit `instance_id`. Instance addresses and credentials are never included in those instructions. + SafeLine CE does not yet provide a dedicated read-only service credential for all required management APIs. This release therefore uses an administrator API Token per instance, while the MCP process enforces the fixed read-only API allowlist. ## AI client authentication @@ -184,4 +186,4 @@ Unknown input fields and fractional integers are rejected by server-side JSON Sc - `nodes` - `total` -SafeLine HTTP failures, HTTP-200 error envelopes, unknown instances, and input validation failures are returned as MCP Tool Execution Errors with `result.isError=true`; they are never converted into an empty successful event list. +SafeLine HTTP failures, HTTP-200 envelopes with a non-empty `err`, unknown instances, and input validation failures are returned as MCP Tool Execution Errors with `result.isError=true`. The MCP server does not interpret human-readable `msg` content when `err` is empty or absent. diff --git a/mcp_server/internal/config/config.go b/mcp_server/internal/config/config.go index 8dd113a7..17125089 100644 --- a/mcp_server/internal/config/config.go +++ b/mcp_server/internal/config/config.go @@ -152,6 +152,30 @@ func normalizeInstances(cfg *Config, configDir string) error { } } + for index, instance := range cfg.Instances { + for previousIndex := 0; previousIndex < index; previousIndex++ { + previous := cfg.Instances[previousIndex] + if strings.EqualFold(instance.DisplayName, previous.DisplayName) { + return errors.New(fmt.Sprintf( + "duplicate instance display_name %q for instances %q and %q", + instance.DisplayName, + previous.ID, + instance.ID, + )) + } + } + for otherIndex, other := range cfg.Instances { + if otherIndex != index && strings.EqualFold(instance.DisplayName, other.ID) { + return errors.New(fmt.Sprintf( + "instance display_name %q for %q conflicts with instance id %q", + instance.DisplayName, + instance.ID, + other.ID, + )) + } + } + } + return nil } diff --git a/mcp_server/internal/config/config_test.go b/mcp_server/internal/config/config_test.go index 7238a4b6..7940903c 100644 --- a/mcp_server/internal/config/config_test.go +++ b/mcp_server/internal/config/config_test.go @@ -78,6 +78,48 @@ instances: } } +func TestLoadRejectsDuplicateDisplayNames(t *testing.T) { + path := writeConfigFile(t, ` +server: {name: SafeLine MCP, version: 1.0.0, host: 127.0.0.1, port: 5678} +logger: {level: info} +instances: + - id: production-a + display_name: Production + base_url: https://one.example.test + token_file: one_token + - id: production-b + display_name: " production " + base_url: https://two.example.test + token_file: two_token +`) + + err := Load(path) + if err == nil || !strings.Contains(err.Error(), "duplicate instance display_name") { + t.Fatalf("Load() error = %v, want duplicate instance display_name", err) + } +} + +func TestLoadRejectsDisplayNameConflictingWithAnotherInstanceID(t *testing.T) { + path := writeConfigFile(t, ` +server: {name: SafeLine MCP, version: 1.0.0, host: 127.0.0.1, port: 5678} +logger: {level: info} +instances: + - id: production-a + display_name: PRODUCTION-B + base_url: https://one.example.test + token_file: one_token + - id: production-b + display_name: Backup Production + base_url: https://two.example.test + token_file: two_token +`) + + err := Load(path) + if err == nil || !strings.Contains(err.Error(), "conflicts with instance id") { + t.Fatalf("Load() error = %v, want display_name and instance id conflict", err) + } +} + func TestLoadRejectsInlineInstanceToken(t *testing.T) { path := writeConfigFile(t, ` server: {name: SafeLine MCP, version: 1.0.0, host: 127.0.0.1, port: 5678} diff --git a/mcp_server/main.go b/mcp_server/main.go index 85609479..8ffa24ce 100644 --- a/mcp_server/main.go +++ b/mcp_server/main.go @@ -74,6 +74,21 @@ func printNewToken(output io.Writer, token string) { _, _ = fmt.Fprintln(output, token) } +func buildServerInstructions(instances []*config.InstanceConfig) string { + var instructions strings.Builder + instructions.WriteString("This server exposes read-only SafeLine data. The get_attack_events tool requires an instance_id.\n") + instructions.WriteString("Configured SafeLine instance mappings:\n") + for _, instance := range instances { + instructions.WriteString(fmt.Sprintf( + "- display_name %q -> instance_id %q\n", + instance.DisplayName, + instance.ID, + )) + } + instructions.WriteString("When the user refers to an instance by display_name, use exactly the mapped instance_id. Never guess an instance_id or substitute another instance.") + return instructions.String() +} + func runServer(args []string, stderr io.Writer) error { flags := flag.NewFlagSet("mcp-server", flag.ContinueOnError) flags.SetOutput(stderr) @@ -122,7 +137,7 @@ func runServer(args []string, stderr io.Writer) error { logger.With("enabled", authEnabled).Info("MCP bearer authentication configured") serverConfig := config.GetServer() - s := mcpserver.NewMCPServer(serverConfig.Name, serverConfig.Version) + s := mcpserver.NewMCPServer(serverConfig.Name, serverConfig.Version, buildServerInstructions(instances)) for _, tool := range tools.Tools() { if err := tool.Register(s); err != nil { return fmt.Errorf("register tool: %w", err) diff --git a/mcp_server/main_test.go b/mcp_server/main_test.go index b89611f8..25525515 100644 --- a/mcp_server/main_test.go +++ b/mcp_server/main_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/chaitin/SafeLine/mcp_server/internal/auth" + "github.com/chaitin/SafeLine/mcp_server/internal/config" ) func TestAuthInitIsOneTimeAndRotateIsExplicit(t *testing.T) { @@ -58,3 +59,40 @@ func lastLine(output string) string { lines := strings.Split(strings.TrimSpace(output), "\n") return lines[len(lines)-1] } + +func TestBuildServerInstructionsIncludesFriendlyInstanceMappings(t *testing.T) { + instructions := buildServerInstructions([]*config.InstanceConfig{ + { + ID: "dev152", + DisplayName: "Development 152", + BaseURL: "https://secret.internal", + TokenFile: "/run/secrets/dev152.token", + }, + { + ID: "dev180", + DisplayName: "Development 180", + BaseURL: "https://other.internal", + TokenFile: "/run/secrets/dev180.token", + }, + }) + + for _, expected := range []string{ + `display_name "Development 152" -> instance_id "dev152"`, + `display_name "Development 180" -> instance_id "dev180"`, + "Never guess an instance_id", + } { + if !strings.Contains(instructions, expected) { + t.Errorf("instructions = %q, want substring %q", instructions, expected) + } + } + for _, sensitive := range []string{ + "secret.internal", + "other.internal", + "/run/secrets/dev152.token", + "/run/secrets/dev180.token", + } { + if strings.Contains(instructions, sensitive) { + t.Fatalf("instructions exposed instance configuration %q: %q", sensitive, instructions) + } + } +} diff --git a/mcp_server/pkg/mcp/mcp.go b/mcp_server/pkg/mcp/mcp.go index a7361a6d..c92d3932 100644 --- a/mcp_server/pkg/mcp/mcp.go +++ b/mcp_server/pkg/mcp/mcp.go @@ -40,10 +40,11 @@ type MCPServer struct { transport *server.StreamableHTTPServer } -func NewMCPServer(name, version string) *MCPServer { +func NewMCPServer(name, version, instructions string) *MCPServer { s := server.NewMCPServer( name, version, + server.WithInstructions(instructions), server.WithToolCapabilities(false), server.WithInputSchemaValidation(), server.WithStrictInputSchemaDefault(), diff --git a/mcp_server/pkg/mcp/mcp_test.go b/mcp_server/pkg/mcp/mcp_test.go index a8dcec25..7ff219a7 100644 --- a/mcp_server/pkg/mcp/mcp_test.go +++ b/mcp_server/pkg/mcp/mcp_test.go @@ -43,7 +43,8 @@ func TestModernStreamableHTTPAndToolErrorSemantics(t *testing.T) { if err := logger.Init(&logger.Config{Level: "error"}); err != nil { t.Fatal(err) } - s := NewMCPServer("test", "1.0.0") + const instructions = "Use the configured display-name to instance-ID mappings." + s := NewMCPServer("test", "1.0.0", instructions) if err := RegisterTool[probeParams, probeResult](s, probeTool{}); err != nil { t.Fatal(err) } @@ -52,6 +53,9 @@ func TestModernStreamableHTTPAndToolErrorSemantics(t *testing.T) { discover := postModern(t, httpServer.URL+EndpointPath, markmcp.MethodServerDiscover, map[string]any{}) result := requireRPCResult(t, discover) + if result["instructions"] != instructions { + t.Fatalf("instructions = %#v, want %q", result["instructions"], instructions) + } versions, ok := result["supportedVersions"].([]any) if !ok || len(versions) == 0 || versions[0] != markmcp.ProtocolVersion20260728 { t.Fatalf("supportedVersions = %#v, want %s first", result["supportedVersions"], markmcp.ProtocolVersion20260728) @@ -101,7 +105,7 @@ func TestModernStreamableHTTPAndToolErrorSemantics(t *testing.T) { } func TestHandlerRejectsOversizedRequestBody(t *testing.T) { - s := NewMCPServer("test", "1.0.0") + s := NewMCPServer("test", "1.0.0", "") body := strings.NewReader(strings.Repeat("x", maxRequestBodySize+1)) req := httptest.NewRequest(http.MethodPost, EndpointPath, body) req.Header.Set("Content-Type", "application/json") @@ -113,6 +117,62 @@ func TestHandlerRejectsOversizedRequestBody(t *testing.T) { } } +func TestLegacyInitializeReturnsInstructions(t *testing.T) { + const instructions = "Use the configured display-name to instance-ID mappings." + s := NewMCPServer("test", "1.0.0", instructions) + httpServer := httptest.NewServer(s.Handler()) + defer httpServer.Close() + + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": markmcp.MethodInitialize, + "params": map[string]any{ + "protocolVersion": markmcp.ProtocolVersion20251125, + "clientInfo": map[string]any{ + "name": "safeline-mcp-legacy-test", + "version": "1.0.0", + }, + "capabilities": map[string]any{}, + }, + }) + if err != nil { + t.Fatal(err) + } + + req, err := http.NewRequestWithContext( + t.Context(), + http.MethodPost, + httpServer.URL+EndpointPath, + bytes.NewReader(body), + ) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("initialize status = %d, want 200", response.StatusCode) + } + + var rpc map[string]any + if err := json.NewDecoder(response.Body).Decode(&rpc); err != nil { + t.Fatal(err) + } + result, ok := rpc["result"].(map[string]any) + if !ok { + t.Fatalf("initialize response = %#v, want result", rpc) + } + if result["instructions"] != instructions { + t.Fatalf("instructions = %#v, want %q", result["instructions"], instructions) + } +} + func postModern(t *testing.T, url string, method markmcp.MCPMethod, params map[string]any) *http.Response { t.Helper() params["_meta"] = map[string]any{ From 25b603b1e020108e47cb08fefc9e838a33fc64c0 Mon Sep 17 00:00:00 2001 From: wenwen Date: Thu, 3 Sep 2026 11:19:47 +0800 Subject: [PATCH 14/31] Refine bug report template fields and descriptions Updated the bug report template to improve clarity and required fields. --- .github/ISSUE_TEMPLATE/bug-report.yaml | 55 +++++++++++++++----------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug-report.yaml b/.github/ISSUE_TEMPLATE/bug-report.yaml index cc7996c0..cbce92d1 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/bug-report.yaml @@ -1,6 +1,5 @@ name: Bug description: Report a bug -# Create a report to help us improve title: "[Bug] " body: @@ -8,38 +7,46 @@ body: attributes: value: | Please search the [open issues](https://github.com/chaitin/SafeLine/issues) and [discussion](https://github.com/chaitin/SafeLine/discussions) for duplicate issue first. - The more information you share, the faster we can identify and fix the bug. - # Please check for duplicate issue first. + + - type: input + id: version + attributes: + label: SafeLine version + description: | + Please provide the exact SafeLine version. + For nightly or custom builds, provide the image tag or commit ID. + placeholder: "e.g. 9.4.0" + validations: + required: true + - type: textarea - id: Description + id: description attributes: - label: What happened? - # Describe the bug + label: Bug description + description: Please describe what happened and what you expected to happen. + value: | + What happened: + + What did you expect: validations: - required: false + required: true + - type: textarea - id: Reproduce + id: reproduce attributes: - label: How we reproduce? - description: | - Reports cannot be reproduced will Most likely be closed. - # To Reproduce + label: Steps to reproduce (if available) + description: If you can reproduce the issue, list the minimum steps. value: | 1. ... 2. ... 3. ... + - type: textarea - id: Expected - attributes: - label: Expected behavior - # placeholder: | - # Descript what you expected to happen. - # Expected behavior. Descript what you expected to happen. - - type: textarea - id: Errorlog + id: evidence attributes: - label: Error log + label: Logs or screenshots (optional) + description: | + Paste relevant logs or drag screenshots here. + Please remove sensitive information before submitting. placeholder: | - Paste the error logs if any. - # Expected behavior. Descript what you expected to happen. - + Paste logs or screenshots here. From 14965932dcf85b19be9779662c5738ea03ccb929 Mon Sep 17 00:00:00 2001 From: wenwen Date: Thu, 3 Sep 2026 11:26:06 +0800 Subject: [PATCH 15/31] Revise feature request template wording Updated the feature request template for clarity and improved instructions. --- .github/ISSUE_TEMPLATE/feature-request.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml index 371e2e15..85b57a38 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -1,5 +1,4 @@ name: Suggestion -# Feature request description: New feature or improvements. title: "[Suggestion] " @@ -8,21 +7,22 @@ body: attributes: value: | Please search the [open issues](https://github.com/chaitin/SafeLine/issues) and [discussion](https://github.com/chaitin/SafeLine/discussions) for duplicate issue first. - Please rise only one suggestion in an issue. + Please submit only one suggestion in an issue. + - type: textarea id: solution attributes: label: What would you like to be added or improved? - # Describe the solution you'd like - # placeholder: | - # + description: Please describe the feature or improvement you would like to see. + placeholder: Describe the desired feature or improvement. + validations: + required: true + - type: textarea id: problem attributes: label: Why is it needed? - # Background and the specific problem that frustrates you - placeholder: | - Background and the problem that frustrates you - + description: Please explain the background and the problem this suggestion would solve. + placeholder: Describe the background and the problem that frustrates you. validations: required: true From e984047791fec3b6dad8c809361b5727bdeae171 Mon Sep 17 00:00:00 2001 From: Stayer Date: Thu, 10 Sep 2026 18:17:39 +0800 Subject: [PATCH 16/31] docs: update Chinese README links to help.waf-ce.chaitin.cn Point the Quickstart and README_CN documentation links to the new help center pages for install, docs home, and site protection setup. --- README.md | 2 +- README_CN.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0a8139bd..2100500d 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ List of the main features as follows: ## 🚀 Quickstart > [!WARNING] -> 中国大陆用户安装国际版可能会导致无法连接云服务,请查看 [中文版安装文档](https://docs.waf-ce.chaitin.cn/zh/%E4%B8%8A%E6%89%8B%E6%8C%87%E5%8D%97/%E5%AE%89%E8%A3%85%E9%9B%B7%E6%B1%A0) +> 中国大陆用户安装国际版可能会导致无法连接云服务,请查看 [中文版安装文档](https://help.waf-ce.chaitin.cn/node/01973fc6-df0f-7650-bafa-8ed8d2fc2bc1) #### 📦 Installing diff --git a/README_CN.md b/README_CN.md index 199c2282..ffd6bf94 100644 --- a/README_CN.md +++ b/README_CN.md @@ -8,7 +8,7 @@

🏠 官网   |   - 📖 文档   |   + 📖 文档   |   🔍 演示环境   |   🙋‍♂️ 社区微信群   |   国际版 @@ -90,11 +90,11 @@ SafeLine,中文名 "雷池",是一款简单好用, 效果突出的 **`Web #### 📦 安装 -查看 [安装雷池](https://docs.waf-ce.chaitin.cn/zh/%E4%B8%8A%E6%89%8B%E6%8C%87%E5%8D%97/%E5%AE%89%E8%A3%85%E9%9B%B7%E6%B1%A0) +查看 [安装雷池](https://help.waf-ce.chaitin.cn/node/01973fc6-df0f-7650-bafa-8ed8d2fc2bc1) #### ⚙️ 配置防护站点 -查看 [快速配置](https://docs.waf-ce.chaitin.cn/zh/%E4%B8%8A%E6%89%8B%E6%8C%87%E5%8D%97/%E5%BF%AB%E9%80%9F%E9%85%8D%E7%BD%AE) +查看 [快速配置](https://help.waf-ce.chaitin.cn/node/01973fc6-e14a-7234-8acd-bdba21c8b3f3) ## 📋 更多信息 From 489af0f5823b8558828e43c7c3f676fd1f84fc29 Mon Sep 17 00:00:00 2001 From: "jialong.wang" Date: Fri, 18 Sep 2026 13:15:48 +0800 Subject: [PATCH 17/31] fix(management): close the security scan findings in mgt-api and tcontrollerd Static analysis of management/webserver and management/tcontrollerd reported eight defects; all of them are addressed here. - main.go: `len(noAuth) >= 0` was always true, so setting NO_AUTH to any value (an empty string included) skipped AuthRequired and disabled authentication entirely. Both NO_AUTH and READ_ONLY now require an explicit truthy value. - main.go: remove the debug log that printed the session signing secret. - main.go: move POST /api/FalsePositives to the authenticated router. An unauthenticated caller could enumerate event ids and make the server forward full detection data to the telemetry endpoint. - model/website.go, api/website.go, tcontrollerd/controller: validate the ports and server names before storing them or rendering them into nginx directives. Only decimal ports and literal host names are accepted, so a value can no longer inject nginx configuration through listen or server_name. - pkg/sessionopts: the session cookie now sets HttpOnly, SameSite=Lax and Secure (outside of the plain HTTP development setup) for the login handler and the authentication middleware alike. - utils/httpclient.go, pkg/fvm/fvm.go: restore TLS certificate verification for the telemetry/upgrade client and for the FSL push to the detector. Verified with go build and go vet on both modules, and with focused tests covering injected ports and server names, unchanged output for legitimate sites, the environment switch truth table and the cookie flags. --- .../tcontrollerd/controller/validation.go | 68 ++++++++++ management/tcontrollerd/controller/website.go | 5 + management/webserver/api/auth.go | 10 +- management/webserver/api/website.go | 12 ++ management/webserver/main.go | 26 ++-- management/webserver/middleware/auth.go | 10 +- management/webserver/model/website.go | 120 +++++++++++++++++- management/webserver/pkg/fvm/fvm.go | 11 +- .../webserver/pkg/sessionopts/sessionopts.go | 60 +++++++++ management/webserver/utils/httpclient.go | 7 +- 10 files changed, 294 insertions(+), 35 deletions(-) create mode 100644 management/tcontrollerd/controller/validation.go create mode 100644 management/webserver/pkg/sessionopts/sessionopts.go diff --git a/management/tcontrollerd/controller/validation.go b/management/tcontrollerd/controller/validation.go new file mode 100644 index 00000000..1687befb --- /dev/null +++ b/management/tcontrollerd/controller/validation.go @@ -0,0 +1,68 @@ +package controller + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +const ( + // maxPort is the largest TCP port that may be listened on. + maxPort = 65535 + + // maxServerNameLength and maxLabelLength follow the DNS limits. + maxServerNameLength = 253 + maxLabelLength = 63 +) + +var ( + // portPattern matches the decimal notation of a TCP port. + portPattern = regexp.MustCompile(`^[0-9]{1,5}$`) + + // serverNameLabelPattern matches one label of a literal host name. The + // accepted character set is deliberately narrow: the value ends up in an + // nginx "server_name" directive, so anything that could terminate the + // directive (or start another one) must not pass. + serverNameLabelPattern = regexp.MustCompile(`^[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?$`) +) + +// validatePort accepts the decimal notation of a TCP port. It rejects any +// other value because the port is interpolated into a "listen" directive. +func validatePort(port string) error { + if !portPattern.MatchString(port) { + return fmt.Errorf("invalid port %q: only digits are allowed", port) + } + + value, err := strconv.Atoi(port) + if err != nil || value < 1 || value > maxPort { + return fmt.Errorf("invalid port %q: must be between 1 and %d", port, maxPort) + } + + return nil +} + +// validateServerName accepts a literal host name, optionally with a wildcard +// as its first label. It rejects any other value because the name is +// interpolated into a "server_name" directive. +func validateServerName(name string) error { + if len(name) > maxServerNameLength { + return fmt.Errorf("invalid server name %q: longer than %d characters", name, maxServerNameLength) + } + + for i, label := range strings.Split(name, ".") { + if label == "*" { + // nginx only accepts a wildcard as the left most label. + if i != 0 { + return fmt.Errorf("invalid server name %q: wildcard must be the first label", name) + } + continue + } + + if len(label) > maxLabelLength || !serverNameLabelPattern.MatchString(label) { + return fmt.Errorf("invalid server name %q: %q is not a valid host name label", name, label) + } + } + + return nil +} diff --git a/management/tcontrollerd/controller/website.go b/management/tcontrollerd/controller/website.go index 48bf716e..e2a2de2a 100644 --- a/management/tcontrollerd/controller/website.go +++ b/management/tcontrollerd/controller/website.go @@ -70,6 +70,8 @@ func generateNginxConfig(website *model.WebsiteConfig) (string, error) { if sn == "*" || sn == "" { sn = "_" addrAnyProperties = addrAnyPropertiesTpl + } else if err := validateServerName(sn); err != nil { + return "", err } serverName = fmt.Sprintf(serverNameTpl, sn) } @@ -77,6 +79,9 @@ func generateNginxConfig(website *model.WebsiteConfig) (string, error) { // only ONE port supported in v1.0 var serverListen string for _, port := range website.Ports { + if err := validatePort(port); err != nil { + return "", err + } serverListen = fmt.Sprintf(serverListenTpl, port, sslFlag, addrAnyProperties) } diff --git a/management/webserver/api/auth.go b/management/webserver/api/auth.go index c0b34c6c..b9d46b4e 100644 --- a/management/webserver/api/auth.go +++ b/management/webserver/api/auth.go @@ -15,6 +15,7 @@ import ( "chaitin.cn/patronus/safeline-2/management/webserver/pkg/constants" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/database" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/log" + "chaitin.cn/patronus/safeline-2/management/webserver/pkg/sessionopts" ) var logger = log.GetLogger("api") @@ -70,14 +71,7 @@ func PostLogin(c *gin.Context) { db.Save(&user) session := sessions.Default(c) - session.Options(sessions.Options{ - Path: "/", - MaxAge: 3600 * 24 * 7, - //Domain: options.Domain, - //HttpOnly: true, - //SameSite: http.SameSiteLaxMode, - //Secure: false, - }) + session.Options(sessionopts.Options(c)) session.Set(constants.DefaultSessionUserKey, user.ID) if err := session.Save(); err != nil { logger.Error(err) diff --git a/management/webserver/api/website.go b/management/webserver/api/website.go index c21b3d43..1a9a8bd1 100644 --- a/management/webserver/api/website.go +++ b/management/webserver/api/website.go @@ -53,6 +53,12 @@ func PostWebsite(ctx *gin.Context) { return } + if err := params.Validate(); err != nil { + logger.Warn(err) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusBadRequest) + return + } + db := database.GetDB() err := db.Transaction(func(tx *gorm.DB) error { website := &model.Website{Comment: params.Comment, ServerNames: params.ServerNames, Upstreams: params.Upstreams, Ports: params.Ports, @@ -90,6 +96,12 @@ func PutWebsite(ctx *gin.Context) { return } + if err := params.Validate(); err != nil { + logger.Warn(err) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusBadRequest) + return + } + db := database.GetDB() err := db.Transaction(func(tx *gorm.DB) error { var website model.Website diff --git a/management/webserver/main.go b/management/webserver/main.go index 43e65851..9357f045 100644 --- a/management/webserver/main.go +++ b/management/webserver/main.go @@ -139,7 +139,6 @@ func main() { var option model.Options database.GetDB().Where(&model.Options{Key: constants.SecretKey}).First(&option) - logger.Debugf("Secret: %s", option.Value) store := cookie.NewStore([]byte(option.Value)) r.Use(sessions.Sessions("session", store)) @@ -147,7 +146,6 @@ func main() { publicRouters.POST(api.Login, api.PostLogin) publicRouters.POST(api.Logout, api.PostLogout) publicRouters.POST(api.Behaviour, api.PostBehaviour) - publicRouters.POST(api.FalsePositives, api.PostFalsePositives) publicRouters.GET(api.OTPUrl, api.GetOTPUrl) publicRouters.GET(api.Version, api.GetVersion) publicRouters.GET(api.UpgradeTips, api.GetUpgradeTips) @@ -159,15 +157,13 @@ func main() { }) limitedRouters := r.Group("/api") - noAuth, existed := os.LookupEnv("NO_AUTH") - if existed && len(noAuth) >= 0 { - logger.Warn("No auth") + if envEnabled(os.Getenv("NO_AUTH")) { + logger.Warn("Authentication is disabled because NO_AUTH is enabled") } else { limitedRouters.Use(middleware.AuthRequired) } - readOnly, existed := os.LookupEnv("READ_ONLY") - if existed && len(readOnly) >= 0 { - logger.Warn("Read only") + if envEnabled(os.Getenv("READ_ONLY")) { + logger.Warn("Write requests are rejected because READ_ONLY is enabled") limitedRouters.Use(middleware.ReadOnly) } @@ -175,6 +171,7 @@ func main() { limitedRouters.GET(api.DetectLogList, api.GetDetectLogList) limitedRouters.GET(api.DetectLogDetail, api.GetDetectLogDetail) + limitedRouters.POST(api.FalsePositives, api.PostFalsePositives) limitedRouters.POST(api.Website, api.PostWebsite) limitedRouters.PUT(api.Website, api.PutWebsite) @@ -208,3 +205,16 @@ func main() { logger.Fatalln("Error occurred when running web server: ", err) } } + +// envEnabled reports whether an environment variable holds an explicit truthy +// value. Switches such as NO_AUTH and READ_ONLY disable a protection, so only +// values that actually ask for it may enable them: an empty string (or any +// other unparsable value) must never do it. +func envEnabled(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} diff --git a/management/webserver/middleware/auth.go b/management/webserver/middleware/auth.go index f200175c..2fbf3171 100644 --- a/management/webserver/middleware/auth.go +++ b/management/webserver/middleware/auth.go @@ -8,6 +8,7 @@ import ( "chaitin.cn/patronus/safeline-2/management/webserver/api/response" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/constants" + "chaitin.cn/patronus/safeline-2/management/webserver/pkg/sessionopts" ) func AuthRequired(c *gin.Context) { @@ -21,14 +22,7 @@ func AuthRequired(c *gin.Context) { } // extend session expired time - session.Options(sessions.Options{ - Path: "/", - MaxAge: 3600 * 24 * 7, - //Domain: options.Domain, - //HttpOnly: true, - //SameSite: http.SameSiteLaxMode, - //Secure: false, - }) + session.Options(sessionopts.Options(c)) if err := session.Save(); err != nil { response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when creating sessions"}, http.StatusInternalServerError) diff --git a/management/webserver/model/website.go b/management/webserver/model/website.go index 4eeb7bbf..e86920c1 100644 --- a/management/webserver/model/website.go +++ b/management/webserver/model/website.go @@ -1,6 +1,34 @@ package model -import "gorm.io/datatypes" +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + + "gorm.io/datatypes" +) + +const ( + // maxPort is the largest TCP port that may be listened on. + maxPort = 65535 + + // maxServerNameLength and maxLabelLength follow the DNS limits. + maxServerNameLength = 253 + maxLabelLength = 63 +) + +var ( + // portPattern matches the decimal notation of a TCP port. + portPattern = regexp.MustCompile(`^[0-9]{1,5}$`) + + // serverNameLabelPattern matches one label of a literal host name. The + // accepted character set is deliberately narrow: the value ends up in an + // nginx "server_name" directive, so anything that could terminate the + // directive (or start another one) must not pass. + serverNameLabelPattern = regexp.MustCompile(`^[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?$`) +) type Website struct { Base @@ -14,3 +42,93 @@ type Website struct { IsEnabled bool `gorm:"is_enabled;default=true" json:"is_enabled"` } + +// Validate checks the fields that tcontrollerd renders into the nginx +// configuration of the site. +// +// Ports and server names are interpolated into nginx directives, so they have +// to be plain ports and host names: a value carrying nginx syntax must be +// rejected here, before it is stored and pushed to the engine. +func (w *Website) Validate() error { + serverNames, err := unmarshalStringList(w.ServerNames) + if err != nil { + return fmt.Errorf("invalid server_names: %v", err) + } + + ports, err := unmarshalStringList(w.Ports) + if err != nil { + return fmt.Errorf("invalid ports: %v", err) + } + + for _, serverName := range serverNames { + if err = ValidateServerName(serverName); err != nil { + return err + } + } + + for _, port := range ports { + if err = ValidatePort(port); err != nil { + return err + } + } + + return nil +} + +// ValidatePort accepts the decimal notation of a TCP port. +func ValidatePort(port string) error { + if !portPattern.MatchString(port) { + return fmt.Errorf("invalid port %q: only digits are allowed", port) + } + + value, err := strconv.Atoi(port) + if err != nil || value < 1 || value > maxPort { + return fmt.Errorf("invalid port %q: must be between 1 and %d", port, maxPort) + } + + return nil +} + +// ValidateServerName accepts a literal host name, optionally with a wildcard +// as its first label, or "*" for the default site. +func ValidateServerName(name string) error { + // "*" (and the empty string, which the console sends for the same purpose) + // makes the site the default server and is rendered as "_". + if name == "*" || name == "" { + return nil + } + + if len(name) > maxServerNameLength { + return fmt.Errorf("invalid server name %q: longer than %d characters", name, maxServerNameLength) + } + + for i, label := range strings.Split(name, ".") { + if label == "*" { + // nginx only accepts a wildcard as the left most label. + if i != 0 { + return fmt.Errorf("invalid server name %q: wildcard must be the first label", name) + } + continue + } + + if len(label) > maxLabelLength || !serverNameLabelPattern.MatchString(label) { + return fmt.Errorf("invalid server name %q: %q is not a valid host name label", name, label) + } + } + + return nil +} + +// unmarshalStringList decodes one of the JSON list columns of a website. +func unmarshalStringList(raw datatypes.JSON) ([]string, error) { + if len(raw) == 0 { + return nil, nil + } + + var values []string + if err := json.Unmarshal(raw, &values); err != nil { + return nil, err + } + + return values, nil +} diff --git a/management/webserver/pkg/fvm/fvm.go b/management/webserver/pkg/fvm/fvm.go index 32a34390..957a7433 100644 --- a/management/webserver/pkg/fvm/fvm.go +++ b/management/webserver/pkg/fvm/fvm.go @@ -19,7 +19,6 @@ import ( "C" //nolint:typecheck ) import ( - "crypto/tls" "fmt" "io" "net/http" @@ -209,11 +208,11 @@ func (f *FVM) PushFsl(server string, update *FVMUpdate) error { req.Header.Add("Content-Type", "application/octet-stream") // req.Header.Set("Connection", "close") - httpClient := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } + // The compiled policy is pushed to the detector over the container network + // and replaces the bytecode the engine executes, so the certificate of an + // HTTPS detector must be verified: a man in the middle could otherwise + // replace the policy and silently disable the WAF. + httpClient := &http.Client{} var ( respErr error diff --git a/management/webserver/pkg/sessionopts/sessionopts.go b/management/webserver/pkg/sessionopts/sessionopts.go new file mode 100644 index 00000000..41595292 --- /dev/null +++ b/management/webserver/pkg/sessionopts/sessionopts.go @@ -0,0 +1,60 @@ +// Package sessionopts holds the cookie attributes of the management console +// session. The login handler and the authentication middleware share it so the +// flags cannot drift apart from each other. +package sessionopts + +import ( + "net/http" + "strings" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + + "chaitin.cn/patronus/safeline-2/management/webserver/pkg/config" +) + +// maxAge is the lifetime of a management console session: one week. +const maxAge = 3600 * 24 * 7 + +// Options returns the cookie attributes used for the management console +// session. +// +// HttpOnly keeps the session cookie out of reach of JavaScript, SameSite=Lax +// prevents browsers from attaching the cookie to cross-site requests (which +// would otherwise turn the state changing API endpoints into CSRF targets), +// and Secure keeps the cookie on HTTPS, which is how the console is served +// outside of a development setup. +func Options(c *gin.Context) sessions.Options { + return sessions.Options{ + Path: "/", + MaxAge: maxAge, + HttpOnly: true, + Secure: secureCookie(c), + SameSite: http.SameSiteLaxMode, + } +} + +// secureCookie reports whether the session cookie may be restricted to HTTPS. +// +// The console is only reachable over TLS in production, but the webserver +// itself sits behind a TLS terminating nginx, so a plain HTTP request in front +// of it can still be a legitimate request of an HTTPS session. +func secureCookie(c *gin.Context) bool { + if !config.GlobalConfig.Server.DevMode { + return true + } + + if c.Request.TLS != nil { + return true + } + + // Honour the scheme reported by a TLS terminating proxy, which is what the + // development setup is expected to use when it wants to exercise the + // production cookie attributes. + proto := c.Request.Header.Get("X-Forwarded-Proto") + if i := strings.IndexByte(proto, ','); i >= 0 { + proto = proto[:i] + } + + return strings.EqualFold(strings.TrimSpace(proto), "https") +} diff --git a/management/webserver/utils/httpclient.go b/management/webserver/utils/httpclient.go index d3874d0a..f178f65c 100644 --- a/management/webserver/utils/httpclient.go +++ b/management/webserver/utils/httpclient.go @@ -1,7 +1,6 @@ package utils import ( - "crypto/tls" "net/http" "net/url" "os" @@ -14,12 +13,12 @@ var httpClient *http.Client func GetHTTPClient() *http.Client { if httpClient == nil { + // Certificates are verified: this client talks to the telemetry + // endpoint and to the upgrade server, both of which are remote and can + // be spoofed by a man in the middle when verification is disabled. tr := &http.Transport{ MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second, - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, } proxyUrl, existed := os.LookupEnv(proxyName) From 4a4d92df5792d85900b9d46daa5fd19f895a57f5 Mon Sep 17 00:00:00 2001 From: "jialong.wang" Date: Fri, 18 Sep 2026 16:23:52 +0800 Subject: [PATCH 18/31] fix(management,mcp-server,yanshi): close the second security scan round A re-scan of main after #1324 reported 29 findings across management/webserver, management/tcontrollerd, mcp_server and yanshi. Each report was checked against the code on main: all of them describe a real weakness, and eight of them are the same defect reported twice (the private key mode, the nginx certificate file name, POST /api/Behaviour, GET /api/OTPUrl and the MCP authentication default). management/webserver - utils/random.go: RandStr is now fed by crypto/rand (a value it returns is used as the session signing key and as an event id), plus RandHex for secrets. - model/option.go: the session signing key is generated with RandHex, and a key that still has the 32 letter shape of the old time seeded generator is rotated at startup, which invalidates the sessions signed with it. - utils/cert.go, api/cert.go: a private key is written with 0600 instead of 0644, an existing key file is tightened, and an upload is stored through O_CREATE|O_EXCL with a mode that depends on the extension, so a key is never created world readable and a planted symlink is never followed. - utils/file.go: RenameWriteFile creates its temporary file with os.CreateTemp instead of a math/rand name, so the name cannot be predicted and an existing file or symlink at that path is never followed. - api/auth.go: the login endpoint is throttled per account and per client address (5 failures, exponential backoff up to 5 minutes, bounded map), a passcode is remembered by time step and refused when it is replayed, and GetOTPUrl no longer replaces a pending TFA secret on every call and can be gated with MGT_BOOTSTRAP_TOKEN. - main.go, middleware/security.go: the API answers carry nosniff, X-Frame-Options, a frame-ancestors CSP, Referrer-Policy and HSTS. - main.go, api/behaviour.go: POST /api/Behaviour moved behind AuthRequired, and the stored routes are length limited and their insert error is checked. - api/errors.go, api/response: a failing database or runtime call answers with fixed text and leaves the detail in the server log. - model/policygroup.go, api/policygroup.go: a policy group that names an unknown module is rejected with 400 instead of dereferencing a nil module. - model/detectlog.go: the sample lists are no longer empty, which used to make every lookup a division by zero. - pkg/log/log.go: log files are created 0640 and an existing world writable one is tightened. - rpc/auth.go, rpc/main.go, rpc/website.go: the Subscribe stream requires a token from MGT_GRPC_TOKEN or /app/sock/mgt_grpc_token, and the global subscriber pointer is guarded by a mutex. - pkg/fvm: the compiled policy is copied out of the C buffer and every push attempt builds its own request, so a retry cannot send an empty policy. management/tcontrollerd - controller/website.go: the walk callback checks err and info before using them (a nil FileInfo took the daemon down), and the certificate file names are validated and confined to the certificate directory before they reach the nginx templates. - controller/controller.go, pkg/config: the control channel token is read from TCD_MGT_TOKEN or the shared token file; compose.yaml exposes an optional override for both containers. - utils/random.go, utils/file.go: the same crypto/rand and os.CreateTemp treatment as the management container. - pkg/log/log.go: the same log file mode fix. mcp_server - internal/auth/auth.go, main.go, docker-compose.yml: authentication is on unless MCP_AUTH_DISABLED=true or MCP_AUTH_ENABLED=false is set, and the process refuses to start without it on a non-loopback address. - config.yaml, README.md: insecure_skip_verify defaults to false, because the SafeLine API Token travels on every request. yanshi - src/parser.y, src/lexer.l, src/fsa_anno.cc: a repetition count is capped at MAX_REPEAT, the lexer saturates an overflowing literal instead of relying on atol, and the expansion refuses an out of range bound. A policy such as 'x'{1000000000} is now rejected in milliseconds. - src/repl.cc: the ctype calls receive an unsigned char. Verified with go build, go vet and go test on mcp_server, with go build and go vet on both management modules, and by building yanshi and running the repeat and boundary cases. pkg/fvm needs the private libfvm headers, so its two files were only parse checked here and have to be built in Linux CI. --- compose.yaml | 10 +- management/tcontrollerd/config.yml | 7 +- .../tcontrollerd/controller/controller.go | 51 +++++- .../tcontrollerd/controller/validation.go | 37 ++++ management/tcontrollerd/controller/website.go | 44 ++++- management/tcontrollerd/pkg/config/config.go | 16 ++ management/tcontrollerd/pkg/config/global.go | 2 + management/tcontrollerd/pkg/log/log.go | 30 ++- management/tcontrollerd/utils/file.go | 34 +++- management/tcontrollerd/utils/random.go | 28 ++- management/webserver/api/auth.go | 153 +++++++++++++++- management/webserver/api/behaviour.go | 18 +- management/webserver/api/cert.go | 47 ++++- management/webserver/api/detectlog.go | 4 +- management/webserver/api/errors.go | 28 +++ management/webserver/api/login_guard.go | 156 ++++++++++++++++ management/webserver/api/policygroup.go | 29 +-- management/webserver/api/policyrule.go | 24 ++- management/webserver/api/response/error.go | 15 +- management/webserver/api/response/jsonbody.go | 6 + management/webserver/api/website.go | 16 +- management/webserver/main.go | 4 +- management/webserver/middleware/security.go | 29 +++ management/webserver/model/detectlog.go | 11 +- management/webserver/model/option.go | 39 +++- management/webserver/model/policygroup.go | 64 ++++++- management/webserver/model/user.go | 9 +- management/webserver/model/website.go | 46 +++++ management/webserver/pkg/fvm/fvm.go | 75 +------- management/webserver/pkg/fvm/push.go | 63 +++++++ management/webserver/pkg/log/log.go | 30 ++- management/webserver/rpc/auth.go | 172 ++++++++++++++++++ management/webserver/rpc/main.go | 14 +- management/webserver/rpc/website.go | 94 +++++++--- management/webserver/utils/cert.go | 12 +- management/webserver/utils/file.go | 36 +++- management/webserver/utils/random.go | 34 +++- mcp_server/README.md | 50 +++-- mcp_server/config.yaml | 8 +- mcp_server/docker-compose.yml | 5 +- mcp_server/internal/auth/auth.go | 29 ++- mcp_server/internal/auth/auth_test.go | 29 ++- mcp_server/internal/config/config_test.go | 2 +- mcp_server/main.go | 36 +++- mcp_server/main_test.go | 39 ++++ yanshi/src/common.hh | 5 + yanshi/src/fsa_anno.cc | 7 + yanshi/src/lexer.l | 17 +- yanshi/src/parser.y | 3 + yanshi/src/repl.cc | 6 +- 50 files changed, 1497 insertions(+), 226 deletions(-) create mode 100644 management/webserver/api/errors.go create mode 100644 management/webserver/api/login_guard.go create mode 100644 management/webserver/middleware/security.go create mode 100644 management/webserver/pkg/fvm/push.go create mode 100644 management/webserver/rpc/auth.go diff --git a/compose.yaml b/compose.yaml index 5536977c..49746b9e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -43,6 +43,11 @@ services: test: curl -k -f https://localhost:1443/api/open/health environment: - MGT_PG=postgres://safeline-ce:${POSTGRES_PASSWORD}@safeline-pg/safeline-ce?sslmode=disable + # Optional override for the control channel token that tcontrollerd has to + # present before it may subscribe. Leave it empty to use + # ${SAFELINE_DIR}/resources/sock/mgt_grpc_token, which this container + # creates and the tengine container reads. + - MGT_GRPC_TOKEN=${MGT_GRPC_TOKEN:-} depends_on: - postgres - fvm @@ -83,6 +88,9 @@ services: environment: - TCD_MGT_API=https://${SUBNET_PREFIX}.4:1443/api/open/publish/server - TCD_SNSERVER=${SUBNET_PREFIX}.5:8000 + # Optional override for the control channel token; empty means the token + # file in ${SAFELINE_DIR}/resources/sock is used. + - TCD_MGT_TOKEN=${MGT_GRPC_TOKEN:-} # deprecated - SNSERVER_ADDR=${SUBNET_PREFIX}.5:8000 - CHAOS_ADDR=${SUBNET_PREFIX}.10 @@ -140,4 +148,4 @@ services: - ${SAFELINE_DIR}/resources/chaos:/app/chaos networks: safeline-ce: - ipv4_address: ${SUBNET_PREFIX}.10 \ No newline at end of file + ipv4_address: ${SUBNET_PREFIX}.10 diff --git a/management/tcontrollerd/config.yml b/management/tcontrollerd/config.yml index 04cf50ce..5f2c49a9 100644 --- a/management/tcontrollerd/config.yml +++ b/management/tcontrollerd/config.yml @@ -2,4 +2,9 @@ log: output: stdout # "stdout", "stderr" or file path level: debug # "debug", "info", "warn" or "error" -mgt_addr: 169.254.0.4:9002 # gRPC addr of mgt-api webserver \ No newline at end of file +mgt_addr: 169.254.0.4:9002 # gRPC addr of mgt-api webserver +# Shared secret of the control channel. mgt-api creates it in a directory that +# both containers mount (see the safeLine compose file); set MGT_TOKEN_FILE to +# point somewhere else, or TCD_MGT_TOKEN to pass the value through the +# environment instead. +# mgt_token_file: /app/sock/mgt_grpc_token diff --git a/management/tcontrollerd/controller/controller.go b/management/tcontrollerd/controller/controller.go index 5eb56284..b3796524 100644 --- a/management/tcontrollerd/controller/controller.go +++ b/management/tcontrollerd/controller/controller.go @@ -1,9 +1,15 @@ package controller import ( + "context" + "os" + "strings" + "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "chaitin.cn/dev/go/errors" "chaitin.cn/patronus/safeline-2/management/tcontrollerd/pkg/config" "chaitin.cn/patronus/safeline-2/management/tcontrollerd/pkg/log" pb "chaitin.cn/patronus/safeline-2/management/tcontrollerd/proto/website" @@ -13,8 +19,26 @@ var ( logger = log.GetLogger("controller") ) +const ( + // TokenEnv holds the control channel token itself. + TokenEnv = "TCD_MGT_TOKEN" + + // tokenMetadataKey is the metadata key that carries the token to the + // management server. gRPC metadata keys have to be lower case. + tokenMetadataKey = "mgt-token" +) + func Handle() error { logger.Infof("Connect mgt-webserver at %s", config.GlobalConfig.MgtWebserver) + + // The management server only accepts a subscription that proves it belongs + // to this installation. + token, err := controlToken() + if err != nil { + logger.Errorf("Fail to read the control channel token: %v", err) + return err + } + gRPCConn, err := grpc.Dial(config.GlobalConfig.MgtWebserver, []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), }...) @@ -33,9 +57,34 @@ func Handle() error { } }(gRPCConn) - if err = websiteHandler(wsClient); err != nil { + ctx := metadata.AppendToOutgoingContext(context.Background(), tokenMetadataKey, token) + if err = websiteHandler(ctx, wsClient); err != nil { return err } return nil } + +// controlToken returns the shared secret of the control channel. +// +// The value is taken from the environment when it is set there, and from the +// token file otherwise. The management server creates that file in the +// directory both containers mount, so the standard deployment needs no +// configuration. +func controlToken() (string, error) { + if token := strings.TrimSpace(os.Getenv(TokenEnv)); token != "" { + return token, nil + } + + content, err := os.ReadFile(config.GlobalConfig.MgtTokenFile) + if err != nil { + return "", errors.Wrapf(err, "read the control channel token from %s", config.GlobalConfig.MgtTokenFile) + } + + token := strings.TrimSpace(string(content)) + if token == "" { + return "", errors.New("the control channel token file " + config.GlobalConfig.MgtTokenFile + " is empty") + } + + return token, nil +} diff --git a/management/tcontrollerd/controller/validation.go b/management/tcontrollerd/controller/validation.go index 1687befb..561c07b3 100644 --- a/management/tcontrollerd/controller/validation.go +++ b/management/tcontrollerd/controller/validation.go @@ -2,6 +2,7 @@ package controller import ( "fmt" + "path/filepath" "regexp" "strconv" "strings" @@ -25,8 +26,44 @@ var ( // nginx "server_name" directive, so anything that could terminate the // directive (or start another one) must not pass. serverNameLabelPattern = regexp.MustCompile(`^[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?$`) + + // certFilenamePattern matches the file name of a certificate or a key. The + // name is rendered into "ssl_certificate" and "ssl_certificate_key", and + // it is joined to the certificate directory, so only a plain file name + // without a path separator, a control character or any nginx syntax may + // pass. + certFilenamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) ) +// maxCertFilenameLength bounds a certificate file name. +const maxCertFilenameLength = 255 + +// validateCertFilename accepts the name of a file in the certificate directory. +// +// The management server validates the same value before it stores it, but this +// side cannot rely on that: the configuration is pushed over the network, and a +// name carrying nginx syntax would turn into directives that run commands. +func validateCertFilename(name string) error { + // An empty name means "this site is served over plain HTTP". + if name == "" { + return nil + } + + if len(name) > maxCertFilenameLength { + return fmt.Errorf("invalid certificate file name %q: longer than %d characters", name, maxCertFilenameLength) + } + + if name != filepath.Base(name) || name == "." || name == ".." { + return fmt.Errorf("invalid certificate file name %q: only a file name without a directory is allowed", name) + } + + if !certFilenamePattern.MatchString(name) { + return fmt.Errorf("invalid certificate file name %q: only letters, digits, dot, dash and underscore are allowed", name) + } + + return nil +} + // validatePort accepts the decimal notation of a TCP port. It rejects any // other value because the port is interpolated into a "listen" directive. func validatePort(port string) error { diff --git a/management/tcontrollerd/controller/website.go b/management/tcontrollerd/controller/website.go index e2a2de2a..41d01283 100644 --- a/management/tcontrollerd/controller/website.go +++ b/management/tcontrollerd/controller/website.go @@ -30,6 +30,10 @@ const ( nginxBackupFilePrefix = "BAK_IF_backend_" nginxFileMode = 0644 + // nginxCertsPath is the directory the ssl_certificate templates below point + // at; certificate file names have to stay inside it. + nginxCertsPath = "/etc/nginx/certs" + HttpsScheme = "https" DefaultHttpsPort = "443" ) @@ -58,6 +62,19 @@ func generateNginxConfig(website *model.WebsiteConfig) (string, error) { sslCertFilename := "" sslCertKeyFilename := "" if website.KeyFilename != "" && website.CertFilename != "" { + for _, filename := range []string{website.CertFilename, website.KeyFilename} { + if err := validateCertFilename(filename); err != nil { + return "", err + } + + // The template renders the name into an absolute path, so an + // explicit check backs up the name validation above: the resolved + // path has to stay inside the certificate directory. + if filepath.Dir(filepath.Join(nginxCertsPath, filename)) != nginxCertsPath { + return "", fmt.Errorf("invalid certificate file name %q: it leaves %s", filename, nginxCertsPath) + } + } + sslFlag = " ssl" // with a blank ahead sslCertFilename = fmt.Sprintf(certTpl, website.CertFilename) sslCertKeyFilename = fmt.Sprintf(certKeyTpl, website.KeyFilename) @@ -110,11 +127,28 @@ func generateFullConfigAndReload(msg []byte) error { return err } + if err := utils.EnsureDir(nginxConfigPath); err != nil { + return err + } + configFilename := make(map[string]struct{}) for _, website := range websites { configFilename[fmt.Sprintf("%s%d", nginxFilePrefix, website.Id)] = struct{}{} } - filepath.Walk(nginxConfigPath, func(path string, info fs.FileInfo, err error) error { + + if err := filepath.Walk(nginxConfigPath, func(path string, info fs.FileInfo, err error) error { + // filepath.Walk reports the entries it could not read through err and + // leaves info nil in that case: touching info before this check is a + // nil pointer dereference that takes the whole daemon down. + if err != nil { + logger.Warnf("Skip %s: %v", path, err) + return nil + } + + if info == nil || info.IsDir() { + return nil + } + _, ok := configFilename[info.Name()] if !ok && strings.HasPrefix(info.Name(), nginxFilePrefix) { if err := os.Remove(path); err != nil { @@ -124,7 +158,9 @@ func generateFullConfigAndReload(msg []byte) error { } return nil - }) + }); err != nil { + return err + } for _, website := range websites { if err := generateConfigAndReload(&website); err != nil { @@ -231,8 +267,8 @@ func sendResponse(stream pb.Website_SubscribeClient, eventType string, errMsg [] }) } -func websiteHandler(wc pb.WebsiteClient) error { - stream, err := wc.Subscribe(context.Background()) +func websiteHandler(ctx context.Context, wc pb.WebsiteClient) error { + stream, err := wc.Subscribe(ctx) if err != nil { logger.Errorf("Subscribe failed: %v", err) return err diff --git a/management/tcontrollerd/pkg/config/config.go b/management/tcontrollerd/pkg/config/config.go index 3b93f747..ad4ca881 100644 --- a/management/tcontrollerd/pkg/config/config.go +++ b/management/tcontrollerd/pkg/config/config.go @@ -10,6 +10,12 @@ var ( GlobalConfig = DefaultGlobalConfig() ) +// DefaultMgtTokenFile is where the control channel token is read from by +// default. The standard deployment mounts the same host directory at +// /app/sock inside the management container, which creates the file, and +// inside the tengine container, which is where tcontrollerd runs. +const DefaultMgtTokenFile = "/app/sock/mgt_grpc_token" + func InitConfigs(configFilePath string) error { s, err := settings.New(configFilePath) if err != nil { @@ -28,5 +34,15 @@ func InitConfigs(configFilePath string) error { GlobalConfig.MgtWebserver = v } + // The token file is optional in the configuration file: the default is + // where the management server puts it. + if err := s.Unmarshal("mgt_token_file", &GlobalConfig.MgtTokenFile); err != nil { + return err + } + + if v, ok := os.LookupEnv("MGT_TOKEN_FILE"); ok { + GlobalConfig.MgtTokenFile = v + } + return nil } diff --git a/management/tcontrollerd/pkg/config/global.go b/management/tcontrollerd/pkg/config/global.go index e03c1cce..e0258ed2 100644 --- a/management/tcontrollerd/pkg/config/global.go +++ b/management/tcontrollerd/pkg/config/global.go @@ -3,11 +3,13 @@ package config type Config struct { Log LogConfig MgtWebserver string + MgtTokenFile string } func DefaultGlobalConfig() Config { return Config{ Log: DefaultLogConfig(), MgtWebserver: "169.254.0.4:9002", + MgtTokenFile: DefaultMgtTokenFile, } } diff --git a/management/tcontrollerd/pkg/log/log.go b/management/tcontrollerd/pkg/log/log.go index 8b5639f7..4abb6d38 100644 --- a/management/tcontrollerd/pkg/log/log.go +++ b/management/tcontrollerd/pkg/log/log.go @@ -51,11 +51,17 @@ func InitLogger() error { fileFlag = fileFlag | os.O_CREATE } - if fp, err := os.OpenFile(config.GlobalConfig.Log.Output, fileFlag, os.ModePerm); err != nil { + // os.ModePerm (0777) let every local user rewrite the audit log. The + // file is created with an owner/group readable mode instead. + if fp, err := os.OpenFile(config.GlobalConfig.Log.Output, fileFlag, logFileMode); err != nil { return fmt.Errorf("failed to open log file: %s", err.Error()) } else { log.SetOutput(log.AllLoggers, log.NewLockOutput(fp)) } + + // The mode above only applies when the file is created, so a log file + // that an older release left world writable is tightened here. + tightenLogFileMode(config.GlobalConfig.Log.Output) } // hook @@ -67,6 +73,28 @@ func InitLogger() error { return nil } +// logFileMode is the permission the log file is created with. Logs are audit +// material: they must not be writable by other local users. +const logFileMode os.FileMode = 0640 + +// looseLogFileMask matches the group and world write bits. +const looseLogFileMask os.FileMode = 0o022 + +// tightenLogFileMode removes the group and world write bits from an existing +// log file, so that a log file created by an older release cannot stay +// tamperable by other local users. +func tightenLogFileMode(path string) { + info, err := os.Stat(path) + if err != nil || info.Mode().Perm()&looseLogFileMask == 0 { + return + } + + log.Warnf("Log file %s is writable by other users, setting mode to %#o", path, logFileMode) + if err := os.Chmod(path, logFileMode); err != nil { + log.Warnf("Cannot tighten the mode of log file %s: %s", path, err) + } +} + type RuntimeHook struct{} func (h *RuntimeHook) Levels() []logrus.Level { diff --git a/management/tcontrollerd/utils/file.go b/management/tcontrollerd/utils/file.go index e4b1121c..159b1b1f 100644 --- a/management/tcontrollerd/utils/file.go +++ b/management/tcontrollerd/utils/file.go @@ -50,11 +50,39 @@ func FilesExist(paths ...string) (bool, error) { } func RenameWriteFile(filename string, data []byte, perm os.FileMode) error { - randFileName := filename + ".tmp." + RandStr(8) - if err := ioutil.WriteFile(randFileName, data, perm); err != nil { + // os.CreateTemp creates the file with O_CREATE|O_EXCL and mode 0600, so a + // file or symlink planted at the temporary path beforehand cannot be + // followed. The previous implementation guessed the name with math/rand and + // wrote through ioutil.WriteFile, which follows symlinks. + tmpFile, err := os.CreateTemp(filepath.Dir(filename), filepath.Base(filename)+".tmp.") + if err != nil { return err } - return os.Rename(randFileName, filename) + tmpName := tmpFile.Name() + + renamed := false + defer func() { + if !renamed { + os.Remove(tmpName) + } + }() + + if err = tmpFile.Chmod(perm); err != nil { + tmpFile.Close() + return err + } + if _, err = tmpFile.Write(data); err != nil { + tmpFile.Close() + return err + } + if err = tmpFile.Close(); err != nil { + return err + } + if err = os.Rename(tmpName, filename); err != nil { + return err + } + renamed = true + return nil } func EnsureRenameWriteFile(path string, data []byte, mode os.FileMode) error { diff --git a/management/tcontrollerd/utils/random.go b/management/tcontrollerd/utils/random.go index 44ebd98a..3b93401e 100644 --- a/management/tcontrollerd/utils/random.go +++ b/management/tcontrollerd/utils/random.go @@ -1,18 +1,36 @@ package utils import ( - "math/rand" - "time" + "crypto/rand" + "encoding/hex" + "math/big" ) var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") +// RandStr returns n letters drawn from a cryptographically secure source. +// +// The value is used to name the temporary file created by RenameWriteFile, so +// it must not be predictable: math/rand is seeded from the clock and its +// output can be reconstructed from a single sample. func RandStr(n int) string { - rand.Seed(time.Now().UnixNano()) - b := make([]rune, n) + limit := big.NewInt(int64(len(letters))) for i := range b { - b[i] = letters[rand.Intn(len(letters))] + idx, err := rand.Int(rand.Reader, limit) + if err != nil { + panic("utils: cannot read from the system entropy source: " + err.Error()) + } + b[i] = letters[idx.Int64()] } return string(b) } + +// RandHex returns a hex string of 2*n characters read from crypto/rand. +func RandHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic("utils: cannot read from the system entropy source: " + err.Error()) + } + return hex.EncodeToString(b) +} diff --git a/management/webserver/api/auth.go b/management/webserver/api/auth.go index b9d46b4e..c4200c93 100644 --- a/management/webserver/api/auth.go +++ b/management/webserver/api/auth.go @@ -1,8 +1,13 @@ package api import ( + "crypto/subtle" + "fmt" "math" "net/http" + "net/url" + "os" + "strconv" "time" "github.com/gin-contrib/sessions" @@ -20,6 +25,18 @@ import ( var logger = log.GetLogger("api") +// loginThrottle slows down passcode guessing. The management console has a +// single account whose only credential is a six digit passcode. +var loginThrottle = newLoginGuard() + +// BootstrapTokenEnv names the environment variable that gates the anonymous +// TFA bootstrap. See GetOTPUrl. +const BootstrapTokenEnv = "MGT_BOOTSTRAP_TOKEN" + +// bootstrapTokenHeader is the header that carries the value of +// BootstrapTokenEnv. +const bootstrapTokenHeader = "X-Bootstrap-Token" + var OtpOpts = totp.GenerateOpts{ Issuer: constants.ProductName, AccountName: constants.SuperUser, @@ -47,8 +64,19 @@ func PostLogin(c *gin.Context) { var user model.User db.Where(&model.User{Username: constants.SuperUser}).First(&user) - valid := totp.Validate(params.Passcode, user.TFASecret) + keys := loginKeys(c.ClientIP(), user.Username) + if key, wait := loginThrottle.lockout(keys); wait > 0 { + logger.Warnf("Login throttled by %s, retry in %s", key, wait) + c.Header("Retry-After", strconv.FormatInt(int64(wait.Seconds())+1, 10)) + response.Error(c, response.JSONBody{Err: response.ErrTooManyAttempts, Msg: "Too many failed login attempts, please try again later"}, http.StatusTooManyRequests) + return + } + + usedStep, valid := verifyPasscode(user.TFASecret, params.Passcode, user.TFALastUsedStep, time.Now()) if !valid { + loginThrottle.fail(keys) + logger.Warnf("Failed login attempt from %s", c.ClientIP()) + millisecondTimestamp := params.Timestamp localTimeStamp := time.Now() if millisecondTimestamp > 0 { @@ -66,8 +94,13 @@ func PostLogin(c *gin.Context) { return } + loginThrottle.reset(keys) + user.LastLoginTime = time.Now().Unix() user.IsEnabled = true + // Remember the time step of the accepted passcode so that the same one + // cannot be used a second time. + user.TFALastUsedStep = usedStep db.Save(&user) session := sessions.Default(c) @@ -94,13 +127,6 @@ func PostLogout(c *gin.Context) { } func GetOTPUrl(c *gin.Context) { - otpKey, err := totp.Generate(OtpOpts) - if err != nil { - logger.Error(err) - response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when generating otp qrcode"}, http.StatusInternalServerError) - return - } - db := database.GetDB() // only SuperUser in v0.9 @@ -112,11 +138,122 @@ func GetOTPUrl(c *gin.Context) { return } + if err := checkBootstrapToken(c); err != nil { + logger.Warnf("Refused to hand out the TFA secret to %s: %s", c.ClientIP(), err) + response.Error(c, response.JSONBody{Err: response.ErrLoginRequired, Msg: "TFA bootstrap is not allowed for this request"}, http.StatusUnauthorized) + return + } + + // The console displays the QR code on the login page, before any session + // exists, so this endpoint is reachable while the account has never logged + // in. It must not hand out a fresh secret on every call: an anonymous + // caller that replaced a secret the administrator had already scanned would + // take over the account, because the passcode is the only credential. + if user.TFASecret != "" { + logger.Debugf("Reusing the pending TFA secret for %s", c.ClientIP()) + response.Success(c, gin.H{"url": otpURL(user.TFASecret)}) + return + } + + otpKey, err := totp.Generate(OtpOpts) + if err != nil { + logger.Error(err) + response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when generating otp qrcode"}, http.StatusInternalServerError) + return + } + user.TFASecret = otpKey.Secret() db.Save(&user) + logger.Warnf("Issued a new TFA secret to %s; the administrator has to bind it before the first login", c.ClientIP()) response.Success(c, gin.H{"url": otpKey.URL()}) } +// verifyPasscode checks a TOTP passcode and returns the time step it belongs to. +// +// lastUsedStep is the step of the passcode that was accepted last; every step +// up to it is refused, which is what makes a captured passcode unusable: the +// plain totp.Validate call the handler used before accepted the same code for +// as long as it stayed inside the window of the current step. +func verifyPasscode(secret, passcode string, lastUsedStep int64, now time.Time) (int64, bool) { + if secret == "" || passcode == "" { + return 0, false + } + + period := int64(OtpOpts.Period) + currentStep := now.Unix() / period + if lastUsedStep > currentStep+1 { + // The recorded step is ahead of the current window, which means the + // clock moved backwards. Keeping it would lock the account out until + // the clock catches up, so the record is treated as stale. + lastUsedStep = 0 + } + + opts := totp.ValidateOpts{ + Period: OtpOpts.Period, + Digits: OtpOpts.Digits, + Algorithm: OtpOpts.Algorithm, + } + + // The current step and its two neighbours: the same window totp.Validate + // accepts by default, so a client clock that is off by one period still + // works. + var matched int64 + for _, step := range []int64{currentStep - 1, currentStep, currentStep + 1} { + if step <= lastUsedStep { + continue + } + + code, err := totp.GenerateCodeCustom(secret, time.Unix(step*period, 0), opts) + if err != nil { + continue + } + + if subtle.ConstantTimeCompare([]byte(code), []byte(passcode)) == 1 { + matched = step + } + } + + return matched, matched != 0 +} + +// otpURL rebuilds the otpauth URL that totp.Generate returns for a secret. +func otpURL(secret string) string { + values := url.Values{} + values.Set("secret", secret) + values.Set("issuer", OtpOpts.Issuer) + values.Set("period", strconv.FormatUint(uint64(OtpOpts.Period), 10)) + values.Set("algorithm", OtpOpts.Algorithm.String()) + values.Set("digits", OtpOpts.Digits.String()) + + bootstrapURL := url.URL{ + Scheme: "otpauth", + Host: "totp", + Path: "/" + OtpOpts.Issuer + ":" + OtpOpts.AccountName, + RawQuery: values.Encode(), + } + + return bootstrapURL.String() +} + +// checkBootstrapToken enforces the optional out of band token of the anonymous +// TFA bootstrap. +// +// Installations that do not want the account to be bindable by whoever reaches +// the API first set MGT_BOOTSTRAP_TOKEN and deliver it to the administrator +// through a channel of their choice (the installation output, for example). +func checkBootstrapToken(c *gin.Context) error { + expected := os.Getenv(BootstrapTokenEnv) + if expected == "" { + return nil + } + + if subtle.ConstantTimeCompare([]byte(c.GetHeader(bootstrapTokenHeader)), []byte(expected)) == 1 { + return nil + } + + return fmt.Errorf("missing or wrong %s header", bootstrapTokenHeader) +} + func GetUser(c *gin.Context) { db := database.GetDB() user := model.User{Username: constants.SuperUser} diff --git a/management/webserver/api/behaviour.go b/management/webserver/api/behaviour.go index 0d63fddb..4fa10843 100644 --- a/management/webserver/api/behaviour.go +++ b/management/webserver/api/behaviour.go @@ -14,6 +14,10 @@ type PostBehaviourRequest struct { model.Behaviour } +// maxRouterLength bounds the console routes that are stored. The values come +// from the browser, so they are limited to what the console can produce. +const maxRouterLength = 256 + func PostBehaviour(c *gin.Context) { var params PostBehaviourRequest if err := c.BindJSON(¶ms); err != nil { @@ -21,7 +25,19 @@ func PostBehaviour(c *gin.Context) { response.Error(c, response.ErrorParamNotOK, http.StatusInternalServerError) return } + + if len(params.SrcRouter) > maxRouterLength || len(params.DstRouter) > maxRouterLength { + logger.Warnf("Rejected behaviour entry with %d/%d bytes of route", len(params.SrcRouter), len(params.DstRouter)) + response.Error(c, response.ErrorParamNotOK, http.StatusBadRequest) + return + } + db := database.GetDB() - db.Create(&model.Behaviour{SrcRouter: params.SrcRouter, DstRouter: params.DstRouter}) + if err := db.Create(&model.Behaviour{SrcRouter: params.SrcRouter, DstRouter: params.DstRouter}).Error; err != nil { + logger.Error(err) + response.Error(c, response.ErrorInternal, http.StatusInternalServerError) + return + } + response.Success(c, nil) } diff --git a/management/webserver/api/cert.go b/management/webserver/api/cert.go index 890104a8..7703010b 100644 --- a/management/webserver/api/cert.go +++ b/management/webserver/api/cert.go @@ -3,7 +3,10 @@ package api import ( "crypto/x509/pkix" "fmt" + "io" + "mime/multipart" "net/http" + "os" "path/filepath" "github.com/gin-gonic/gin" @@ -33,9 +36,17 @@ func PostUploadSSLCert(c *gin.Context) { response.Error(c, response.ErrorParamNotOK, http.StatusInternalServerError) return } + + // The console uploads both the certificate and the private key through + // this endpoint. The extension decides which of the two it is, and the key + // must not be created with the default (0666 before umask) mode. + mode := os.FileMode(0644) switch filepath.Ext(file.Filename) { - case CRT, PEM, KEY: + case CRT: + logger.Debugf("File: %v is valid", file.Filename) + case PEM, KEY: logger.Debugf("File: %v is valid", file.Filename) + mode = os.FileMode(0600) default: logger.Errorf("Filename: %s, ext: %s", file.Filename, filepath.Ext(file.Filename)) response.Error(c, response.JSONBody{Err: response.ErrWrongFileType, Msg: "Wrong file type, please upload a file of .crt or .key"}, http.StatusUnsupportedMediaType) @@ -43,13 +54,13 @@ func PostUploadSSLCert(c *gin.Context) { } var dstPath string - filename := fmt.Sprintf("%s_%s", utils.RandStr(16), file.Filename) + filename := fmt.Sprintf("%s_%s", utils.RandStr(16), filepath.Base(file.Filename)) if config.GlobalConfig.Server.DevMode { dstPath = filepath.Join("./nginx", SSLCertDir, filename) } else { dstPath = filepath.Join(config.GlobalConfig.NgxResDir, SSLCertDir, filename) } - if err = c.SaveUploadedFile(file, dstPath); err != nil { + if err = saveUploadedFile(file, dstPath, mode); err != nil { logger.Error(err) response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when saving file"}, http.StatusInternalServerError) return @@ -58,6 +69,36 @@ func PostUploadSSLCert(c *gin.Context) { response.Success(c, gin.H{"filename": filename}) } +// saveUploadedFile stores an uploaded file with an explicit permission mode. +// +// gin.Context.SaveUploadedFile calls os.Create, which asks for 0666 (0644 +// after the usual umask) for every file it is given, including private keys. +// O_CREATE|O_EXCL also makes sure an existing file - or a symlink pointing +// outside the certificate directory - is never followed. +func saveUploadedFile(file *multipart.FileHeader, dstPath string, mode os.FileMode) error { + if err := utils.EnsureFileDir(dstPath); err != nil { + return err + } + + src, err := file.Open() + if err != nil { + return err + } + defer src.Close() + + dst, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return err + } + + if _, err = io.Copy(dst, src); err != nil { + dst.Close() + return err + } + + return dst.Close() +} + func PostSSLCert(c *gin.Context) { var params postSSLCertRequest if err := c.BindJSON(¶ms); err != nil { diff --git a/management/webserver/api/detectlog.go b/management/webserver/api/detectlog.go index 220e95d9..42666b21 100644 --- a/management/webserver/api/detectlog.go +++ b/management/webserver/api/detectlog.go @@ -8,8 +8,6 @@ import ( "strconv" "strings" - "chaitin.cn/dev/go/errors" - "chaitin.cn/patronus/safeline-2/management/webserver/pkg" "github.com/gin-gonic/gin" @@ -51,7 +49,7 @@ func getDetectLog(eventId string) (*model.DetectLog, error) { var detectLogBasic model.DetectLogBasic res := db.Where(&model.DetectLogBasic{EventId: eventId}).First(&detectLogBasic) if res.RowsAffected == 0 { - return nil, errors.New("Data queried does not exist") + return nil, errDataNotExist } var detectLogDetail model.DetectLogDetail diff --git a/management/webserver/api/errors.go b/management/webserver/api/errors.go new file mode 100644 index 00000000..f856502b --- /dev/null +++ b/management/webserver/api/errors.go @@ -0,0 +1,28 @@ +package api + +import ( + "chaitin.cn/dev/go/errors" + + "chaitin.cn/patronus/safeline-2/management/webserver/api/response" +) + +// Errors whose message is meant for the administrator. Everything else that +// comes back from a database or a runtime call is answered with +// response.ErrorInternal: its text describes the inside of the deployment and +// belongs in the server log, where the operator can read it. +var ( + errDataNotExist = errors.New("Data queried does not exist") + errRulesCompile = errors.New("Rules compile error, please check your params.") +) + +// responseMessage returns the text that may be sent to the caller. +func responseMessage(err error) string { + switch err { + case errDataNotExist: + return errDataNotExist.Error() + case errRulesCompile: + return errRulesCompile.Error() + default: + return response.ErrorInternal.Msg + } +} diff --git a/management/webserver/api/login_guard.go b/management/webserver/api/login_guard.go new file mode 100644 index 00000000..1d6e40f6 --- /dev/null +++ b/management/webserver/api/login_guard.go @@ -0,0 +1,156 @@ +package api + +import ( + "fmt" + "sync" + "time" +) + +const ( + // loginFailureThreshold is the number of failed attempts that are tolerated + // before the caller has to wait. + loginFailureThreshold = 5 + + // loginFailureWindow is how long a failed attempt is remembered. + loginFailureWindow = 10 * time.Minute + + // loginMaxLockout caps the wait that is imposed on a caller that keeps + // failing. A six digit passcode stays out of reach of a brute force attack + // with this delay, even over a long period. + loginMaxLockout = 5 * time.Minute + + // loginMaxEntries bounds the memory the throttle may use. The keys carry a + // client address, and a client can choose as many of those as it likes. + loginMaxEntries = 1024 +) + +// loginAttempt is the state of one throttling key. +type loginAttempt struct { + failures int + lastFailure time.Time +} + +// loginGuard throttles the login endpoint per account and per client address. +// +// The management console has a single account and the passcode is its only +// credential, so a caller that is allowed to try passcodes without limit can +// simply enumerate them. +type loginGuard struct { + mu sync.Mutex + attempts map[string]*loginAttempt + + // now is a seam for the tests. + now func() time.Time +} + +func newLoginGuard() *loginGuard { + return &loginGuard{ + attempts: make(map[string]*loginAttempt), + now: time.Now, + } +} + +// loginKeys returns the throttle keys of a login attempt. +func loginKeys(clientIP, username string) []string { + keys := []string{fmt.Sprintf("account:%s", username)} + if clientIP != "" { + keys = append(keys, fmt.Sprintf("ip:%s", clientIP)) + } + + return keys +} + +// lockout reports how long the caller has to wait, and the key that caused it. +func (g *loginGuard) lockout(keys []string) (string, time.Duration) { + g.mu.Lock() + defer g.mu.Unlock() + + now := g.now() + g.prune(now) + + var ( + blockedKey string + wait time.Duration + ) + + for _, key := range keys { + attempt, ok := g.attempts[key] + if !ok || attempt.failures < loginFailureThreshold { + continue + } + + if remaining := loginBackoff(attempt.failures) - now.Sub(attempt.lastFailure); remaining > wait { + blockedKey, wait = key, remaining + } + } + + return blockedKey, wait +} + +// fail records an authentication failure. +func (g *loginGuard) fail(keys []string) { + g.mu.Lock() + defer g.mu.Unlock() + + now := g.now() + g.prune(now) + + for _, key := range keys { + attempt, ok := g.attempts[key] + if !ok || now.Sub(attempt.lastFailure) > loginFailureWindow { + attempt = &loginAttempt{} + g.attempts[key] = attempt + } + + attempt.failures++ + attempt.lastFailure = now + } +} + +// reset forgets the failures of a key, which is what a successful login does. +func (g *loginGuard) reset(keys []string) { + g.mu.Lock() + defer g.mu.Unlock() + + for _, key := range keys { + delete(g.attempts, key) + } +} + +// prune drops records that are no longer useful and keeps the map bounded. +func (g *loginGuard) prune(now time.Time) { + for key, attempt := range g.attempts { + if now.Sub(attempt.lastFailure) > loginFailureWindow+loginMaxLockout { + delete(g.attempts, key) + } + } + + // Dropping keys that are still active only weakens the throttle of callers + // that stopped trying; keeping the map bounded matters more, because a + // client can invent client addresses faster than the failures expire. + for key := range g.attempts { + if len(g.attempts) <= loginMaxEntries { + break + } + delete(g.attempts, key) + } +} + +// loginBackoff returns how long a caller that failed n times in a row has to +// wait after its last failure. The delay doubles per failure and is capped. +func loginBackoff(failures int) time.Duration { + shift := failures - loginFailureThreshold + if shift < 0 { + shift = 0 + } + if shift > 8 { + shift = 8 + } + + backoff := time.Second << uint(shift) + if backoff > loginMaxLockout { + backoff = loginMaxLockout + } + + return backoff +} diff --git a/management/webserver/api/policygroup.go b/management/webserver/api/policygroup.go index 8a4eb638..753e0d5b 100644 --- a/management/webserver/api/policygroup.go +++ b/management/webserver/api/policygroup.go @@ -7,8 +7,6 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" - "chaitin.cn/dev/go/errors" - "chaitin.cn/patronus/safeline-2/management/webserver/api/response" "chaitin.cn/patronus/safeline-2/management/webserver/model" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/constants" @@ -24,6 +22,15 @@ func PutPolicyGroupGlobal(ctx *gin.Context) { return } + // Reject unknown modules and modes before anything is persisted: the map + // keys are rendered into the skynet configuration, so a bad key has to be + // reported to the caller instead of reaching the engine or the renderer. + if err := model.ValidatePolicyGroup(params); err != nil { + logger.Error(err) + response.Error(ctx, response.JSONBody{Err: response.ErrorParamNotOK.Err, Msg: err.Error()}, http.StatusBadRequest) + return + } + db := database.GetDB() err := db.Transaction(func(tx *gorm.DB) error { var pggOption model.Options @@ -32,13 +39,13 @@ func PutPolicyGroupGlobal(ctx *gin.Context) { return res.Error } if res.RowsAffected == 0 { - return errors.New("Data queried does not exist") + return errDataNotExist } pggStr, err := json.Marshal(params) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return err } @@ -46,14 +53,14 @@ func PutPolicyGroupGlobal(ctx *gin.Context) { tx.Save(&pggOption) if err := fvm.PushFSL(tx); err != nil { - return errors.New("Rules compile error, please check your params.") + return errRulesCompile } return nil }) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } @@ -68,7 +75,7 @@ func GetPolicyGroupGlobal(ctx *gin.Context) { err := json.Unmarshal([]byte(pggOption.Value), &pgg) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } @@ -91,13 +98,13 @@ func PutSrcIPConfig(ctx *gin.Context) { return res.Error } if res.RowsAffected == 0 { - return errors.New("Data queried does not exist") + return errDataNotExist } scStr, err := json.Marshal(params) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return err } @@ -105,14 +112,14 @@ func PutSrcIPConfig(ctx *gin.Context) { tx.Save(&scOption) if err := fvm.PushFSL(tx); err != nil { - return errors.New("Rules compile error, please check your params.") + return errRulesCompile } return nil }) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } diff --git a/management/webserver/api/policyrule.go b/management/webserver/api/policyrule.go index 784b4274..8bf4ea13 100644 --- a/management/webserver/api/policyrule.go +++ b/management/webserver/api/policyrule.go @@ -8,8 +8,6 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" - "chaitin.cn/dev/go/errors" - "chaitin.cn/patronus/safeline-2/management/webserver/api/response" "chaitin.cn/patronus/safeline-2/management/webserver/model" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/database" @@ -37,14 +35,14 @@ func PostPolicyRule(ctx *gin.Context) { } if err := fvm.PushFSL(tx); err != nil { - return errors.New("Rules compile error, please check your params.") + return errRulesCompile } return nil }) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } @@ -66,18 +64,18 @@ func PutSwitchPolicyRule(ctx *gin.Context) { return res.Error } if res.RowsAffected == 0 { - return errors.New("Data queried does not exist") + return errDataNotExist } if err := fvm.PushFSL(tx); err != nil { - return errors.New("Rules compile error, please check your params.") + return errRulesCompile } return nil }) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } } @@ -98,7 +96,7 @@ func PutPolicyRule(ctx *gin.Context) { return res.Error } if res.RowsAffected == 0 { - return errors.New("Data queried does not exist") + return errDataNotExist } policyRule.Action = params.Action @@ -108,14 +106,14 @@ func PutPolicyRule(ctx *gin.Context) { tx.Save(&policyRule) if err := fvm.PushFSL(tx); err != nil { - return errors.New("Rules compile error, please check your params.") + return errRulesCompile } return nil }) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } @@ -137,18 +135,18 @@ func DeletePolicyRule(ctx *gin.Context) { return res.Error } if res.RowsAffected == 0 { - return errors.New("Data queried does not exist") + return errDataNotExist } if err := fvm.PushFSL(tx); err != nil { - return errors.New("Rules compile error, please check your params.") + return errRulesCompile } return nil }) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } diff --git a/management/webserver/api/response/error.go b/management/webserver/api/response/error.go index d62b815c..6373cff7 100644 --- a/management/webserver/api/response/error.go +++ b/management/webserver/api/response/error.go @@ -1,11 +1,12 @@ package response const ( - ErrLoginRequired = "login-required" - ErrWrongPasscode = "wrong-passcode" - ErrWrongTimeGap = "wrong-time-gap" - ErrInternalError = "internal-error" - ErrDataNotExist = "data-not-exist" - ErrWrongFileType = "wrong-filetype" - ErrReadOnly = "read-only" + ErrLoginRequired = "login-required" + ErrWrongPasscode = "wrong-passcode" + ErrWrongTimeGap = "wrong-time-gap" + ErrInternalError = "internal-error" + ErrDataNotExist = "data-not-exist" + ErrWrongFileType = "wrong-filetype" + ErrReadOnly = "read-only" + ErrTooManyAttempts = "too-many-attempts" ) diff --git a/management/webserver/api/response/jsonbody.go b/management/webserver/api/response/jsonbody.go index 4af6e615..9e498b4b 100644 --- a/management/webserver/api/response/jsonbody.go +++ b/management/webserver/api/response/jsonbody.go @@ -17,6 +17,12 @@ var ( ErrorParamNotOK = JSONBody{ErrInternalError, "Error occurred when extracting params", nil} ErrorDataNotExist = JSONBody{ErrDataNotExist, "Data queried does not exist", nil} ErrorReadOnly = JSONBody{ErrReadOnly, "This environment is read only", nil} + + // ErrorInternal is the answer to a failed database or runtime call. The + // text of the underlying error stays in the server log: it can carry + // schema names, addresses and other details of the deployment that the + // caller has no use for. + ErrorInternal = JSONBody{ErrInternalError, "Error occurred when processing the request, please check the server log", nil} ) func Success(c *gin.Context, data interface{}) { diff --git a/management/webserver/api/website.go b/management/webserver/api/website.go index 1a9a8bd1..c79ce7d5 100644 --- a/management/webserver/api/website.go +++ b/management/webserver/api/website.go @@ -5,8 +5,6 @@ import ( "net/http" "strconv" - "chaitin.cn/dev/go/errors" - "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -55,6 +53,8 @@ func PostWebsite(ctx *gin.Context) { if err := params.Validate(); err != nil { logger.Warn(err) + // The message of a validation error is written for the administrator + // ("invalid port ..."), so it is passed on as it is. response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusBadRequest) return } @@ -81,7 +81,7 @@ func PostWebsite(ctx *gin.Context) { }) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } @@ -98,6 +98,8 @@ func PutWebsite(ctx *gin.Context) { if err := params.Validate(); err != nil { logger.Warn(err) + // The message of a validation error is written for the administrator + // ("invalid port ..."), so it is passed on as it is. response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusBadRequest) return } @@ -110,7 +112,7 @@ func PutWebsite(ctx *gin.Context) { return res.Error } if res.RowsAffected == 0 { - return errors.New("Data queried does not exist") + return errDataNotExist } website.Comment = params.Comment @@ -135,7 +137,7 @@ func PutWebsite(ctx *gin.Context) { }) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } @@ -157,7 +159,7 @@ func DeleteWebsite(ctx *gin.Context) { return res.Error } if res.RowsAffected == 0 { - return errors.New("Data queried does not exist") + return errDataNotExist } if config.GlobalConfig.Server.DevMode { @@ -173,7 +175,7 @@ func DeleteWebsite(ctx *gin.Context) { }) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } diff --git a/management/webserver/main.go b/management/webserver/main.go index 9357f045..fb3ad39b 100644 --- a/management/webserver/main.go +++ b/management/webserver/main.go @@ -136,6 +136,7 @@ func main() { gin.SetMode(gin.ReleaseMode) r := gin.Default() + r.Use(middleware.SecurityHeaders) var option model.Options database.GetDB().Where(&model.Options{Key: constants.SecretKey}).First(&option) @@ -145,7 +146,6 @@ func main() { publicRouters := r.Group("/api") publicRouters.POST(api.Login, api.PostLogin) publicRouters.POST(api.Logout, api.PostLogout) - publicRouters.POST(api.Behaviour, api.PostBehaviour) publicRouters.GET(api.OTPUrl, api.GetOTPUrl) publicRouters.GET(api.Version, api.GetVersion) publicRouters.GET(api.UpgradeTips, api.GetUpgradeTips) @@ -169,6 +169,8 @@ func main() { limitedRouters.GET(api.User, api.GetUser) + limitedRouters.POST(api.Behaviour, api.PostBehaviour) + limitedRouters.GET(api.DetectLogList, api.GetDetectLogList) limitedRouters.GET(api.DetectLogDetail, api.GetDetectLogDetail) limitedRouters.POST(api.FalsePositives, api.PostFalsePositives) diff --git a/management/webserver/middleware/security.go b/management/webserver/middleware/security.go new file mode 100644 index 00000000..c8545a66 --- /dev/null +++ b/management/webserver/middleware/security.go @@ -0,0 +1,29 @@ +package middleware + +import "github.com/gin-gonic/gin" + +// SecurityHeaders sets the response headers that keep a browser from +// interpreting an API answer as something else, and keep the console out of +// frames. +// +// The policy deliberately restricts only embedding, plugins and the base URI: +// the management console is served from the same host, so a stricter policy +// would have to be coordinated with the console bundle. +func SecurityHeaders(c *gin.Context) { + // A JSON answer must never be sniffed into HTML or JavaScript. + c.Header("X-Content-Type-Options", "nosniff") + + // The console must not be embedded, so that another page cannot click + // through it on behalf of the administrator. + c.Header("X-Frame-Options", "DENY") + c.Header("Content-Security-Policy", "frame-ancestors 'none'; object-src 'none'; base-uri 'none'") + + // Keep the console URL out of the Referer header of third party requests. + c.Header("Referrer-Policy", "no-referrer") + + // The console is reached over TLS; a browser that saw this header once will + // refuse to fall back to plain HTTP for the following year. + c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + + c.Next() +} diff --git a/management/webserver/model/detectlog.go b/management/webserver/model/detectlog.go index b12473a6..ef8fafd1 100644 --- a/management/webserver/model/detectlog.go +++ b/management/webserver/model/detectlog.go @@ -160,10 +160,13 @@ func InitDetectLogSamples() { protocolList := []int{constants.ProtocolHTTP, constants.ProtocolHTTPS} portList := []uint{80, 443} - provinceList := []string{} - cityList := []string{} - ipList := []string{} - ruleIdList := []string{} + // These lists are indexed with randInt%len(...), so they must not be + // empty: a zero length makes every one of the lookups below a division by + // zero, which panics instead of generating sample data. + provinceList := []string{"Beijing", "Shanghai", "Guangdong"} + cityList := []string{"Beijing", "Shanghai", "Guangzhou"} + ipList := []string{"10.2.35.143", "192.168.1.10", "172.16.0.5"} + ruleIdList := []string{"10001", "20002", "30003"} for i := 0; i < 100; i++ { randInt := rand.Intn(1000) diff --git a/management/webserver/model/option.go b/management/webserver/model/option.go index 538bf5f9..6c838696 100644 --- a/management/webserver/model/option.go +++ b/management/webserver/model/option.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "net/http" + "regexp" "gorm.io/gorm/clause" @@ -24,14 +25,48 @@ type Options struct { func initOptions() error { db := database.GetDB() - secretKey := Options{Key: constants.SecretKey, Value: utils.RandStr(32)} + // The session signing key authorises every management request, so it has + // to come from a cryptographically secure source. + secretKey := Options{Key: constants.SecretKey, Value: utils.RandHex(32)} db.Clauses(clause.OnConflict{DoNothing: true}).Create(&secretKey) machineId := Options{Key: constants.MachineID, Value: utils.RandStr(32)} _ = db.Clauses(clause.OnConflict{DoNothing: true}).Create(&machineId) go NotifyInstallation(machineId.Value) - return nil + return rotatePredictableSecretKey() +} + +// predictableSecretKeyPattern matches the shape of a key produced by the +// former RandStr: 32 letters out of [a-zA-Z]. RandHex produces 64 hexadecimal +// characters, so a value matching this pattern can only come from the +// predictable generator. +var predictableSecretKeyPattern = regexp.MustCompile(`^[a-zA-Z]{32}$`) + +// rotatePredictableSecretKey replaces a session signing key that was generated +// with the predictable time seeded PRNG. +// +// The key is only created when it is missing (OnConflict DoNothing), so an +// installation that was set up by an older release keeps its weak value +// forever. Such a key has to be treated as public: it is rotated here, which +// invalidates the sessions that were signed with it, and the administrator +// simply logs in again. +func rotatePredictableSecretKey() error { + db := database.GetDB() + + var secretKey Options + if err := db.Where(&Options{Key: constants.SecretKey}).First(&secretKey).Error; err != nil { + return err + } + + if !predictableSecretKeyPattern.MatchString(secretKey.Value) { + return nil + } + + logger.Warn("The session signing key was generated by a predictable random source, rotating it. Existing sessions are invalidated.") + secretKey.Value = utils.RandHex(32) + + return db.Save(&secretKey).Error } func NotifyInstallation(machineId string) { diff --git a/management/webserver/model/policygroup.go b/management/webserver/model/policygroup.go index ba6cce8e..636170cf 100644 --- a/management/webserver/model/policygroup.go +++ b/management/webserver/model/policygroup.go @@ -2,6 +2,7 @@ package model import ( "encoding/json" + "fmt" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -79,9 +80,54 @@ func initSrcIPConfig() error { return nil } +// skynetConfigStr is the skynet configuration of 5.3.9. It is also the +// source of truth for the module names the engine knows about: the keys of a +// policy group are module names that end up in this document. +const skynetConfigStr = "{\"decode_config\":{\"decode_methods\":[\"url decode\",\"JSON\",\"base64\",\"hex\",\"eval\",\"XML\",\"PHP deserialize\",\"utf7\"]},\"deep_detect\":false,\"modules\":{\"m_asp_code_injection\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_cmd_injection\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_csrf\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_file_include\":{\"detect_config\":{\"detect_suspicious_schema\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_file_upload\":{\"detect_config\":{\"action_of_handling_custome_file\":[\"deny transmission\"],\"detect_file_content\":true,\"detect_real_file_content\":false,\"enable_module_in_detecting_file_content\":[\"java\",\"asp_code_injection\",\"php_code_injection\"],\"file_type_of_custome_action\":[],\"forbidden_extra_token\":false,\"forbidden_multiple_filename\":true,\"forbidden_suspicious_filename\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_http\":{\"detect_config\":{\"warning_http_parse_failed\":false,\"warning_suspicious_http_version\":false},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"disabled\"},\"m_java\":{\"detect_config\":{\"detect_lookup\":false},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_java_unserialize\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_php_code_injection\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_php_unserialize\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_response\":{\"detect_config\":{\"detection_configure\":[\"detect_jsp_code_leak\",\"detect_php_code_leak\",\"detect_webshell\"],\"error_types\":[\"directory indexing\",\"SQL execution error\",\"server exception\"]},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"disabled\"},\"m_rule\":{\"detect_config\":{\"check_info_leak_by_rsp\":false,\"compatibility_mode\":false,\"detection_configure\":[\"detect_phpinfo\",\"detect_admin_page\",\"detect_backdoor\",\"detect_php_vuln\",\"detect_nginx\",\"detect_dedecms\",\"detect_apache\",\"detect_xml\",\"detect_struts2\",\"detect_java_vuln\",\"detect_php168\",\"detect_wordpress\",\"detect_directory_traversal\",\"detect_iis\",\"detect_gogs_gitea\",\"detect_thinkphp\",\"detect_jenkins\",\"detect_ecshop\",\"detect_nexus\",\"detect_drupal\",\"detect_ghostscript\",\"detect_atlassian\",\"detect_weblogic\",\"detect_coremail\",\"detect_phpcms\",\"detect_spring\",\"detect_southidc\",\"detect_fastjson\",\"detect_tbk_dvr\",\"detect_joomla\",\"detect_ecology_oa\",\"detect_jackson\",\"detect_xstream\",\"detect_activemq\",\"detect_solr\",\"detect_csii\",\"detect_big_ip\",\"detect_apisix\",\"detect_druid\",\"detect_log4j\"],\"info_leak_types\":[\"test file\",\"backup file\",\"code repository\",\"server sensitive file\"],\"rules_config\":{\"disable_ruleid\":[],\"enable_ruleid\":[],\"rules_status\":[]}},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_scanner\":{\"detect_config\":{\"language_types\":[\"python_scanner\",\"go_scanner\"],\"other_types\":[\"normal_scanner\"],\"spider_types\":[]},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"disabled\"},\"m_sqli\":{\"detect_config\":{\"detect_non_injection_sql\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_ssrf\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_ssti\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_xss\":{\"detect_config\":{\"detect_complete_html\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"}},\"timeout\":{\"threshold\":1000,\"log\":\"enabled\"}}" + +// SkynetModules returns the set of module names the engine knows about. +func SkynetModules() (map[string]struct{}, error) { + var sc SkynetConfig + if err := json.Unmarshal([]byte(skynetConfigStr), &sc); err != nil { + return nil, err + } + + modules := make(map[string]struct{}, len(sc.Modules)) + for module := range sc.Modules { + modules[module] = struct{}{} + } + + return modules, nil +} + +// ValidatePolicyGroup checks that a policy group only names modules and modes +// that the engine understands. +// +// The keys of the map are written into the skynet configuration, so an unknown +// module would either be handed to an engine that has never heard of it or make +// the rendering step below dereference a nil module entry. +func ValidatePolicyGroup(pgg PolicyGroup) error { + modules, err := SkynetModules() + if err != nil { + return err + } + + for module, mode := range pgg { + if _, ok := modules[module]; !ok { + return fmt.Errorf("unknown module %q", module) + } + + switch mode { + case StrictMode, DefaultMode, DisableMode: + default: + return fmt.Errorf("module %q: unknown mode %q", module, mode) + } + } + + return nil +} + func GetSkynetConfig(db *gorm.DB) (string, error) { - // skynetConfigStr skynet config in 5.3.9 - const skynetConfigStr = "{\"decode_config\":{\"decode_methods\":[\"url decode\",\"JSON\",\"base64\",\"hex\",\"eval\",\"XML\",\"PHP deserialize\",\"utf7\"]},\"deep_detect\":false,\"modules\":{\"m_asp_code_injection\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_cmd_injection\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_csrf\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_file_include\":{\"detect_config\":{\"detect_suspicious_schema\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_file_upload\":{\"detect_config\":{\"action_of_handling_custome_file\":[\"deny transmission\"],\"detect_file_content\":true,\"detect_real_file_content\":false,\"enable_module_in_detecting_file_content\":[\"java\",\"asp_code_injection\",\"php_code_injection\"],\"file_type_of_custome_action\":[],\"forbidden_extra_token\":false,\"forbidden_multiple_filename\":true,\"forbidden_suspicious_filename\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_http\":{\"detect_config\":{\"warning_http_parse_failed\":false,\"warning_suspicious_http_version\":false},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"disabled\"},\"m_java\":{\"detect_config\":{\"detect_lookup\":false},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_java_unserialize\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_php_code_injection\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_php_unserialize\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_response\":{\"detect_config\":{\"detection_configure\":[\"detect_jsp_code_leak\",\"detect_php_code_leak\",\"detect_webshell\"],\"error_types\":[\"directory indexing\",\"SQL execution error\",\"server exception\"]},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"disabled\"},\"m_rule\":{\"detect_config\":{\"check_info_leak_by_rsp\":false,\"compatibility_mode\":false,\"detection_configure\":[\"detect_phpinfo\",\"detect_admin_page\",\"detect_backdoor\",\"detect_php_vuln\",\"detect_nginx\",\"detect_dedecms\",\"detect_apache\",\"detect_xml\",\"detect_struts2\",\"detect_java_vuln\",\"detect_php168\",\"detect_wordpress\",\"detect_directory_traversal\",\"detect_iis\",\"detect_gogs_gitea\",\"detect_thinkphp\",\"detect_jenkins\",\"detect_ecshop\",\"detect_nexus\",\"detect_drupal\",\"detect_ghostscript\",\"detect_atlassian\",\"detect_weblogic\",\"detect_coremail\",\"detect_phpcms\",\"detect_spring\",\"detect_southidc\",\"detect_fastjson\",\"detect_tbk_dvr\",\"detect_joomla\",\"detect_ecology_oa\",\"detect_jackson\",\"detect_xstream\",\"detect_activemq\",\"detect_solr\",\"detect_csii\",\"detect_big_ip\",\"detect_apisix\",\"detect_druid\",\"detect_log4j\"],\"info_leak_types\":[\"test file\",\"backup file\",\"code repository\",\"server sensitive file\"],\"rules_config\":{\"disable_ruleid\":[],\"enable_ruleid\":[],\"rules_status\":[]}},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_scanner\":{\"detect_config\":{\"language_types\":[\"python_scanner\",\"go_scanner\"],\"other_types\":[\"normal_scanner\"],\"spider_types\":[]},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"disabled\"},\"m_sqli\":{\"detect_config\":{\"detect_non_injection_sql\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_ssrf\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_ssti\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_xss\":{\"detect_config\":{\"detect_complete_html\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"}},\"timeout\":{\"threshold\":1000,\"log\":\"enabled\"}}" var sc SkynetConfig err := json.Unmarshal([]byte(skynetConfigStr), &sc) if err != nil { @@ -98,28 +144,30 @@ func GetSkynetConfig(db *gorm.DB) (string, error) { } for module, mode := range pgg { + // The module names come from a request body, so a key that the engine + // does not know about must be rejected instead of being dereferenced + // (sc.Modules[module] is a nil pointer for an unknown module). + scm, ok := sc.Modules[module] + if !ok || scm == nil { + return "", fmt.Errorf("unknown module %q", module) + } + switch mode { case StrictMode: - scm := sc.Modules[module] scm.HighRiskAction = "deny" scm.MediumRiskAction = "deny" scm.LowRiskAction = "deny" scm.State = "enabled" - sc.Modules[module] = scm case DefaultMode: - scm := sc.Modules[module] scm.HighRiskAction = "deny" scm.MediumRiskAction = "continue" scm.LowRiskAction = "continue" scm.State = "enabled" - sc.Modules[module] = scm case DisableMode: - scm := sc.Modules[module] scm.HighRiskAction = "continue" scm.MediumRiskAction = "continue" scm.LowRiskAction = "continue" scm.State = "disabled" - sc.Modules[module] = scm default: return "", errors.New("no such mode") } diff --git a/management/webserver/model/user.go b/management/webserver/model/user.go index eef58e73..027f7fca 100644 --- a/management/webserver/model/user.go +++ b/management/webserver/model/user.go @@ -16,7 +16,14 @@ type User struct { TFAEnabled bool `gorm:"column:tfa_enabled;default:true"` TFASecret string `gorm:"column:tfa_secret"` LastLoginTime int64 `gorm:"default:0"` - IsEnabled bool `gorm:"default:true"` + + // TFALastUsedStep is the time step (unix time divided by the TOTP period) + // of the passcode that was accepted last. A passcode is only accepted when + // it belongs to a later step, so a captured passcode cannot be replayed + // while it is still inside the validity window. + TFALastUsedStep int64 `gorm:"column:tfa_last_used_step;default:0"` + + IsEnabled bool `gorm:"default:true"` } func initAdminUser() error { diff --git a/management/webserver/model/website.go b/management/webserver/model/website.go index e86920c1..4e50bf55 100644 --- a/management/webserver/model/website.go +++ b/management/webserver/model/website.go @@ -3,6 +3,7 @@ package model import ( "encoding/json" "fmt" + "path/filepath" "regexp" "strconv" "strings" @@ -28,6 +29,13 @@ var ( // nginx "server_name" directive, so anything that could terminate the // directive (or start another one) must not pass. serverNameLabelPattern = regexp.MustCompile(`^[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?$`) + + // certFilenamePattern matches the file name of an uploaded certificate or + // key. The name is interpolated into "ssl_certificate" and + // "ssl_certificate_key", and it is joined to the certificate directory, so + // only a plain file name without a path separator, a control character or + // any nginx syntax may pass. + certFilenamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) ) type Website struct { @@ -72,6 +80,44 @@ func (w *Website) Validate() error { } } + if err = ValidateCertFilename(w.CertFilename); err != nil { + return err + } + + if err = ValidateCertFilename(w.KeyFilename); err != nil { + return err + } + + return nil +} + +// maxCertFilenameLength bounds a certificate file name. +const maxCertFilenameLength = 255 + +// ValidateCertFilename accepts the name of a file in the certificate directory. +// +// The console stores uploaded certificates under a generated name, so a value +// that is not a plain file name is either a mistake or an attempt to inject +// nginx directives (through the rendered ssl_certificate directives) or to +// address a file outside the certificate directory. +func ValidateCertFilename(name string) error { + // An empty name means "this site is served over plain HTTP". + if name == "" { + return nil + } + + if len(name) > maxCertFilenameLength { + return fmt.Errorf("invalid certificate file name %q: longer than %d characters", name, maxCertFilenameLength) + } + + if name != filepath.Base(name) || name == "." || name == ".." { + return fmt.Errorf("invalid certificate file name %q: only a file name without a directory is allowed", name) + } + + if !certFilenamePattern.MatchString(name) { + return fmt.Errorf("invalid certificate file name %q: only letters, digits, dot, dash and underscore are allowed", name) + } + return nil } diff --git a/management/webserver/pkg/fvm/fvm.go b/management/webserver/pkg/fvm/fvm.go index 957a7433..a69aaea4 100644 --- a/management/webserver/pkg/fvm/fvm.go +++ b/management/webserver/pkg/fvm/fvm.go @@ -19,9 +19,6 @@ import ( "C" //nolint:typecheck ) import ( - "fmt" - "io" - "net/http" "net/url" "unsafe" @@ -185,82 +182,22 @@ func (f *FVM) PushFsl(server string, update *FVMUpdate) error { log.Infof("Push FSL to %s", server) var length = uint32(update.update.buf.length) - var p = uintptr(unsafe.Pointer(update.update.buf.ptr)) + ptr := unsafe.Pointer(update.update.buf.ptr) log.Infof("Get update length %d, %d", length, C.int(update.update.buf.length)) - u := &upload{ - Length: length, - P: p, - } + // Copy the compiled policy out of the C buffer. The C memory can be + // released while the request is in flight, and the Go side needs a value + // it can replay for every attempt. + policy := C.GoBytes(ptr, C.int(length)) base, _ := url.Parse(server) base.Path = "/update/policy" snURL := base.String() - req, err := http.NewRequest( - "POST", - snURL, - u, - ) - if err != nil { - return errors.Annotatef(err, "create request failed") - } - req.Header.Add("Content-Type", "application/octet-stream") - // req.Header.Set("Connection", "close") // The compiled policy is pushed to the detector over the container network // and replaces the bytecode the engine executes, so the certificate of an // HTTPS detector must be verified: a man in the middle could otherwise // replace the policy and silently disable the WAF. - httpClient := &http.Client{} - - var ( - respErr error - resp *http.Response - ) - for i := 0; i < 3; i++ { - resp, respErr = httpClient.Do(req) - if respErr == nil { - break - } - } - if respErr != nil { - return errors.Annotatef(respErr, "Get response failed") - } - if resp != nil && resp.StatusCode != 200 { - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - log.Fatal(err) - } - bodyString := string(bodyBytes) - - return errors.New(fmt.Sprintf("Push FSL response(%d %s)", resp.StatusCode, bodyString)) - } - - return nil -} - -type upload struct { - Length uint32 - off uint32 - P uintptr -} - -func (u *upload) Read(p []byte) (n int, err error) { - if u.off >= u.Length { - if len(p) == 0 { - return 0, nil - } - return 0, io.EOF - } - plen := uint32(len(p)) - var i uint32 - for i = 0; i < plen; i++ { - if i+u.off >= u.Length { - break - } - p[i] = *(*byte)(unsafe.Pointer(u.P + unsafe.Sizeof(p[0])*uintptr(i+u.off))) - } - u.off = i + u.off - return int(i), nil + return pushPolicy(snURL, policy) } diff --git a/management/webserver/pkg/fvm/push.go b/management/webserver/pkg/fvm/push.go new file mode 100644 index 00000000..0f381021 --- /dev/null +++ b/management/webserver/pkg/fvm/push.go @@ -0,0 +1,63 @@ +package fvm + +import ( + "bytes" + "fmt" + "io" + "net/http" + + "chaitin.cn/dev/go/errors" + "chaitin.cn/dev/go/log" +) + +const ( + // pushPolicyAttempts is how often a failed push is retried. + pushPolicyAttempts = 3 + + // maxPushPolicyResponseBody bounds the part of an error response that is + // kept for the message. + maxPushPolicyResponseBody = 4 << 10 +) + +// pushPolicy sends a compiled policy to the detector. +// +// The body of an http.Request is a stream that can be read once, so a retry +// that reuses a request sends an empty body. The detector accepts an empty +// update with 200, which would leave the previous policy in place while the +// caller believes the new one was applied: every attempt therefore builds its +// own request around the same bytes. +func pushPolicy(snURL string, policy []byte) error { + client := &http.Client{} + + var lastErr error + for attempt := 0; attempt < pushPolicyAttempts; attempt++ { + req, err := http.NewRequest(http.MethodPost, snURL, bytes.NewReader(policy)) + if err != nil { + return errors.Annotatef(err, "create request failed") + } + req.Header.Add("Content-Type", "application/octet-stream") + req.ContentLength = int64(len(policy)) + + resp, err := client.Do(req) + if err != nil { + lastErr = err + continue + } + + body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxPushPolicyResponseBody)) + if closeErr := resp.Body.Close(); closeErr != nil { + log.Warnf("Close push FSL response failed: %s", closeErr) + } + if readErr != nil { + log.Warnf("Read push FSL response failed: %s", readErr) + } + + if resp.StatusCode != http.StatusOK { + return errors.New(fmt.Sprintf("Push FSL response(%d %s)", resp.StatusCode, string(body))) + } + + return nil + } + + return errors.Annotatef(lastErr, "Get response failed") +} diff --git a/management/webserver/pkg/log/log.go b/management/webserver/pkg/log/log.go index 3af9a18b..a68a98ae 100644 --- a/management/webserver/pkg/log/log.go +++ b/management/webserver/pkg/log/log.go @@ -51,11 +51,17 @@ func InitLogger() error { fileFlag = fileFlag | os.O_CREATE } - if fp, err := os.OpenFile(config.GlobalConfig.Log.Output, fileFlag, os.ModePerm); err != nil { + // os.ModePerm (0777) let every local user rewrite the audit log. The + // file is created with an owner/group readable mode instead. + if fp, err := os.OpenFile(config.GlobalConfig.Log.Output, fileFlag, logFileMode); err != nil { return fmt.Errorf("failed to open log file: %s", err.Error()) } else { log.SetOutput(log.AllLoggers, log.NewLockOutput(fp)) } + + // The mode above only applies when the file is created, so a log file + // that an older release left world writable is tightened here. + tightenLogFileMode(config.GlobalConfig.Log.Output) } // hook @@ -67,6 +73,28 @@ func InitLogger() error { return nil } +// logFileMode is the permission the log file is created with. Logs are audit +// material: they must not be writable by other local users. +const logFileMode os.FileMode = 0640 + +// looseLogFileMask matches the group and world write bits. +const looseLogFileMask os.FileMode = 0o022 + +// tightenLogFileMode removes the group and world write bits from an existing +// log file, so that a log file created by an older release cannot stay +// tamperable by other local users. +func tightenLogFileMode(path string) { + info, err := os.Stat(path) + if err != nil || info.Mode().Perm()&looseLogFileMask == 0 { + return + } + + log.Warnf("Log file %s is writable by other users, setting mode to %#o", path, logFileMode) + if err := os.Chmod(path, logFileMode); err != nil { + log.Warnf("Cannot tighten the mode of log file %s: %s", path, err) + } +} + type RuntimeHook struct{} func (h *RuntimeHook) Levels() []logrus.Level { diff --git a/management/webserver/rpc/auth.go b/management/webserver/rpc/auth.go new file mode 100644 index 00000000..66cb1eed --- /dev/null +++ b/management/webserver/rpc/auth.go @@ -0,0 +1,172 @@ +package rpc + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "os" + "path/filepath" + "strings" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" +) + +const ( + // TokenEnv holds the shared secret of the control channel between + // tcontrollerd and this server. + TokenEnv = "MGT_GRPC_TOKEN" + + // TokenFileEnv names the file the secret is read from when TokenEnv is + // empty. + TokenFileEnv = "MGT_GRPC_TOKEN_FILE" + + // DefaultTokenFile is where the secret is read from (and created) by + // default. The standard deployment mounts the same host directory at + // /app/sock in the management container and in the tengine container, + // which is where tcontrollerd runs, so both sides find the same value + // without any configuration. + DefaultTokenFile = "/app/sock/mgt_grpc_token" + + // tokenMetadataKey is the gRPC metadata key that carries the secret. gRPC + // metadata keys have to be lower case. + tokenMetadataKey = "mgt-token" + + // tokenBytes is the size of a generated secret. + tokenBytes = 32 +) + +// tokenFileMode keeps the secret readable for the owner only. +const tokenFileMode os.FileMode = 0600 + +// controlToken is the secret that Subscribe requires. It is empty when the +// server could not obtain one, in which case every subscription is refused. +var controlToken string + +// InitControlToken loads the token of the control channel, creating it when it +// does not exist yet. +// +// A failure here does not stop the server: it disables the control channel, +// which is reported by Publish as an error, and leaves the console usable so +// that an operator can fix the deployment. +func InitControlToken() error { + if token := strings.TrimSpace(os.Getenv(TokenEnv)); token != "" { + controlToken = token + return nil + } + + token, err := loadOrCreateToken(TokenFileFromEnv()) + if err != nil { + return err + } + + controlToken = token + return nil +} + +// TokenFileFromEnv returns the path of the token file. +func TokenFileFromEnv() string { + if path := strings.TrimSpace(os.Getenv(TokenFileEnv)); path != "" { + return path + } + + return DefaultTokenFile +} + +// loadOrCreateToken reads the control channel token from path, creating it with +// a fresh random value when the file does not exist yet. +func loadOrCreateToken(path string) (string, error) { + if token := readToken(path); token != "" { + return token, nil + } + + token := make([]byte, tokenBytes) + if _, err := rand.Read(token); err != nil { + return "", err + } + value := hex.EncodeToString(token) + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return "", err + } + + // O_EXCL: another process may have created the file between the read above + // and this call, in which case its value wins. + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, tokenFileMode) + if err != nil { + if os.IsExist(err) { + return readToken(path), nil + } + return "", err + } + + if _, err = file.WriteString(value + "\n"); err != nil { + file.Close() + return "", err + } + + if err = file.Close(); err != nil { + return "", err + } + + return value, nil +} + +// readToken returns the token stored in path, or an empty string when it is not +// available. +func readToken(path string) string { + content, err := os.ReadFile(path) + if err != nil { + return "" + } + + return strings.TrimSpace(string(content)) +} + +// subscribeAuthInterceptor refuses a Subscribe call that does not prove that it +// knows the control channel token. +// +// Without it, any client that can reach the gRPC port becomes the subscriber: +// it receives the full site configuration of the installation and silently +// takes the place of tcontrollerd, which then stops receiving its updates. +func subscribeAuthInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + if controlToken == "" { + logger.Error("The control channel token is not configured, refusing subscriptions. Set " + TokenEnv + " or make " + TokenFileFromEnv() + " writable.") + return status.Error(codes.Unauthenticated, "the control channel token is not configured on the server") + } + + if subtle.ConstantTimeCompare([]byte(incomingToken(ss.Context())), []byte(controlToken)) != 1 { + logger.Warnf("Rejected a subscription from %s: invalid control channel token", peerAddress(ss.Context())) + return status.Error(codes.Unauthenticated, "a valid control channel token is required") + } + + return handler(srv, ss) +} + +// incomingToken returns the token that the client sent in the metadata. +func incomingToken(ctx context.Context) string { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return "" + } + + values := md.Get(tokenMetadataKey) + if len(values) == 0 { + return "" + } + + return values[0] +} + +// peerAddress returns the address of the client for the log. +func peerAddress(ctx context.Context) string { + if p, ok := peer.FromContext(ctx); ok && p.Addr != nil { + return p.Addr.String() + } + + return "unknown" +} diff --git a/management/webserver/rpc/main.go b/management/webserver/rpc/main.go index 3b5ae8c9..b20c4137 100644 --- a/management/webserver/rpc/main.go +++ b/management/webserver/rpc/main.go @@ -31,7 +31,19 @@ func StartGRPCSever() error { if err != nil { return errors.Wrap(err, "Failed to listen") } - var opts []grpc.ServerOption + + if err := InitControlToken(); err != nil { + // Not fatal: the console keeps working, and every subscription is + // refused with an explanation until the token is available. + logger.Errorf("Failed to init the control channel token: %s", err) + } + + opts := []grpc.ServerOption{ + // The control channel carries the complete site configuration of the + // installation and republishes it on every change, so a subscription + // has to prove that it is tcontrollerd. + grpc.StreamInterceptor(subscribeAuthInterceptor), + } grpcServer := grpc.NewServer(opts...) pb.RegisterWebsiteServer(grpcServer, GetWebsiteServer()) go func() { diff --git a/management/webserver/rpc/website.go b/management/webserver/rpc/website.go index 08012923..33e05292 100644 --- a/management/webserver/rpc/website.go +++ b/management/webserver/rpc/website.go @@ -3,6 +3,7 @@ package rpc import ( "encoding/json" "io" + "sync" "time" "chaitin.cn/dev/go/errors" @@ -12,25 +13,35 @@ import ( ) var ( - ping = pb.Event{Type: Ping, Msg: nil} - Subscriber *StreamClient // only ONE sub + ping = pb.Event{Type: Ping, Msg: nil} + + // subscriberMu guards subscriber, which is written by the goroutine that + // serves the Subscribe stream and read by every request handler that + // publishes a configuration. + subscriberMu sync.RWMutex + subscriber *StreamClient // only ONE sub ) func Publish(msg []byte, eventType string) error { - if Subscriber == nil { + // The subscriber is read once and every use below goes through the local + // variable: a new stream may replace it at any moment, and re-reading the + // global pointer would let this call talk to a closing stream or to a + // different subscription than the one it started with. + sub := currentSubscriber() + if sub == nil { return errors.New("Service is abnormal, and the nginx conf cannot be updated for the time being. Please go to the shell to check the relevant logs") } - err := Subscriber.stream.Send(&pb.Event{ + err := sub.stream.Send(&pb.Event{ Type: eventType, Msg: msg, }) if err != nil { select { - case <-Subscriber.stream.Context().Done(): - err = Subscriber.stream.Context().Err() // context canceled - Subscriber = nil + case <-sub.stream.Context().Done(): + err = sub.stream.Context().Err() // context canceled + clearSubscriber(sub) return err - case Subscriber.errCh <- errors.Wrapf(err, "Send event err: %s", msg): + case sub.errCh <- errors.Wrapf(err, "Send event err: %s", msg): return errors.Wrapf(err, "Send event err: %s", msg) } } @@ -40,7 +51,7 @@ func Publish(msg []byte, eventType string) error { for { select { - case rsp := <-Subscriber.rspCh: + case rsp := <-sub.rspCh: if rsp.Err { return errors.New(string(rsp.Msg)) } else { @@ -62,6 +73,43 @@ type StreamClient struct { quit chan struct{} // quit stream client gracefully } +// currentSubscriber returns the active subscriber, or nil when no stream is +// connected. +func currentSubscriber() *StreamClient { + subscriberMu.RLock() + defer subscriberMu.RUnlock() + + return subscriber +} + +// setSubscriber makes sc the active subscriber. +func setSubscriber(sc *StreamClient) { + subscriberMu.Lock() + defer subscriberMu.Unlock() + + if subscriber != nil && subscriber != sc { + // A client that restarts reconnects while its previous stream may not + // have timed out yet, so a replacement is expected; it is logged + // because only a client that knows the control channel token can + // trigger it. + logger.Warn("A new subscriber replaces the previous one") + } + + subscriber = sc +} + +// clearSubscriber forgets the active subscriber, but only when it is still the +// one that asks: a stream that ends after it was replaced must not disconnect +// its successor. +func clearSubscriber(sc *StreamClient) { + subscriberMu.Lock() + defer subscriberMu.Unlock() + + if subscriber == sc { + subscriber = nil + } +} + func newStreamClient(stream pb.Website_SubscribeServer) *StreamClient { return &StreamClient{ stream: stream, @@ -77,7 +125,7 @@ func (sc *StreamClient) pingLoop() { defer pingTicker.Stop() for { - if Subscriber != nil && sc != Subscriber { + if current := currentSubscriber(); current != nil && current != sc { // new subscriber in replace of the old one. logger.Debug("New subscriber in replace of the old one") return @@ -101,7 +149,7 @@ func (sc *StreamClient) pingLoop() { func (sc *StreamClient) recvLoop() { for { - if Subscriber != nil && sc != Subscriber { + if current := currentSubscriber(); current != nil && current != sc { // new subscriber in replace of the old one. close(sc.quit) logger.Debug("New subscriber in replace of the old one") @@ -153,11 +201,16 @@ func publishFullWebsite() error { // Subscribe is gRPC API entrypoint func (ws *WebsiteServer) Subscribe(stream pb.Website_SubscribeServer) error { - Subscriber = newStreamClient(stream) - defer Subscriber.timer.Stop() + sc := newStreamClient(stream) + setSubscriber(sc) + + defer sc.timer.Stop() + // A stream that ends without being replaced leaves the channel unusable + // until a new client connects, so the global pointer is released here. + defer clearSubscriber(sc) - go Subscriber.pingLoop() - go Subscriber.recvLoop() + go sc.pingLoop() + go sc.recvLoop() if err := publishFullWebsite(); err != nil { // triggered when tcd starts, ignore push error messages @@ -165,17 +218,16 @@ func (ws *WebsiteServer) Subscribe(stream pb.Website_SubscribeServer) error { } select { - case <-Subscriber.quit: + case <-sc.quit: logger.Infof("Disconnected gracefully") return nil - case <-Subscriber.timer.C: + case <-sc.timer.C: logger.Error("Keepalive timeout") - case err := <-Subscriber.errCh: + case err := <-sc.errCh: logger.WithError(err).Error() return err - case <-Subscriber.stream.Context().Done(): - logger.Infof("Subscribe context done: %s", Subscriber.stream.Context().Err()) - Subscriber = nil + case <-sc.stream.Context().Done(): + logger.Infof("Subscribe context done: %s", sc.stream.Context().Err()) } return nil diff --git a/management/webserver/utils/cert.go b/management/webserver/utils/cert.go index 816cd0c1..cfe86106 100644 --- a/management/webserver/utils/cert.go +++ b/management/webserver/utils/cert.go @@ -9,6 +9,7 @@ import ( "encoding/pem" "io/ioutil" "math/big" + "os" "time" ) @@ -79,7 +80,16 @@ func WriteCertIfNotExist(certFilePath, keyFilePath string, generator func() ([]b return err } - if err = EnsureRenameWriteFile(keyFilePath, key, 0644); err != nil { + // A certificate is public, its private key is not: 0644 lets every local + // user read the key and impersonate the service it belongs to. + if err = EnsureRenameWriteFile(keyFilePath, key, 0600); err != nil { + return err + } + + // The file may already have existed with the permissive mode that older + // releases gave it, in which case the mode passed to the write above is + // not applied by the kernel. + if err = os.Chmod(keyFilePath, 0600); err != nil { return err } diff --git a/management/webserver/utils/file.go b/management/webserver/utils/file.go index 4ae2d88c..c5e45c46 100644 --- a/management/webserver/utils/file.go +++ b/management/webserver/utils/file.go @@ -49,11 +49,41 @@ func FilesExist(paths ...string) (bool, error) { } func RenameWriteFile(filename string, data []byte, perm os.FileMode) error { - randFileName := filename + ".tmp." + RandStr(8) - if err := ioutil.WriteFile(randFileName, data, perm); err != nil { + // os.CreateTemp creates the file with O_CREATE|O_EXCL and mode 0600, so a + // file or symlink that was planted at the temporary path beforehand cannot + // be followed and the payload is never readable by other users before the + // rename. The previous implementation built a predictable name with + // math/rand and wrote through ioutil.WriteFile, which does both. + tmpFile, err := os.CreateTemp(filepath.Dir(filename), filepath.Base(filename)+".tmp.") + if err != nil { + return err + } + tmpName := tmpFile.Name() + + renamed := false + defer func() { + if !renamed { + os.Remove(tmpName) + } + }() + + if err = tmpFile.Chmod(perm); err != nil { + tmpFile.Close() + return err + } + if _, err = tmpFile.Write(data); err != nil { + tmpFile.Close() + return err + } + if err = tmpFile.Close(); err != nil { return err } - return os.Rename(randFileName, filename) + if err = os.Rename(tmpName, filename); err != nil { + return err + } + + renamed = true + return nil } func EnsureRenameWriteFile(path string, data []byte, mode os.FileMode) error { diff --git a/management/webserver/utils/random.go b/management/webserver/utils/random.go index 44ebd98a..bd09f32c 100644 --- a/management/webserver/utils/random.go +++ b/management/webserver/utils/random.go @@ -1,18 +1,42 @@ package utils import ( - "math/rand" - "time" + "crypto/rand" + "encoding/hex" + "math/big" ) var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") +// RandStr returns n letters drawn from a cryptographically secure source. +// +// The values produced here are used as session signing keys, temporary file +// names and event ids. math/rand is seeded from a predictable value (and its +// output can be reconstructed from a single sample), so it must not be used +// for any of them. func RandStr(n int) string { - rand.Seed(time.Now().UnixNano()) - b := make([]rune, n) + limit := big.NewInt(int64(len(letters))) for i := range b { - b[i] = letters[rand.Intn(len(letters))] + idx, err := rand.Int(rand.Reader, limit) + if err != nil { + // crypto/rand fails only when the system entropy source is + // unusable. Continuing with a predictable value is not an option, + // because callers use the result as a secret. + panic("utils: cannot read from the system entropy source: " + err.Error()) + } + b[i] = letters[idx.Int64()] } return string(b) } + +// RandHex returns a hex string of 2*n characters read from crypto/rand. Every +// byte of the result carries eight bits of entropy, which makes it the right +// helper for keys and secrets. +func RandHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic("utils: cannot read from the system entropy source: " + err.Error()) + } + return hex.EncodeToString(b) +} diff --git a/mcp_server/README.md b/mcp_server/README.md index e436e1ce..1042a221 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -44,7 +44,7 @@ instances: token_file: "/run/secrets/production-a.token" timeout: 30 debug: false - insecure_skip_verify: true + insecure_skip_verify: false - id: "production-b" display_name: "Production B" @@ -52,11 +52,16 @@ instances: token_file: "/run/secrets/production-b.token" timeout: 30 debug: false - insecure_skip_verify: true + insecure_skip_verify: false ``` `LISTEN_ADDRESS` and `LISTEN_PORT` override listener settings. There is deliberately no global `SAFELINE_API_TOKEN`: every instance uses a separate token file. +`insecure_skip_verify` disables TLS verification for the SafeLine API. The API +Token is an administrator credential that is sent on every request, so leave the +setting at `false` and give the instance a certificate the server trusts. Enable +it only for a lab instance whose certificate cannot be replaced. + Each `display_name` must be unique, ignoring letter case, and must not match another instance's `id`. During server discovery (or legacy initialization), the server publishes only the `display_name` to `instance_id` mappings as Server Instructions. This lets a user refer to a friendly name while the AI still calls `get_attack_events` with the stable, explicit `instance_id`. Instance addresses and credentials are never included in those instructions. SafeLine CE does not yet provide a dedicated read-only service credential for all required management APIs. This release therefore uses an administrator API Token per instance, while the MCP process enforces the fixed read-only API allowlist. @@ -65,20 +70,15 @@ SafeLine CE does not yet provide a dedicated read-only service credential for al MCP client authentication is independent from the downstream SafeLine API Tokens. -### Authentication disabled - -```text -MCP_AUTH_ENABLED=false -``` - -No MCP client token is generated or required. +Authentication is on by default: the server always installs the bearer gate +unless a deployment explicitly opts out, and it refuses to start without a token +on any non-loopback listener. -### Authentication enabled +### Initialization Generate the MCP client Bearer Token once during deployment initialization: ```bash -export MCP_AUTH_ENABLED=true docker compose run --rm mcp_server auth init ``` @@ -97,7 +97,9 @@ trusted HTTPS-terminating gateway or keep it on an equivalently protected private transport. Do not set `MCP_PUBLISH_ADDRESS=0.0.0.0` on an untrusted network without TLS termination. -If authentication is enabled but state has not been initialized, the server refuses to start. Explicit rotation is available locally: +The server refuses to start while this state is missing, so a deployment cannot +serve any request before the token exists. Explicit rotation is available +locally: ```bash docker compose run --rm mcp_server auth rotate @@ -109,6 +111,16 @@ as a complete MCP OAuth 2.1 implementation. Run `auth rotate` as a single serialized deployment operation; concurrent rotation commands are not supported. +### Disabling authentication + +`MCP_AUTH_DISABLED=true` (or `MCP_AUTH_ENABLED=false`) removes the gate. The +server then refuses to start unless the listener is loopback-only, so a disabled +gate cannot silently publish attack telemetry to the network: + +```text +MCP_AUTH_DISABLED=true +``` + ## Docker deployment 1. Build the image. The included `.dockerignore` excludes `secrets/` and @@ -135,7 +147,8 @@ rotation commands are not supported. 3. Edit `config.yaml` so each `token_file` matches its file under `/run/secrets`. -4. If MCP authentication is enabled, run the one-time `auth init` command and save the displayed token. +4. Run the one-time `auth init` command and save the displayed token. The + service refuses to start until this state exists. 5. Start the service: @@ -151,16 +164,15 @@ loopback-only unless `MCP_PUBLISH_ADDRESS` is explicitly changed. ```bash go test ./... go build -o mcp-server . -MCP_AUTH_ENABLED=false ./mcp-server --config ./config.yaml +./mcp-server auth init --state-file "$PWD/state/auth.json" +./mcp-server --config ./config.yaml ``` -For an authenticated local deployment, choose a persistent state file before initialization: +The default configuration listens on `127.0.0.1`, which is the only address the +server accepts while authentication is disabled: ```bash -export MCP_AUTH_ENABLED=true -export MCP_AUTH_STATE_FILE="$PWD/state/auth.json" -./mcp-server auth init -./mcp-server --config ./config.yaml +MCP_AUTH_DISABLED=true ./mcp-server --config ./config.yaml ``` ## Tool contract diff --git a/mcp_server/config.yaml b/mcp_server/config.yaml index d7d45d77..37eaa8a1 100644 --- a/mcp_server/config.yaml +++ b/mcp_server/config.yaml @@ -21,7 +21,11 @@ instances: token_file: "/run/secrets/safeline-a.token" timeout: 30 debug: false - insecure_skip_verify: true + # Leave TLS verification enabled: the SafeLine API Token is an + # administrator credential and disabling verification lets anyone on the + # path read it. Set this to true only for a lab instance with an + # unchangeable self-signed certificate. + insecure_skip_verify: false - id: "safeline-b" display_name: "SafeLine B" @@ -29,4 +33,4 @@ instances: token_file: "/run/secrets/safeline-b.token" timeout: 30 debug: false - insecure_skip_verify: true + insecure_skip_verify: false diff --git a/mcp_server/docker-compose.yml b/mcp_server/docker-compose.yml index c9fb3457..2ad91e8e 100644 --- a/mcp_server/docker-compose.yml +++ b/mcp_server/docker-compose.yml @@ -9,7 +9,10 @@ services: environment: LISTEN_ADDRESS: "0.0.0.0" LISTEN_PORT: "${LISTEN_PORT:-5678}" - MCP_AUTH_ENABLED: "${MCP_AUTH_ENABLED:-false}" + # Authentication is enabled by default. Only set MCP_AUTH_DISABLED=true + # for a loopback-only deployment; a non-loopback listener without a token + # is refused at startup. + MCP_AUTH_DISABLED: "${MCP_AUTH_DISABLED:-}" MCP_AUTH_STATE_FILE: "/var/lib/safeline-mcp/auth.json" read_only: true security_opt: diff --git a/mcp_server/internal/auth/auth.go b/mcp_server/internal/auth/auth.go index 002cbe56..ad9e0f07 100644 --- a/mcp_server/internal/auth/auth.go +++ b/mcp_server/internal/auth/auth.go @@ -20,6 +20,7 @@ import ( const ( EnabledEnv = "MCP_AUTH_ENABLED" + DisabledEnv = "MCP_AUTH_DISABLED" StateFileEnv = "MCP_AUTH_STATE_FILE" DefaultStateFile = "/var/lib/safeline-mcp/auth.json" @@ -42,17 +43,31 @@ type Authenticator struct { tokenHash [sha256.Size]byte } +// EnabledFromEnv reports whether the MCP bearer gate must be active. +// +// Authentication is mandatory unless a deployment explicitly opts out: an +// unset environment means enabled, so exposing attack telemetry by simply +// forgetting one variable is not possible. MCP_AUTH_ENABLED=false and +// MCP_AUTH_DISABLED=true both opt out; the server then refuses to listen on a +// non-loopback address. func EnabledFromEnv() (bool, error) { - raw := strings.TrimSpace(os.Getenv(EnabledEnv)) - if raw == "" { - return false, nil + if raw := strings.TrimSpace(os.Getenv(EnabledEnv)); raw != "" { + enabled, err := strconv.ParseBool(raw) + if err != nil { + return false, fmt.Errorf("parse %s: %w", EnabledEnv, err) + } + return enabled, nil } - enabled, err := strconv.ParseBool(raw) - if err != nil { - return false, fmt.Errorf("parse %s: %w", EnabledEnv, err) + if raw := strings.TrimSpace(os.Getenv(DisabledEnv)); raw != "" { + disabled, err := strconv.ParseBool(raw) + if err != nil { + return false, fmt.Errorf("parse %s: %w", DisabledEnv, err) + } + return !disabled, nil } - return enabled, nil + + return true, nil } func StateFileFromEnv() string { diff --git a/mcp_server/internal/auth/auth_test.go b/mcp_server/internal/auth/auth_test.go index 028436aa..446b0966 100644 --- a/mcp_server/internal/auth/auth_test.go +++ b/mcp_server/internal/auth/auth_test.go @@ -246,8 +246,17 @@ func TestMiddlewareRequiresBearerToken(t *testing.T) { } func TestEnabledFromEnv(t *testing.T) { - t.Setenv(EnabledEnv, "true") + // Authentication must be mandatory when the deployment says nothing at all, + // otherwise forgetting one variable would expose attack telemetry. + t.Setenv(EnabledEnv, "") + t.Setenv(DisabledEnv, "") enabled, err := EnabledFromEnv() + if err != nil || !enabled { + t.Fatalf("EnabledFromEnv() with unset environment = (%v, %v), want true", enabled, err) + } + + t.Setenv(EnabledEnv, "true") + enabled, err = EnabledFromEnv() if err != nil || !enabled { t.Fatalf("EnabledFromEnv() = (%v, %v), want true", enabled, err) } @@ -256,4 +265,22 @@ func TestEnabledFromEnv(t *testing.T) { if _, err := EnabledFromEnv(); err == nil { t.Fatal("EnabledFromEnv() accepted an invalid boolean") } + + t.Setenv(EnabledEnv, "") + t.Setenv(DisabledEnv, "true") + if enabled, err := EnabledFromEnv(); err != nil || enabled { + t.Fatalf("EnabledFromEnv() with %s=true = (%v, %v), want false", DisabledEnv, enabled, err) + } + + t.Setenv(EnabledEnv, "false") + t.Setenv(DisabledEnv, "") + if enabled, err := EnabledFromEnv(); err != nil || enabled { + t.Fatalf("EnabledFromEnv() with %s=false = (%v, %v), want false", EnabledEnv, enabled, err) + } + + t.Setenv(EnabledEnv, "") + t.Setenv(DisabledEnv, "not-a-bool") + if _, err := EnabledFromEnv(); err == nil { + t.Fatal("EnabledFromEnv() accepted an invalid disable boolean") + } } diff --git a/mcp_server/internal/config/config_test.go b/mcp_server/internal/config/config_test.go index 7940903c..ea6cca33 100644 --- a/mcp_server/internal/config/config_test.go +++ b/mcp_server/internal/config/config_test.go @@ -31,7 +31,7 @@ instances: base_url: https://dev152:9443 token_file: secrets/dev152_token timeout: 12 - insecure_skip_verify: true + insecure_skip_verify: false - id: dev180 base_url: https://dev180:9443 token_file: /run/secrets/dev180_token diff --git a/mcp_server/main.go b/mcp_server/main.go index 8ffa24ce..f5dd09f0 100644 --- a/mcp_server/main.go +++ b/mcp_server/main.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "io" + "net" "os" "strings" @@ -89,6 +90,21 @@ func buildServerInstructions(instances []*config.InstanceConfig) string { return instructions.String() } +// isLoopbackHost reports whether host only accepts connections from the local +// machine. Anything else (an interface address, 0.0.0.0, a hostname) is +// reachable from the network. +func isLoopbackHost(host string) bool { + host = strings.TrimSpace(host) + if host == "" { + return false + } + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(strings.Trim(host, "[]")) + return ip != nil && ip.IsLoopback() +} + func runServer(args []string, stderr io.Writer) error { flags := flag.NewFlagSet("mcp-server", flag.ContinueOnError) flags.SetOutput(stderr) @@ -115,16 +131,27 @@ func runServer(args []string, stderr io.Writer) error { return fmt.Errorf("initialize logger: %w", err) } + serverConfig := config.GetServer() + authEnabled, err := auth.EnabledFromEnv() + if err != nil { + return err + } + if !authEnabled && !isLoopbackHost(serverConfig.Host) { + // Without the bearer gate every reachable listener would proxy SafeLine + // attack telemetry to anyone who can open a socket, so refuse to start + // rather than depend on the deployer noticing. + return fmt.Errorf( + "MCP authentication is disabled but the listener is not loopback-only (host %q); keep authentication enabled or bind a loopback address", + serverConfig.Host, + ) + } + instances := config.GetInstances() logger.With("instances", len(instances)).Info("initializing SafeLine API clients") if err := api.InitInstances(instances); err != nil { return fmt.Errorf("initialize SafeLine API clients: %w", err) } - authEnabled, err := auth.EnabledFromEnv() - if err != nil { - return err - } var middleware []mcpserver.Middleware if authEnabled { stateFile := auth.StateFileFromEnv() @@ -136,7 +163,6 @@ func runServer(args []string, stderr io.Writer) error { } logger.With("enabled", authEnabled).Info("MCP bearer authentication configured") - serverConfig := config.GetServer() s := mcpserver.NewMCPServer(serverConfig.Name, serverConfig.Version, buildServerInstructions(instances)) for _, tool := range tools.Tools() { if err := tool.Register(s); err != nil { diff --git a/mcp_server/main_test.go b/mcp_server/main_test.go index 25525515..1caae6b5 100644 --- a/mcp_server/main_test.go +++ b/mcp_server/main_test.go @@ -96,3 +96,42 @@ func TestBuildServerInstructionsIncludesFriendlyInstanceMappings(t *testing.T) { } } } + +func TestAuthDisabledRefusesNonLoopbackListener(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.yaml") + contents := ` +server: + name: SafeLine MCP + version: 1.0.0 + host: 0.0.0.0 + port: 5678 +logger: + level: info +instances: + - id: dev152 + base_url: https://dev152:9443 + token_file: /run/secrets/dev152_token +` + if err := os.WriteFile(configPath, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + + t.Setenv(auth.DisabledEnv, "true") + err := run([]string{"--config", configPath}, &bytes.Buffer{}, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "loopback") { + t.Fatalf("run() error = %v, want refusal of a non-loopback listener", err) + } +} + +func TestIsLoopbackHost(t *testing.T) { + for _, host := range []string{"127.0.0.1", "::1", "[::1]", "localhost", "LocalHost"} { + if !isLoopbackHost(host) { + t.Errorf("isLoopbackHost(%q) = false, want true", host) + } + } + for _, host := range []string{"", "0.0.0.0", "::", "10.0.0.10", "example.com"} { + if isLoopbackHost(host) { + t.Errorf("isLoopbackHost(%q) = true, want false", host) + } + } +} diff --git a/yanshi/src/common.hh b/yanshi/src/common.hh index 1d1051bb..51ca0a1c 100644 --- a/yanshi/src/common.hh +++ b/yanshi/src/common.hh @@ -44,6 +44,11 @@ extern char*** _NSGetArgv(void); #define CYAN "\x1b[1;36m" #define NORMAL_YELLOW "\x1b[33m" const long MAX_CODEPOINT = 0x10ffff; +// Upper bound for a single repetition count. gen_repeat() rejects larger +// bounds while parsing and FsaAnno::repeat() enforces the same limit as +// defense in depth, because the automaton expansion performed per iteration +// makes the cost of a large count grow without bound (CWE-770). +const long MAX_REPEAT = 10000; extern long action_label_base, action_label, call_label_base, call_label, collapse_label_base, collapse_label; void bold(long fd = 1); diff --git a/yanshi/src/fsa_anno.cc b/yanshi/src/fsa_anno.cc index 3e6c6738..7ae94999 100644 --- a/yanshi/src/fsa_anno.cc +++ b/yanshi/src/fsa_anno.cc @@ -7,6 +7,7 @@ #include #include #include +#include #include #include using namespace std; @@ -294,6 +295,12 @@ void FsaAnno::question(QuestionExpr* expr) { } void FsaAnno::repeat(RepeatExpr& expr) { + // gen_repeat() already rejects out-of-range bounds at parse time; check + // again here so a programmatically built RepeatExpr can never drive the + // unbounded copy/concat loops below (CWE-770). + if (expr.low < 0 || expr.low > MAX_REPEAT || + expr.high < expr.low || (expr.high != LONG_MAX && expr.high > MAX_REPEAT)) + err_exit(EX_USAGE, "repeat count exceeds limit (max %ld)", MAX_REPEAT); FsaAnno r = epsilon_fsa(NULL); REP(i, expr.low) { FsaAnno t = *this; diff --git a/yanshi/src/lexer.l b/yanshi/src/lexer.l index 44464e2c..0958f7ea 100644 --- a/yanshi/src/lexer.l +++ b/yanshi/src/lexer.l @@ -5,6 +5,7 @@ #include "syntax.hh" #include +#include #include #include #include @@ -28,6 +29,20 @@ static long invalid_escape(YYSTYPE* yylval, const char* text) return INVALID_CHARACTER; } +// atol() cannot report an overflow, so a count beyond LONG_MAX silently +// saturated and could drive an unbounded repeat expansion. Convert with +// strtoll() and saturate explicitly; the parser then rejects the value +// through gen_repeat()'s MAX_REPEAT check. +static long parse_integer_token(const char* text) +{ + errno = 0; + char* end = NULL; + long long value = strtoll(text, &end, 10); + if (errno == ERANGE || value < 0 || value > (long long)LONG_MAX) + return LONG_MAX; + return (long)value; +} + static int invalid_escape_octonary(YYSTYPE* yylval, const char* text) { yylval->errmsg = aprintf("invalid number after \\-escape: %s", text); @@ -116,7 +131,7 @@ L [a-zA-Z_\x80-\xff] "semicolon" semicolon = true; "nosemicolon" semicolon = false; {L}({L}|{D})* yylval->str = new string(yytext); return IDENT; -{D}+ yylval->integer = atol(yytext); return INTEGER; +{D}+ yylval->integer = parse_integer_token(yytext); return INTEGER; "#define" return PREPROCESS_DEFINE; "#" yy_push_state(IN_LINE_COMMENT, yyscanner); diff --git a/yanshi/src/parser.y b/yanshi/src/parser.y index b2ea352e..33653a0a 100644 --- a/yanshi/src/parser.y +++ b/yanshi/src/parser.y @@ -94,6 +94,9 @@ int yylex(YYSTYPE* yylval, YYLTYPE* loc, Stmt*& res, long& errors, const Locatio if (low > high) { \ FAIL(yyloc, "low > high"); \ } \ + if (low > MAX_REPEAT || (high != LONG_MAX && high > MAX_REPEAT)) { \ + FAIL(yyloc, "repeat count exceeds limit"); \ + } \ x = new RepeatExpr(inner, low, high) %} diff --git a/yanshi/src/repl.cc b/yanshi/src/repl.cc index 2f9bfd5c..860de7af 100644 --- a/yanshi/src/repl.cc +++ b/yanshi/src/repl.cc @@ -159,7 +159,7 @@ char* readline(const char* prompt) static void run_command(char* line) { size_t p = 1; - while (line[p] && isalnum(line[p])) + while (line[p] && isalnum((unsigned char)line[p])) p++; Command* com = NULL; REP(i, LEN(commands)) @@ -172,10 +172,10 @@ static void run_command(char* line) else if (com == (Command*)1) printf("Ambiguous command '%s'\n", line); else { - while (line[p] && isspace(line[p])) + while (line[p] && isspace((unsigned char)line[p])) p++; size_t len = strlen(line); - while (len && isspace(line[len-1])) + while (len && isspace((unsigned char)line[len-1])) line[--len] = '\0'; com->fn(line+p); } From d060125b124df55697573b5a209df98f1a748274 Mon Sep 17 00:00:00 2001 From: "jialong.wang" Date: Fri, 18 Sep 2026 16:55:10 +0800 Subject: [PATCH 19/31] fix(security): bind the TFA bootstrap to its address and close the throttle bypass Third round of the security review, covering the findings the two previous rounds left open. management/webserver - main.go: SetTrustedProxies(nil). gin trusts every proxy by default, so ClientIP() returned the first value of X-Forwarded-For and a client could choose the address it is throttled by. The login throttle and the TFA bootstrap both key on it. - api/auth.go, model/user.go, cmd/reset_user.go: the pending TFA secret is bound to the address it was handed to. GetOTPUrl only serves it again to that address, and PostLogin refuses a first login from anywhere else, so a caller that reaches the API before the administrator can no longer bind the account to a secret it knows. -reset_user clears the secret too, which is the way back from a bootstrap that was taken over. - api/login_guard.go: prune() never drops an account key. Filling the map with invented client addresses used to be enough to evict account: and reset its backoff, which made a six digit passcode brute forceable again. - api/cert.go: PostSSLCert validates the host name with the server name rule before it is written into the certificate as a DNS name. - middleware/security.go: drop includeSubDomains from the HSTS header. - pkg/fvm/helper.go: save the bytecode 0644 instead of 0666. sdk/kong - handler.lua: a detector that cannot be reached now rejects the request in block mode instead of letting it through. A detector outage used to be a complete bypass of the WAF. Checked with gofmt -e and luac -p. go build could not run in this environment: the chaitin.cn/dev/go modules are only published on the internal goproxy, so both management modules still have to be built in CI. --- management/webserver/api/auth.go | 41 ++++++++++++++++++--- management/webserver/api/cert.go | 16 ++++++++ management/webserver/api/login_guard.go | 25 +++++++++++-- management/webserver/cmd/reset_user.go | 10 +++++ management/webserver/main.go | 9 +++++ management/webserver/middleware/security.go | 7 +++- management/webserver/model/user.go | 7 ++++ management/webserver/pkg/fvm/helper.go | 12 ++++-- sdk/kong/kong/plugins/safeline/handler.lua | 9 ++++- 9 files changed, 120 insertions(+), 16 deletions(-) diff --git a/management/webserver/api/auth.go b/management/webserver/api/auth.go index c4200c93..9e239011 100644 --- a/management/webserver/api/auth.go +++ b/management/webserver/api/auth.go @@ -72,6 +72,18 @@ func PostLogin(c *gin.Context) { return } + // Until the first successful login the account is still bound to the address + // the TFA secret was handed to. Without this check a caller that reached + // GET /api/OTPUrl first would hold a secret the administrator is about to + // bind, and knowing the secret is enough to log in. + if user.LastLoginTime == 0 && user.TFASecret != "" && user.TFABootstrapIP != "" && + user.TFABootstrapIP != c.ClientIP() { + loginThrottle.fail(keys) + logger.Warnf("Refused a login from %s before the first successful one: the pending TFA secret was issued to %s", c.ClientIP(), user.TFABootstrapIP) + response.Error(c, response.JSONBody{Err: response.ErrWrongPasscode, Msg: "Failed to verify your passcode"}, http.StatusUnauthorized) + return + } + usedStep, valid := verifyPasscode(user.TFASecret, params.Passcode, user.TFALastUsedStep, time.Now()) if !valid { loginThrottle.fail(keys) @@ -98,6 +110,9 @@ func PostLogin(c *gin.Context) { user.LastLoginTime = time.Now().Unix() user.IsEnabled = true + // The bootstrap is over: the secret is bound, so it is no longer pending + // and no longer tied to the address that fetched it. + user.TFABootstrapIP = "" // Remember the time step of the accepted passcode so that the same one // cannot be used a second time. user.TFALastUsedStep = usedStep @@ -146,11 +161,26 @@ func GetOTPUrl(c *gin.Context) { // The console displays the QR code on the login page, before any session // exists, so this endpoint is reachable while the account has never logged - // in. It must not hand out a fresh secret on every call: an anonymous - // caller that replaced a secret the administrator had already scanned would - // take over the account, because the passcode is the only credential. + // in. It must not hand out a fresh secret on every call, and the secret it + // does hand out belongs to the one address that asked for it: an anonymous + // caller that fetched a secret the administrator is about to bind would + // otherwise be able to log in with it, because the passcode is the only + // credential. + clientIP := c.ClientIP() if user.TFASecret != "" { - logger.Debugf("Reusing the pending TFA secret for %s", c.ClientIP()) + if user.TFABootstrapIP == "" { + // An installation that already carries a pending secret from a + // release that did not record the address keeps working: the first + // caller adopts it, exactly as it did before. + user.TFABootstrapIP = clientIP + db.Save(&user) + } else if user.TFABootstrapIP != clientIP { + logger.Warnf("Refused to hand out the pending TFA secret to %s: it was issued to %s", clientIP, user.TFABootstrapIP) + response.Error(c, response.JSONBody{Err: response.ErrLoginRequired, Msg: "The pending TFA secret belongs to another address, run 'mgt -reset_user ' to bind a new one"}, http.StatusForbidden) + return + } + + logger.Debugf("Reusing the pending TFA secret for %s", clientIP) response.Success(c, gin.H{"url": otpURL(user.TFASecret)}) return } @@ -163,8 +193,9 @@ func GetOTPUrl(c *gin.Context) { } user.TFASecret = otpKey.Secret() + user.TFABootstrapIP = clientIP db.Save(&user) - logger.Warnf("Issued a new TFA secret to %s; the administrator has to bind it before the first login", c.ClientIP()) + logger.Warnf("Issued a new TFA secret to %s; it can only be bound and used from that address until the first login", clientIP) response.Success(c, gin.H{"url": otpKey.URL()}) } diff --git a/management/webserver/api/cert.go b/management/webserver/api/cert.go index 7703010b..40c532f3 100644 --- a/management/webserver/api/cert.go +++ b/management/webserver/api/cert.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "chaitin.cn/patronus/safeline-2/management/webserver/api/response" + "chaitin.cn/patronus/safeline-2/management/webserver/model" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/config" "chaitin.cn/patronus/safeline-2/management/webserver/utils" ) @@ -107,6 +108,21 @@ func PostSSLCert(c *gin.Context) { return } + // The host name is written into the certificate as a DNS name, and a value + // that is not a host name produces a certificate that stands for nothing in + // particular. It is checked with the same rule the front end uses for a + // server name before it reaches the certificate. + if params.Hostname == "" { + response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "The host name of the certificate is required"}, http.StatusBadRequest) + return + } + + if err := model.ValidateServerName(params.Hostname); err != nil { + logger.Warn(err) + response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusBadRequest) + return + } + filePrefix := utils.RandStr(16) certFilename := fmt.Sprintf("%s_backend.crt", filePrefix) keyFilename := fmt.Sprintf("%s_backend.key", filePrefix) diff --git a/management/webserver/api/login_guard.go b/management/webserver/api/login_guard.go index 1d6e40f6..ac4cbc17 100644 --- a/management/webserver/api/login_guard.go +++ b/management/webserver/api/login_guard.go @@ -2,6 +2,7 @@ package api import ( "fmt" + "strings" "sync" "time" ) @@ -50,9 +51,19 @@ func newLoginGuard() *loginGuard { } } +// accountKeyPrefix marks the throttle key that every caller of an account +// shares, as opposed to the keys that belong to a single client address. +const accountKeyPrefix = "account:" + +// isAccountKey reports whether a throttle key protects an account rather than a +// single client address. +func isAccountKey(key string) bool { + return strings.HasPrefix(key, accountKeyPrefix) +} + // loginKeys returns the throttle keys of a login attempt. func loginKeys(clientIP, username string) []string { - keys := []string{fmt.Sprintf("account:%s", username)} + keys := []string{accountKeyPrefix + username} if clientIP != "" { keys = append(keys, fmt.Sprintf("ip:%s", clientIP)) } @@ -125,13 +136,19 @@ func (g *loginGuard) prune(now time.Time) { } } - // Dropping keys that are still active only weakens the throttle of callers - // that stopped trying; keeping the map bounded matters more, because a - // client can invent client addresses faster than the failures expire. + // The map has to stay bounded, because a client can invent client addresses + // faster than the failures expire. Dropping an address key only weakens the + // throttle of a caller that stopped trying, but an account key is never + // dropped: it is the one that keeps the passcode out of reach, and being + // able to reset it by filling the map with invented addresses would make + // the whole throttle useless. for key := range g.attempts { if len(g.attempts) <= loginMaxEntries { break } + if isAccountKey(key) { + continue + } delete(g.attempts, key) } } diff --git a/management/webserver/cmd/reset_user.go b/management/webserver/cmd/reset_user.go index c879f0da..cad81f1e 100644 --- a/management/webserver/cmd/reset_user.go +++ b/management/webserver/cmd/reset_user.go @@ -5,10 +5,20 @@ import ( "chaitin.cn/patronus/safeline-2/management/webserver/pkg/database" ) +// ResetUser clears the credentials of a user so that the next visit of the +// console binds a new TFA secret. +// +// The pending secret and the address it was issued to are cleared together: +// a secret that was handed to somebody else cannot be bound by the +// administrator, and starting the bootstrap over from the shell is the only +// way out of that state. func ResetUser(username string) { db := database.GetDB() var user model.User db.Where(&model.User{Username: username}).First(&user) user.LastLoginTime = 0 + user.TFASecret = "" + user.TFABootstrapIP = "" + user.TFALastUsedStep = 0 db.Save(&user) } diff --git a/management/webserver/main.go b/management/webserver/main.go index fb3ad39b..0e621a58 100644 --- a/management/webserver/main.go +++ b/management/webserver/main.go @@ -136,6 +136,15 @@ func main() { gin.SetMode(gin.ReleaseMode) r := gin.Default() + // gin.ClientIP() returns the first value of X-Forwarded-For as soon as the + // direct peer belongs to the trusted set, and the default trusted set is + // every address. The management port is published on the host and reached + // directly, so a client could otherwise pick the address it is throttled by + // - the login throttle and the TFA bootstrap both key on it. No proxy is + // trusted unless a deployment says so. + if err := r.SetTrustedProxies(nil); err != nil { + logger.Fatalln("Failed to disable trusted proxies: ", err) + } r.Use(middleware.SecurityHeaders) var option model.Options diff --git a/management/webserver/middleware/security.go b/management/webserver/middleware/security.go index c8545a66..9b5af017 100644 --- a/management/webserver/middleware/security.go +++ b/management/webserver/middleware/security.go @@ -22,8 +22,11 @@ func SecurityHeaders(c *gin.Context) { c.Header("Referrer-Policy", "no-referrer") // The console is reached over TLS; a browser that saw this header once will - // refuse to fall back to plain HTTP for the following year. - c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + // refuse to fall back to plain HTTP for the following year. includeSubDomains + // is deliberately left out: the console shares its host with whatever else + // the deployment serves from it, and pinning every subdomain of that name is + // not this service's decision to make. + c.Header("Strict-Transport-Security", "max-age=31536000") c.Next() } diff --git a/management/webserver/model/user.go b/management/webserver/model/user.go index 027f7fca..dc1dd57e 100644 --- a/management/webserver/model/user.go +++ b/management/webserver/model/user.go @@ -23,6 +23,13 @@ type User struct { // while it is still inside the validity window. TFALastUsedStep int64 `gorm:"column:tfa_last_used_step;default:0"` + // TFABootstrapIP is the client address the pending TFA secret was handed to + // before the account ever logged in. The secret is only served again to + // that address, and the first login has to come from it, so a caller that + // reaches the API before the administrator cannot bind the account to a + // secret it knows. + TFABootstrapIP string `gorm:"column:tfa_bootstrap_ip"` + IsEnabled bool `gorm:"default:true"` } diff --git a/management/webserver/pkg/fvm/helper.go b/management/webserver/pkg/fvm/helper.go index 9aba93bc..187c6ab5 100644 --- a/management/webserver/pkg/fvm/helper.go +++ b/management/webserver/pkg/fvm/helper.go @@ -26,8 +26,10 @@ func CompileAndSave(text string) error { update := Serialize(output, false, 0, 1) defer update.Release() - // save to file - if err = utils.EnsureWriteFile(config.GlobalConfig.Detector.FslBytecode, update.ToBytes(), 0666); err != nil { + // save to file. The bytecode is not secret, but nothing but the management + // process has a reason to write it, so it is not created group or world + // writable either. + if err = utils.EnsureWriteFile(config.GlobalConfig.Detector.FslBytecode, update.ToBytes(), 0644); err != nil { return errors.Annotate(err, "failed to save bytecode") } @@ -44,8 +46,10 @@ func CompileAndPush(text, serverAddr string) error { update := Serialize(output, false, 0, 1) defer update.Release() - // save to file - if err = utils.EnsureWriteFile(config.GlobalConfig.Detector.FslBytecode, update.ToBytes(), 0666); err != nil { + // save to file. The bytecode is not secret, but nothing but the management + // process has a reason to write it, so it is not created group or world + // writable either. + if err = utils.EnsureWriteFile(config.GlobalConfig.Detector.FslBytecode, update.ToBytes(), 0644); err != nil { return errors.Annotate(err, "failed to save bytecode") } diff --git a/sdk/kong/kong/plugins/safeline/handler.lua b/sdk/kong/kong/plugins/safeline/handler.lua index 06da7bf0..af6fcda6 100644 --- a/sdk/kong/kong/plugins/safeline/handler.lua +++ b/sdk/kong/kong/plugins/safeline/handler.lua @@ -28,11 +28,18 @@ local function get_conf(conf) end function SafelineHandler:access(conf) - -- your custom code here local t = get_conf(conf) local ok, err, result = t1k.do_access(t, false) if not ok then + -- The detector could not be asked, so nothing is known about this + -- request. Letting it through in block mode would turn a detector + -- outage into a complete bypass of the WAF, which is the one outcome an + -- inline protection must not have. monitor mode keeps passing traffic. kong.log.err("failed to detector req: ", err) + if conf.mode == t1k_constants.MODE_BLOCK then + return kong.response.exit(500, fmt(blocked_message, 500, "")) + end + return end if result and result.status then if result.action == t1k_constants.ACTION_BLOCKED then From 45ac4247693a3e6a5923642317a4238f37fe2847 Mon Sep 17 00:00:00 2001 From: "jialong.wang" Date: Fri, 18 Sep 2026 17:51:10 +0800 Subject: [PATCH 20/31] fix(security): stop the ingress SDK failing open and close the resource leaks A third pass over the findings that stayed open on main. Each one was checked against the code before it was touched, and the ones that did not hold up were dropped (the /var/run mount of the management container is the bridge socket directory mgt and the engine share, not an unused privilege). sdk/ingress-nginx - lib/safeline/main.lua: rewrite() answered nothing when the detector could not be asked, and also when the plugin had no host at all, so a detector outage (or a missing configuration) turned into a complete bypass of the WAF while the plugin ran in block mode. It now answers 500 in block mode; monitor mode keeps passing traffic and off mode never reaches that point. The blocked branch answers with the status the detector reported instead of ending the request with ngx.exit(ngx.HTTP_OK), a status the detector does not control falls back to 403, and both answers carry the JSON content type. management/webserver - api/common.go: the upgrade tips endpoint is unauthenticated, and it neither closed its response body nor bounded what it read from the platform. - api/detectlog.go, model/option.go: DoPostTelemetry hands the response to its caller, and neither caller released it. - pkg/cron/update_policy.go: the detector status response was closed only on the branch that continued to the update, so every early return leaked it. - api/common.go: page_size is capped at 100, the largest page the console offers and the range the MCP tools already accept, instead of being passed to SQL LIMIT unchecked. - middleware/bodylimit.go, main.go: this server terminates TLS itself and its port is published on the host, so the request body is now bounded instead of being read as far as a client cares to send it. - model/website.go: an upstream is validated before it is stored - http/https scheme, host name or IP address, port in range - because its scheme and host end up in the "proxy_pass" and "server" directives of the site. - utils/httpclient.go: the shared client is built with sync.Once; the API handlers and the cron jobs raced on the lazy assignment. management/tcontrollerd - controller/validation.go, controller/website.go: the upstream check runs on the side that renders the value, so a site configuration arriving over the network cannot carry nginx syntax into the generated file either. --- .../tcontrollerd/controller/validation.go | 49 +++++++++++++++ management/tcontrollerd/controller/website.go | 5 ++ management/webserver/api/common.go | 19 +++++- management/webserver/api/detectlog.go | 3 + management/webserver/main.go | 3 + management/webserver/middleware/bodylimit.go | 31 +++++++++ management/webserver/model/option.go | 3 + management/webserver/model/website.go | 63 +++++++++++++++++++ .../webserver/pkg/cron/update_policy.go | 21 ++++--- management/webserver/utils/httpclient.go | 15 ++++- sdk/ingress-nginx/lib/safeline/main.lua | 45 ++++++++++--- 11 files changed, 235 insertions(+), 22 deletions(-) create mode 100644 management/webserver/middleware/bodylimit.go diff --git a/management/tcontrollerd/controller/validation.go b/management/tcontrollerd/controller/validation.go index 561c07b3..83bdf1ce 100644 --- a/management/tcontrollerd/controller/validation.go +++ b/management/tcontrollerd/controller/validation.go @@ -2,6 +2,7 @@ package controller import ( "fmt" + "net/url" "path/filepath" "regexp" "strconv" @@ -33,6 +34,16 @@ var ( // without a path separator, a control character or any nginx syntax may // pass. certFilenamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + + // upstreamSchemePattern matches the two schemes an upstream may use. The + // scheme is rendered into "proxy_pass", so anything else is rejected. + upstreamSchemePattern = regexp.MustCompile(`^https?$`) + + // upstreamHostPattern matches the host of an upstream: a host name, an IPv4 + // address or a bracketed IPv6 address, with an optional port. The host is + // rendered into the "server" directive of the site, so a value carrying + // nginx syntax (a space, a semicolon, a "$", a quote) must not pass. + upstreamHostPattern = regexp.MustCompile(`^(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?\.?)(?::([0-9]{1,5}))?$`) ) // maxCertFilenameLength bounds a certificate file name. @@ -79,6 +90,44 @@ func validatePort(port string) error { return nil } +// validateUpstream accepts the address of a site upstream. +// +// The management server validates the same value before it stores it, but this +// side cannot rely on that: the site configuration is pushed over the network, +// and the scheme and the host of an upstream are rendered into the "proxy_pass" +// and "server" directives of the site. +func validateUpstream(upstream string) error { + parsed, err := url.Parse(upstream) + if err != nil { + return fmt.Errorf("invalid upstream %q: %v", upstream, err) + } + + if !upstreamSchemePattern.MatchString(parsed.Scheme) { + return fmt.Errorf("invalid upstream %q: the scheme has to be http or https", upstream) + } + + host := parsed.Host + if parsed.Scheme == HttpsScheme && parsed.Port() == "" { + // generateNginxConfig appends the default HTTPS port to this value + // before it renders it, so validate the value it renders. + host = host + ":" + DefaultHttpsPort + } + + matches := upstreamHostPattern.FindStringSubmatch(host) + if matches == nil { + return fmt.Errorf("invalid upstream %q: %q is not a host name, an IP address or a host with a port", upstream, host) + } + + if port := matches[1]; port != "" { + value, err := strconv.Atoi(port) + if err != nil || value < 1 || value > maxPort { + return fmt.Errorf("invalid upstream %q: port has to be between 1 and %d", upstream, maxPort) + } + } + + return nil +} + // validateServerName accepts a literal host name, optionally with a wildcard // as its first label. It rejects any other value because the name is // interpolated into a "server_name" directive. diff --git a/management/tcontrollerd/controller/website.go b/management/tcontrollerd/controller/website.go index 41d01283..8eaf52ac 100644 --- a/management/tcontrollerd/controller/website.go +++ b/management/tcontrollerd/controller/website.go @@ -52,6 +52,11 @@ func generateNginxConfig(website *model.WebsiteConfig) (string, error) { if urlInfo.Scheme == HttpsScheme && urlInfo.Port() == "" { urlInfo.Host = urlInfo.Host + ":" + DefaultHttpsPort } + // The host and the scheme are rendered into "server" and "proxy_pass", + // so a value carrying nginx syntax has to be rejected here. + if err := validateUpstream(upstream); err != nil { + return "", err + } upstreamAddr = fmt.Sprintf(upstreamAddrTpl, urlInfo.Host) if len(urlInfo.Scheme) > 0 { scheme = urlInfo.Scheme diff --git a/management/webserver/api/common.go b/management/webserver/api/common.go index 426b8263..3aac1c47 100644 --- a/management/webserver/api/common.go +++ b/management/webserver/api/common.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "fmt" + "io" "io/ioutil" "net/http" "strings" @@ -18,13 +19,22 @@ import ( const VersionInfoEntrypoint = "/release/latest/version.json" +// maxVersionInfoBytes bounds the upgrade information this server reads from +// the platform. The body is parsed and logged on error, so a large or endless +// answer from a misbehaving (or hostile) endpoint must not be buffered whole. +const maxVersionInfoBytes = 1 << 20 + type idsRequest struct { IDs []uint `json:"ids" form:"ids"` } type pageRequest struct { + // PageSize is capped at 100: the value is handed to SQL LIMIT, so an + // unbounded one lets a single request ask the server to load an arbitrary + // number of rows. 100 is the largest page the console offers and the same + // range the MCP tools accept. Page int `json:"page" form:"page,default=1" binding:"min=1"` - PageSize int `json:"page_size" form:"page_size,default=10" binding:"min=1"` + PageSize int `json:"page_size" form:"page_size,default=10" binding:"min=1,max=100"` } type versionInfoResponse struct { @@ -52,7 +62,12 @@ func GetUpgradeTips(ctx *gin.Context) { response.Success(ctx, gin.H{"upgrade_tips": constants.NotUpgrade}) return } - body, err := ioutil.ReadAll(versionInfoRsp.Body) + // The response body has to be released on every path, otherwise the + // connection (and its file descriptor) stays around until the transport + // times it out. + defer versionInfoRsp.Body.Close() + + body, err := ioutil.ReadAll(io.LimitReader(versionInfoRsp.Body, maxVersionInfoBytes)) if err != nil { logger.Warn(err) response.Success(ctx, gin.H{"upgrade_tips": constants.NotUpgrade}) diff --git a/management/webserver/api/detectlog.go b/management/webserver/api/detectlog.go index 42666b21..333eae43 100644 --- a/management/webserver/api/detectlog.go +++ b/management/webserver/api/detectlog.go @@ -170,6 +170,9 @@ func PostFalsePositives(c *gin.Context) { response.Success(c, nil) return } + // The caller owns the response returned by DoPostTelemetry and has to + // release it, otherwise every report leaks a connection. + defer rsp.Body.Close() if rsp.StatusCode != http.StatusOK && rsp.StatusCode != http.StatusCreated { log.Warn(fmt.Sprintf("Transfer telemetry failed, status code = %d", rsp.StatusCode), err) diff --git a/management/webserver/main.go b/management/webserver/main.go index 0e621a58..ce1fcb82 100644 --- a/management/webserver/main.go +++ b/management/webserver/main.go @@ -146,6 +146,9 @@ func main() { logger.Fatalln("Failed to disable trusted proxies: ", err) } r.Use(middleware.SecurityHeaders) + // Nothing in front of this server caps the request body, so it is bounded + // here (see middleware.BodyLimit). + r.Use(middleware.BodyLimit(middleware.MaxRequestBodyBytes)) var option model.Options database.GetDB().Where(&model.Options{Key: constants.SecretKey}).First(&option) diff --git a/management/webserver/middleware/bodylimit.go b/management/webserver/middleware/bodylimit.go new file mode 100644 index 00000000..1e592201 --- /dev/null +++ b/management/webserver/middleware/bodylimit.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// MaxRequestBodyBytes bounds the size of a request body the management API +// accepts. +// +// This server terminates TLS itself and its port is published on the host, so +// no proxy caps the body in front of it: without a limit a client can make it +// buffer an arbitrary amount of data. The largest body the API takes is an SSL +// certificate upload, which is a few tens of kilobytes, so the limit leaves a +// wide margin while still bounding what a single request may consume. +const MaxRequestBodyBytes = 16 << 20 // 16 MiB + +// BodyLimit caps the request body at maxBytes. +// +// The body is replaced by a reader that reports an error once the limit is +// exceeded, so a handler that parses the body fails with a parameter error +// instead of reading it all. Requests without a body are not affected. +func BodyLimit(maxBytes int64) gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.Body != nil { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes) + } + c.Next() + } +} diff --git a/management/webserver/model/option.go b/management/webserver/model/option.go index 6c838696..7dcd4f8d 100644 --- a/management/webserver/model/option.go +++ b/management/webserver/model/option.go @@ -93,6 +93,9 @@ func NotifyInstallation(machineId string) { logger.Error(err) return } + // The caller owns the response returned by DoPostTelemetry and has to + // release it, otherwise every report leaks a connection. + defer rsp.Body.Close() if rsp.StatusCode != http.StatusOK && rsp.StatusCode != http.StatusCreated { logger.Errorf("transfer telemetry %s failed, status code = %d", constants.Installation, rsp.StatusCode) diff --git a/management/webserver/model/website.go b/management/webserver/model/website.go index 4e50bf55..af530e7d 100644 --- a/management/webserver/model/website.go +++ b/management/webserver/model/website.go @@ -3,6 +3,7 @@ package model import ( "encoding/json" "fmt" + "net/url" "path/filepath" "regexp" "strconv" @@ -18,6 +19,10 @@ const ( // maxServerNameLength and maxLabelLength follow the DNS limits. maxServerNameLength = 253 maxLabelLength = 63 + + // defaultHttpsPort is the port tcontrollerd appends to an upstream that + // names https without a port. + defaultHttpsPort = "443" ) var ( @@ -36,6 +41,16 @@ var ( // only a plain file name without a path separator, a control character or // any nginx syntax may pass. certFilenamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + + // upstreamSchemePattern matches the two schemes an upstream may use. The + // scheme is interpolated into "proxy_pass", so anything else is rejected. + upstreamSchemePattern = regexp.MustCompile(`^https?$`) + + // upstreamHostPattern matches the host of an upstream: a host name, an IPv4 + // address or a bracketed IPv6 address, with an optional port. The host is + // interpolated into the "server" directive of the site, so a value carrying + // nginx syntax (a space, a semicolon, a "$", a quote) must not pass. + upstreamHostPattern = regexp.MustCompile(`^(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?\.?)(?::([0-9]{1,5}))?$`) ) type Website struct { @@ -80,6 +95,17 @@ func (w *Website) Validate() error { } } + upstreams, err := unmarshalStringList(w.Upstreams) + if err != nil { + return fmt.Errorf("invalid upstreams: %v", err) + } + + for _, upstream := range upstreams { + if err = ValidateUpstream(upstream); err != nil { + return err + } + } + if err = ValidateCertFilename(w.CertFilename); err != nil { return err } @@ -91,6 +117,43 @@ func (w *Website) Validate() error { return nil } +// ValidateUpstream accepts the address of a site upstream. +// +// The scheme and the host of this value are interpolated into the "proxy_pass" +// and "server" directives of the site, so a value carrying nginx syntax must be +// rejected here, before it is stored and pushed to the engine. +func ValidateUpstream(upstream string) error { + parsed, err := url.Parse(upstream) + if err != nil { + return fmt.Errorf("invalid upstream %q: %v", upstream, err) + } + + if !upstreamSchemePattern.MatchString(parsed.Scheme) { + return fmt.Errorf("invalid upstream %q: the scheme has to be http or https", upstream) + } + + host := parsed.Host + if parsed.Scheme == "https" && parsed.Port() == "" { + // tcontrollerd appends the default HTTPS port to this value before it + // renders it, so validate the value it renders. + host = host + ":" + defaultHttpsPort + } + + matches := upstreamHostPattern.FindStringSubmatch(host) + if matches == nil { + return fmt.Errorf("invalid upstream %q: %q is not a host name, an IP address or a host with a port", upstream, host) + } + + if port := matches[1]; port != "" { + value, err := strconv.Atoi(port) + if err != nil || value < 1 || value > maxPort { + return fmt.Errorf("invalid upstream %q: port has to be between 1 and %d", upstream, maxPort) + } + } + + return nil +} + // maxCertFilenameLength bounds a certificate file name. const maxCertFilenameLength = 255 diff --git a/management/webserver/pkg/cron/update_policy.go b/management/webserver/pkg/cron/update_policy.go index ab5b6f86..7de3c447 100644 --- a/management/webserver/pkg/cron/update_policy.go +++ b/management/webserver/pkg/cron/update_policy.go @@ -71,10 +71,19 @@ func CheckAndUpdatePolicy() { statRspData := statResponseBody{} statRsp, err := client.Do(statReq) - if err != nil || statRsp.StatusCode != http.StatusOK { + if err != nil { logger.Warn(err) return } + // The version check below returns early on every branch that is not "an + // update is needed", so the body has to be released on the way out instead + // of only on the path that continues. + defer statRsp.Body.Close() + + if statRsp.StatusCode != http.StatusOK { + logger.Warnf("%s get policy version, return %d", addr, statRsp.StatusCode) + return + } body, err := ioutil.ReadAll(statRsp.Body) if err != nil { @@ -92,23 +101,15 @@ func CheckAndUpdatePolicy() { return } - err = statRsp.Body.Close() - if err != nil { - logger.Warn(err) - } - logger.Info("Update fsl bytecode") updateRsp, err := client.Do(updateReq) if err != nil { logger.Warn(err) return } + defer updateRsp.Body.Close() if updateRsp.StatusCode != http.StatusOK { logger.Warnf("%s update policy, return %d", addr, updateRsp.StatusCode) } - err = updateRsp.Body.Close() - if err != nil { - logger.Warn(err) - } } diff --git a/management/webserver/utils/httpclient.go b/management/webserver/utils/httpclient.go index f178f65c..a8dc1f0c 100644 --- a/management/webserver/utils/httpclient.go +++ b/management/webserver/utils/httpclient.go @@ -4,15 +4,24 @@ import ( "net/http" "net/url" "os" + "sync" "time" ) const proxyName = "HTTPS_PROXY" -var httpClient *http.Client +var ( + httpClientOnce sync.Once + httpClient *http.Client +) +// GetHTTPClient returns the shared HTTP client. +// +// The client is built once: the API handlers and the cron jobs call this at the +// same time, and the unsynchronized lazy assignment used before let two of them +// build (and see) a client at the same time. func GetHTTPClient() *http.Client { - if httpClient == nil { + httpClientOnce.Do(func() { // Certificates are verified: this client talks to the telemetry // endpoint and to the upgrade server, both of which are remote and can // be spoofed by a man in the middle when verification is disabled. @@ -28,7 +37,7 @@ func GetHTTPClient() *http.Client { } httpClient = &http.Client{Transport: tr} - } + }) return httpClient } diff --git a/sdk/ingress-nginx/lib/safeline/main.lua b/sdk/ingress-nginx/lib/safeline/main.lua index b4741813..db895178 100644 --- a/sdk/ingress-nginx/lib/safeline/main.lua +++ b/sdk/ingress-nginx/lib/safeline/main.lua @@ -3,6 +3,7 @@ local t1k_constants = require "resty.t1k.constants" local ngx = ngx local fmt = string.format +local lower = string.lower local blocked_message = [[{"code": %s, "success":false, ]] .. [["message": "blocked by Chaitin SafeLine Web Application Firewall", "event_id": "%s"}]] @@ -36,26 +37,56 @@ local function get_conf() return t end +local function is_block_mode(t) + -- resty.t1k lower cases the mode before comparing it, so do the same here: + -- a mode of "BLOCK" has to fail closed exactly like "block". + return type(t.mode) == "string" and lower(t.mode) == t1k_constants.MODE_BLOCK +end + +-- fail_closed answers a request that could not be checked. +-- +-- The detector was not asked (or the plugin is not configured at all), so +-- nothing is known about this request. Answering it while the plugin runs in +-- block mode would turn a detector outage into a complete bypass of the WAF, +-- which is the one outcome an inline protection must not have. monitor mode +-- keeps passing traffic, and off mode never reaches this point. +local function fail_closed(t) + if not is_block_mode(t) then + return + end + + local status = ngx.HTTP_INTERNAL_SERVER_ERROR + local msg = fmt(blocked_message, status, "") + ngx.status = status + ngx.header.content_type = t1k_constants.BLOCK_CONTENT_TYPE + ngx.say(msg) + return ngx.exit(status) +end + function _M.rewrite() local t = get_conf() if not t.host then ngx.log(ngx.ERR, "safeline host is required") - return + -- resty.t1k refuses to run without a host, so this is the same + -- "cannot be checked" situation as a detector that does not answer. + return fail_closed(t) end local ok, err, result = t1k.do_access(t, false) if not ok then ngx.log(ngx.ERR, "failed to detector req: ", err) - return + return fail_closed(t) end if result then if result.action == t1k_constants.ACTION_BLOCKED then - local msg = fmt(blocked_message, result.status, result.event_id) - ngx.log(ngx.ERR, "blocked by safeline waf: ",msg) - ngx.status = tonumber(result.status,10) + local status = tonumber(result.status, 10) or ngx.HTTP_FORBIDDEN + local msg = fmt(blocked_message, status, result.event_id) + ngx.log(ngx.ERR, "blocked by safeline waf: ", msg) + ngx.status = status + ngx.header.content_type = t1k_constants.BLOCK_CONTENT_TYPE ngx.say(msg) - return ngx.exit(ngx.HTTP_OK) + return ngx.exit(status) end end end -return _M \ No newline at end of file +return _M From 849e20b968b23e1a482366132a93211ab110c14d Mon Sep 17 00:00:00 2001 From: Londo <109172537+Londopy@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:20:41 -0700 Subject: [PATCH 21/31] Fix heading typo in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2100500d..1fa4f1dd 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Information on how to install SafeLine can be found in the [Install Guide](https to see [Configuration](https://docs.waf.chaitin.com/en/GetStarted/AddApplication) -## 📋 More Informations +## 📋 More Information #### Effect Evaluation From f284647ab5837d30f36dd9b0b5b4b5331d52638d Mon Sep 17 00:00:00 2001 From: "jialong.wang" Date: Sun, 20 Sep 2026 10:29:22 +0800 Subject: [PATCH 22/31] fix(security): raise the vulnerable dependencies and harden the yanshi backtrace Fourth pass over main. This one is a dependency and binary level pass: govulncheck 1.1.4 was run against all three Go modules of the tree with the real package set, and every finding it reported was checked against its call sites before a version was touched. The ones that did not hold up were dropped - the logrus advisory describes Entry.Writer(), which no file of this repository calls, and the x/crypto advisories sit behind ssh and openpgp, of which no module of this tree imports a package. management/webserver, management/tcontrollerd - go.mod: the reachable findings are cleared by raising the modules they live in. google.golang.org/grpc 1.65.0 to 1.83.2 (GO-2026-6443, GO-2026-6348, GO-2026-6061, GO-2026-4762), github.com/jackc/pgx/v5 5.5.5 to 5.11.0 (GO-2026-5004, reached from DeleteWebsite through gorm.Transaction), golang.org/x/net to 0.58.0 (GO-2026-5026, GO-2026-4918, GO-2025-3595), golang.org/x/text to 0.41.0 (GO-2026-5970) and golang.org/x/sys to 0.47.0. The go and toolchain directives move to 1.25.14 together with them, which is the release the standard library findings are fixed in; mcp_server/Dockerfile already builds on golang:1.25.14-alpine3.23, so the rest of the tree is on that toolchain too and no build image has to change. - management/webserver/go.mod: github.com/sirupsen/logrus 1.4.2 to 1.9.3 (GO-2025-4188). Nothing here calls the function the advisory is about, but the same module is a declared dependency of tcontrollerd at a fixed version, and leaving the two management modules on different logrus revisions is a line that comes back in every scan. - management/tcontrollerd/go.mod: gopkg.in/yaml.v2 2.2.7 to 2.4.0 (GO-2020-0036, reached from settings.New through yaml.Decoder.Decode). - go.sum: the entries the raised modules need, keeping every line the previous revision had. mcp_server - go.mod: golang.org/x/text 0.14.0 to 0.41.0 (GO-2026-5970). It was only carried indirectly and nothing calls the package the advisory is about, but the two modules on either side of it are on that revision already. yanshi/src/common.cc - err_exit() formatted the addr2line command line with sprintf and ran it with system(). program_invocation_name is argv[0], which whoever spawns this binary chooses, so a name carrying shell syntax ran a command of its own - the backtrace of a crash was a way to run something else. The addresses now go through an argument vector and execvp(), which never involves a shell. - the same function asked backtrace() for LEN(buf) - 1024 - entries while the array it wrote into held 99, so a frame list longer than that wrote past the end of a stack buffer. backtrace() is now told the size of the array it is given. Checked with go build, go vet, go test and govulncheck 1.1.4 under the go1.25.14 toolchain: all four pass on all three modules, and govulncheck reports no reachable vulnerability in any of them. mcp_server is clean at the module level too; the only alert left anywhere is three golang.org/x/crypto advisories in the webserver graph, which no imported package calls, two of which are fixed in v0.56.0 and the openpgp one in none. The yanshi change is checked with c++ -std=c++1y and AddressSanitizer on macOS. The argv[0] injection is reproduced end to end - the old code creates the file its payload names and the new code does not - and the overflow is reproduced against the function itself under ASan, with the array size this commit uses reporting nothing. The Linux/glibc behaviour of the same path could not be exercised here because Docker is not running. --- management/tcontrollerd/go.mod | 13 +++---- management/tcontrollerd/go.sum | 24 ++++++++++--- management/webserver/go.mod | 14 ++++---- management/webserver/go.sum | 54 ++++++++++++++++++++++------ mcp_server/go.mod | 2 +- mcp_server/go.sum | 2 ++ yanshi/src/common.cc | 64 +++++++++++++++++++++++++++++----- 7 files changed, 136 insertions(+), 37 deletions(-) diff --git a/management/tcontrollerd/go.mod b/management/tcontrollerd/go.mod index aba1d943..cc1a1723 100644 --- a/management/tcontrollerd/go.mod +++ b/management/tcontrollerd/go.mod @@ -1,8 +1,8 @@ module chaitin.cn/patronus/safeline-2/management/tcontrollerd -go 1.21 +go 1.25.0 -toolchain go1.21.3 +toolchain go1.25.14 require ( chaitin.cn/dev/go/errors v0.0.0-20210324055134-dc5247602af6 @@ -10,11 +10,12 @@ require ( chaitin.cn/dev/go/settings v0.0.0-20221220104336-05125760b10c github.com/robfig/cron/v3 v3.0.1 github.com/sirupsen/logrus v1.9.3 - google.golang.org/grpc v1.65.0 + google.golang.org/grpc v1.83.2 ) require ( - golang.org/x/net v0.33.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/management/tcontrollerd/go.sum b/management/tcontrollerd/go.sum index fe02fe0e..d23388d5 100644 --- a/management/tcontrollerd/go.sum +++ b/management/tcontrollerd/go.sum @@ -1,3 +1,4 @@ +chaitin.cn/dev/go/errors v0.0.0-20200717101723-df6132d53dc8 h1:qljuZkcItfSnd1OvteZKgxlqZzcr48DC9qm+OTnt/Xo= chaitin.cn/dev/go/errors v0.0.0-20200717101723-df6132d53dc8/go.mod h1:u+ZD0shdyUt0UG9XfCgD1R5mSqXpTVoO5rwQXQeo3Eo= chaitin.cn/dev/go/errors v0.0.0-20210324055134-dc5247602af6 h1:1Qa9ABk907/9ZrOLbbRcS8Fqq9VhjAF/mLjbSP1qAJY= chaitin.cn/dev/go/errors v0.0.0-20210324055134-dc5247602af6/go.mod h1:u+ZD0shdyUt0UG9XfCgD1R5mSqXpTVoO5rwQXQeo3Eo= @@ -21,8 +22,8 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= @@ -49,6 +50,7 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -66,9 +68,11 @@ golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -77,13 +81,17 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= @@ -96,6 +104,8 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 h1:+kGHl1aib/qcwaRi1CbqBZ1rk19r85MNUf8HaBghugY= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= @@ -103,6 +113,8 @@ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8 google.golang.org/grpc v1.39.0-dev h1:K4VkkiYp4LCvQiW6OiGglzm5nO4Zyryf7pHhzP15cmI= google.golang.org/grpc v1.39.0-dev/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -117,11 +129,15 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/management/webserver/go.mod b/management/webserver/go.mod index db79f843..914f6027 100644 --- a/management/webserver/go.mod +++ b/management/webserver/go.mod @@ -1,8 +1,8 @@ module chaitin.cn/patronus/safeline-2/management/webserver -go 1.21 +go 1.25.0 -toolchain go1.21.3 +toolchain go1.25.14 require ( chaitin.cn/dev/go/errors v0.0.0-20210324055134-dc5247602af6 @@ -13,8 +13,8 @@ require ( github.com/pquerna/otp v1.4.0 github.com/robfig/cron/v3 v3.0.1 github.com/rogpeppe/go-internal v1.10.0 - github.com/sirupsen/logrus v1.4.2 - google.golang.org/grpc v1.65.0 + github.com/sirupsen/logrus v1.9.3 + google.golang.org/grpc v1.83.2 gorm.io/datatypes v1.1.1 gorm.io/driver/postgres v1.5.9 gorm.io/gorm v1.25.10 @@ -36,7 +36,7 @@ require ( github.com/gorilla/sessions v1.2.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/pgx/v5 v5.11.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -50,8 +50,8 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/management/webserver/go.sum b/management/webserver/go.sum index c1e3978c..a38752aa 100644 --- a/management/webserver/go.sum +++ b/management/webserver/go.sum @@ -1,3 +1,4 @@ +chaitin.cn/dev/go/errors v0.0.0-20200717101723-df6132d53dc8 h1:qljuZkcItfSnd1OvteZKgxlqZzcr48DC9qm+OTnt/Xo= chaitin.cn/dev/go/errors v0.0.0-20200717101723-df6132d53dc8/go.mod h1:u+ZD0shdyUt0UG9XfCgD1R5mSqXpTVoO5rwQXQeo3Eo= chaitin.cn/dev/go/errors v0.0.0-20210324055134-dc5247602af6 h1:1Qa9ABk907/9ZrOLbbRcS8Fqq9VhjAF/mLjbSP1qAJY= chaitin.cn/dev/go/errors v0.0.0-20210324055134-dc5247602af6/go.mod h1:u+ZD0shdyUt0UG9XfCgD1R5mSqXpTVoO5rwQXQeo3Eo= @@ -7,10 +8,10 @@ chaitin.cn/dev/go/settings v0.0.0-20221220104336-05125760b10c h1:tXsraF7o9iUsQY6 chaitin.cn/dev/go/settings v0.0.0-20221220104336-05125760b10c/go.mod h1:fUvtmpG8Z8Zf5aciadL9a/vn5SB3knG7pdNJixDplPg= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.8.0 h1:ea0Xadu+sHlu7x5O3gKhRpQ1IKiMrSiHttPF0ybECuA= github.com/bytedance/sonic v1.8.0/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= -github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= @@ -21,13 +22,16 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE= github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.9.0 h1:OjyFBKICoexlu99ctXNR2gg+c5pKrKMuyjgARg9qeY8= github.com/gin-gonic/gin v1.9.0/go.mod h1:W1Me9+hsUSyj3CePGrd1/QrKJMSJ1Tu/0hFEH89961k= -github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= @@ -35,6 +39,7 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU= github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= @@ -60,10 +65,16 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA= github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/puddle/v2 v2.2.0/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= @@ -88,9 +99,11 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE= @@ -101,6 +114,7 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -108,11 +122,13 @@ github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -131,53 +147,65 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.9 h1:rmenucSohSTiyL09Y+l2OCk+FrMxGMzho2+tjr5ticU= github.com/ugorji/go/codec v1.2.9/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -190,10 +218,14 @@ google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f/go.mod h1:RGgjbofJ google.golang.org/grpc v1.54.0 h1:EhTqbhiYeixwWQtAEZAxmV9MGqcjEU2mFx52xCzNyag= google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -212,12 +244,14 @@ gorm.io/driver/mysql v1.4.7 h1:rY46lkCspzGHn7+IYsNpSfEv9tA+SU4SkkB+GFX125Y= gorm.io/driver/mysql v1.4.7/go.mod h1:SxzItlnT1cb6e1e4ZRpgJN2VYtcqJgqnHxWr4wsP8oc= gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U= gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A= +gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU= gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0= gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11 h1:9qNbmu21nNThCNnF5i2R3kw2aL27U8ZwbzccNjOmW0g= gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/mcp_server/go.mod b/mcp_server/go.mod index 97db03b3..5ad13ca2 100644 --- a/mcp_server/go.mod +++ b/mcp_server/go.mod @@ -12,7 +12,7 @@ require ( github.com/google/jsonschema-go v0.4.2 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/spf13/cast v1.7.1 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/text v0.41.0 // indirect ) require ( diff --git a/mcp_server/go.sum b/mcp_server/go.sum index d80a0602..9aace7e6 100644 --- a/mcp_server/go.sum +++ b/mcp_server/go.sum @@ -41,6 +41,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/yanshi/src/common.cc b/yanshi/src/common.cc index 2ea555bb..b301c6c6 100644 --- a/yanshi/src/common.cc +++ b/yanshi/src/common.cc @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -75,6 +76,57 @@ void err_msg(const char *format, ...) } #define err_msg_g(...) ({err_msg(__VA_ARGS__); goto quit;}) +// MAX_BACKTRACE_FRAMES bounds the addresses that are collected and resolved. +// backtrace() writes as many entries as the stack holds, so the count handed +// to it has to be the size of the buffer itself: asking for more than the +// array holds writes past the end of it. +#define MAX_BACKTRACE_FRAMES 99 + +// run_addr2line resolves the collected addresses and writes the result to +// stderr. +// +// The addresses are passed as an argument vector rather than as a shell +// command line: the program name is argv[0], which whoever spawns this binary +// chooses, so a value carrying shell syntax would otherwise be able to run a +// command of its own. +static void run_addr2line(void** bt, int nptrs) +{ + if (nptrs > MAX_BACKTRACE_FRAMES) + nptrs = MAX_BACKTRACE_FRAMES; + + char addrs[MAX_BACKTRACE_FRAMES][32]; + // three fixed entries (the program, its options and the object file), one + // address per frame, and the terminating NULL + char* argv[MAX_BACKTRACE_FRAMES + 4]; + int n = 0; + argv[n++] = (char*)"addr2line"; + argv[n++] = (char*)"-Cfipe"; + // glibc leaves program_invocation_name empty when it cannot name the + // program; addr2line then falls back to its own default. + argv[n++] = (char*)(program_invocation_name ? program_invocation_name : ""); + REP(i, nptrs) { + snprintf(addrs[i], sizeof addrs[i], "%p", bt[i]); + argv[n++] = addrs[i]; + } + argv[n] = NULL; + + pid_t pid = fork(); + if (pid < 0) + return; + + if (pid == 0) { + // the backtrace belongs next to the message that is being written + dup2(STDERR_FILENO, STDOUT_FILENO); + execvp(argv[0], argv); + dprintf(STDERR_FILENO, "addr2line: %s\n", strerror(errno)); + _exit(EXIT_FAILURE); + } + + int status; + while (waitpid(pid, &status, 0) < 0 && errno == EINTR) + ; +} + void err_exit(int exitno, const char *format, ...) { va_list ap; @@ -84,16 +136,10 @@ void err_exit(int exitno, const char *format, ...) errno = saved; va_end(ap); - void *bt[99]; - char buf[1024]; - int nptrs = backtrace(bt, LEN(buf)); - int i = sprintf(buf, "addr2line -Cfipe %s", program_invocation_name), j = 0; - while (j < nptrs && i+30 < sizeof buf) - i += sprintf(buf+i, " %p", bt[j++]); - strcat(buf, ">&2"); + void *bt[MAX_BACKTRACE_FRAMES]; + int nptrs = backtrace(bt, LEN(bt)); fputs("\n", stderr); - system(buf); - //backtrace_symbols_fd(buf, nptrs, STDERR_FILENO); + run_addr2line(bt, nptrs); exit(exitno); } From 74d0163ec4db3745cb9f339d09de070c2f28f429 Mon Sep 17 00:00:00 2001 From: "jialong.wang" Date: Sun, 20 Sep 2026 10:44:56 +0800 Subject: [PATCH 23/31] fix(management,mcp-server,yanshi): close the third scan round findings A third scan of main reported fifteen findings across thirteen distinct issues. The ones that hold up are fixed here; the rest are explained in the scan report thread. File writes that followed symlinks (CWE-59) management/tcontrollerd/utils/file.go: EnsureWriteFile no longer wraps ioutil.WriteFile, and CopyFileFromIO streams into a temporary file that is renamed over the destination. tcontrollerd runs as root and writes the nginx configuration of every site to a path derived from the site id, so a link planted in sites-enabled redirected that write and truncated its target. The rewrite also makes the update atomic, which is what the pending detection engine byte code needed: RenameCopyFromIO is now the single write primitive of the package, and management/webserver/utils/file.go uses its own for the same reason. management/tcontrollerd/pkg/cron/forbidden_page.go wrote the default forbidden page with ioutil.WriteFile directly and now goes through the helper. Certificate generation (CWE-770) management/webserver/api/cert.go generates a fresh 4096 bit RSA key per request behind an authenticated route, with no limit and nothing that would ever be reused, so a single session could keep the process busy and grow the certificate directory. It is throttled per client address now, through the same guard the login uses. TFA bootstrap (CWE-307) management/webserver/api/auth.go: GET /api/OTPUrl is reachable without a session and its only gate is a token comparison, which had no limit at all. Rejected attempts are throttled per client address, and the daemon announces at startup when no bootstrap token is configured, since that value is what decides whether the first caller binds the account. compose.yaml passes MGT_BOOTSTRAP_TOKEN through so an operator can set it without a custom override file. api/login_guard.go now takes its thresholds as fields so that the bootstrap and the login can pick their own numbers. MCP server pkg/logger/logger.go shadowed err while opening the log file, which returned a nil error and left the global logger unset, and GetLogger then handed out nil to callers that dereference it. The file is created 0640 as well, like the log files of the management services. pkg/errors/errors.go no longer appends the caller location to the error message: that message is the text of a failed tool call, and the location is an absolute path of the machine the helper runs on. WrapL still logs it, and Location still exposes it. yanshi src/fsa_anno.cc: complement() replaced every association with an empty entry and never called add_assoc, so every action attached under '~' was dropped silently and the CallExpr bookkeeping lost its targets. The automaton is now determinized with its association remapped, and the result of '~' keeps it, as concat/union/star already did. Log injection management/webserver/api/common.go logged the body of a remote answer verbatim, which let that remote end forge log lines with a newline or an escape sequence. The body and the version strings are quoted and truncated before they reach the log. --- compose.yaml | 7 ++ .../tcontrollerd/pkg/cron/forbidden_page.go | 6 +- management/tcontrollerd/utils/file.go | 71 ++++++++++++------- management/webserver/api/auth.go | 60 ++++++++++++++-- management/webserver/api/cert.go | 51 +++++++++++++ management/webserver/api/common.go | 26 ++++++- management/webserver/api/login_guard.go | 48 +++++++++---- management/webserver/main.go | 10 +++ management/webserver/utils/file.go | 16 +++-- mcp_server/pkg/errors/errors.go | 9 ++- mcp_server/pkg/logger/logger.go | 24 ++++++- yanshi/src/fsa_anno.cc | 32 ++++++++- 12 files changed, 296 insertions(+), 64 deletions(-) diff --git a/compose.yaml b/compose.yaml index 49746b9e..b1eb269a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -48,6 +48,13 @@ services: # ${SAFELINE_DIR}/resources/sock/mgt_grpc_token, which this container # creates and the tengine container reads. - MGT_GRPC_TOKEN=${MGT_GRPC_TOKEN:-} + # Optional out of band token for the anonymous TFA bootstrap. As long as + # it is empty, GET /api/OTPUrl hands the TFA secret of the account to + # whoever reaches the port first, and the login is then bound to that + # address; the daemon warns about it at startup. Set it, and pass the + # same value to the request as X-Bootstrap-Token, to keep the binding for + # an administrator who has the token. + - MGT_BOOTSTRAP_TOKEN=${MGT_BOOTSTRAP_TOKEN:-} depends_on: - postgres - fvm diff --git a/management/tcontrollerd/pkg/cron/forbidden_page.go b/management/tcontrollerd/pkg/cron/forbidden_page.go index d5efe05b..2a8d5fe6 100644 --- a/management/tcontrollerd/pkg/cron/forbidden_page.go +++ b/management/tcontrollerd/pkg/cron/forbidden_page.go @@ -45,7 +45,11 @@ func checkAndUpdateForbiddenPage() { return } - err = ioutil.WriteFile(forbiddenPagePath, []byte(constants.DefaultForbiddenPage), 0644) + // utils.EnsureWriteFile replaces the file atomically instead of truncating + // it in place: the path lives in the nginx configuration tree, so a symlink + // planted there would otherwise make this root process overwrite its target + // (CWE-59). + err = utils.EnsureWriteFile(forbiddenPagePath, []byte(constants.DefaultForbiddenPage), 0644) if err != nil { logger.Error(err) return diff --git a/management/tcontrollerd/utils/file.go b/management/tcontrollerd/utils/file.go index 159b1b1f..ed42ae64 100644 --- a/management/tcontrollerd/utils/file.go +++ b/management/tcontrollerd/utils/file.go @@ -1,9 +1,9 @@ package utils import ( + "bytes" "fmt" "io" - "io/ioutil" "os" "path/filepath" ) @@ -49,12 +49,33 @@ func FilesExist(paths ...string) (bool, error) { return true, nil } +// RenameWriteFile writes data to filename by writing a temporary file next to +// it and renaming that file into place. +// +// os.CreateTemp creates the file with O_CREATE|O_EXCL and mode 0600, so a file +// or symlink planted at the temporary path beforehand cannot be followed, and +// the rename replaces a symlink that was left at filename instead of writing +// through it. The previous implementation guessed the name with math/rand and +// wrote through ioutil.WriteFile, which follows symlinks. func RenameWriteFile(filename string, data []byte, perm os.FileMode) error { - // os.CreateTemp creates the file with O_CREATE|O_EXCL and mode 0600, so a - // file or symlink planted at the temporary path beforehand cannot be - // followed. The previous implementation guessed the name with math/rand and - // wrote through ioutil.WriteFile, which follows symlinks. - tmpFile, err := os.CreateTemp(filepath.Dir(filename), filepath.Base(filename)+".tmp.") + return RenameCopyFromIO(bytes.NewReader(data), filename, perm) +} + +// RenameCopyFromIO streams src into a temporary file created next to dstPath +// and renames it over dstPath once the whole content has been written. +// +// This is the write primitive of the package: opening dstPath in place, as +// CopyFileFromIO used to do with O_CREATE|O_WRONLY|O_TRUNC and no O_NOFOLLOW, +// lets anyone who can create an entry in the destination directory redirect the +// write to another file and truncate it. tcontrollerd runs as root and writes +// the nginx configuration of every site into a path derived from the site id, +// so the destination names are predictable and the target of such a link would +// be overwritten with root privileges. +// +// The rename also makes the update atomic: a reader either sees the complete +// old file or the complete new one, never a half written mixture. +func RenameCopyFromIO(src io.Reader, dstPath string, perm os.FileMode) error { + tmpFile, err := os.CreateTemp(filepath.Dir(dstPath), filepath.Base(dstPath)+".tmp.") if err != nil { return err } @@ -71,16 +92,17 @@ func RenameWriteFile(filename string, data []byte, perm os.FileMode) error { tmpFile.Close() return err } - if _, err = tmpFile.Write(data); err != nil { + if _, err = io.Copy(tmpFile, src); err != nil { tmpFile.Close() return err } if err = tmpFile.Close(); err != nil { return err } - if err = os.Rename(tmpName, filename); err != nil { + if err = os.Rename(tmpName, dstPath); err != nil { return err } + renamed = true return nil } @@ -94,13 +116,14 @@ func EnsureRenameWriteFile(path string, data []byte, mode os.FileMode) error { return RenameWriteFile(path, data, mode) } +// EnsureWriteFile writes a file with the given mode, creating its parent +// directory when needed. +// +// It goes through RenameCopyFromIO, so the write is atomic and cannot be +// redirected through a symlink: ioutil.WriteFile, which this used to call, +// truncates the target of a pre-existing link at the destination path. func EnsureWriteFile(path string, data []byte, mode os.FileMode) error { - err := EnsureFileDir(path) - if err != nil { - return err - } - - return ioutil.WriteFile(path, data, mode) + return EnsureRenameWriteFile(path, data, mode) } func CopyFile(srcPath, dstPath string) error { @@ -123,24 +146,18 @@ func CopyFile(srcPath, dstPath string) error { return CopyFileFromIO(srcFile, dstPath, fileInfo.Mode()) } +// CopyFileFromIO copies src into dstPath. +// +// The copy is written to a temporary file and renamed into place, so it is +// atomic and cannot be redirected through a symlink left at dstPath. Opening +// dstPath with O_CREATE|O_WRONLY|O_TRUNC and no O_NOFOLLOW, which this used to +// do, truncated whatever a planted link pointed at. func CopyFileFromIO(src io.Reader, dstPath string, perm os.FileMode) error { if err := EnsureFileDir(dstPath); err != nil { return err } - dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm) - if err != nil { - return err - } - defer func(dstFile *os.File) { - err := dstFile.Close() - if err != nil { - - } - }(dstFile) - - _, err = io.Copy(dstFile, src) - return err + return RenameCopyFromIO(src, dstPath, perm) } func CopyFileIfNotExist(srcPath, dstPath string) error { diff --git a/management/webserver/api/auth.go b/management/webserver/api/auth.go index 9e239011..c94f7070 100644 --- a/management/webserver/api/auth.go +++ b/management/webserver/api/auth.go @@ -33,9 +33,40 @@ var loginThrottle = newLoginGuard() // TFA bootstrap. See GetOTPUrl. const BootstrapTokenEnv = "MGT_BOOTSTRAP_TOKEN" -// bootstrapTokenHeader is the header that carries the value of +// BootstrapTokenHeader is the header that carries the value of // BootstrapTokenEnv. -const bootstrapTokenHeader = "X-Bootstrap-Token" +const BootstrapTokenHeader = "X-Bootstrap-Token" + +const ( + // bootstrapFailureThreshold is how many rejected bootstrap attempts a + // client address may make before it has to wait. + bootstrapFailureThreshold = 3 + + // bootstrapFailureWindow is how long a rejected attempt is remembered. + bootstrapFailureWindow = 10 * time.Minute + + // bootstrapMaxLockout caps the wait imposed on a caller that keeps trying. + bootstrapMaxLockout = 5 * time.Minute +) + +// bootstrapThrottle slows down guessing of the bootstrap token. +// +// GET /api/OTPUrl is reachable without a session, and the token comparison is +// the only thing between a caller and the TFA secret of the account, so a +// caller that may try values without limit could sit on the endpoint until it +// guesses one. The keys are per client address: a key shared by every caller +// would hand an attacker a way to lock the administrator out of the binding +// instead. +var bootstrapThrottle = newThrottle(bootstrapFailureThreshold, bootstrapFailureWindow, bootstrapMaxLockout) + +// bootstrapKeys returns the throttle keys of a bootstrap attempt. +func bootstrapKeys(clientIP string) []string { + if clientIP == "" { + return nil + } + + return []string{fmt.Sprintf("bootstrap:%s", clientIP)} +} var OtpOpts = totp.GenerateOpts{ Issuer: constants.ProductName, @@ -153,12 +184,24 @@ func GetOTPUrl(c *gin.Context) { return } + clientIP := c.ClientIP() + throttleKeys := bootstrapKeys(clientIP) + if key, wait := bootstrapThrottle.lockout(throttleKeys); wait > 0 { + logger.Warnf("TFA bootstrap throttled by %s, retry in %s", key, wait) + c.Header("Retry-After", strconv.FormatInt(int64(wait.Seconds())+1, 10)) + response.Error(c, response.JSONBody{Err: response.ErrTooManyAttempts, Msg: "Too many attempts, please try again later"}, http.StatusTooManyRequests) + return + } + if err := checkBootstrapToken(c); err != nil { - logger.Warnf("Refused to hand out the TFA secret to %s: %s", c.ClientIP(), err) + bootstrapThrottle.fail(throttleKeys) + logger.Warnf("Refused to hand out the TFA secret to %s: %s", clientIP, err) response.Error(c, response.JSONBody{Err: response.ErrLoginRequired, Msg: "TFA bootstrap is not allowed for this request"}, http.StatusUnauthorized) return } + bootstrapThrottle.reset(throttleKeys) + // The console displays the QR code on the login page, before any session // exists, so this endpoint is reachable while the account has never logged // in. It must not hand out a fresh secret on every call, and the secret it @@ -166,7 +209,6 @@ func GetOTPUrl(c *gin.Context) { // caller that fetched a secret the administrator is about to bind would // otherwise be able to log in with it, because the passcode is the only // credential. - clientIP := c.ClientIP() if user.TFASecret != "" { if user.TFABootstrapIP == "" { // An installation that already carries a pending secret from a @@ -272,17 +314,23 @@ func otpURL(secret string) string { // Installations that do not want the account to be bindable by whoever reaches // the API first set MGT_BOOTSTRAP_TOKEN and deliver it to the administrator // through a channel of their choice (the installation output, for example). +// +// The empty value keeps the bootstrap open on purpose: the console that ships +// in the management image asks this endpoint for the secret without knowing it, +// so requiring a token here would leave a fresh installation without any way to +// bind the account. That trade off is announced at startup (see main.go) so +// that it is a visible choice rather than a silent default. func checkBootstrapToken(c *gin.Context) error { expected := os.Getenv(BootstrapTokenEnv) if expected == "" { return nil } - if subtle.ConstantTimeCompare([]byte(c.GetHeader(bootstrapTokenHeader)), []byte(expected)) == 1 { + if subtle.ConstantTimeCompare([]byte(c.GetHeader(BootstrapTokenHeader)), []byte(expected)) == 1 { return nil } - return fmt.Errorf("missing or wrong %s header", bootstrapTokenHeader) + return fmt.Errorf("missing or wrong %s header", BootstrapTokenHeader) } func GetUser(c *gin.Context) { diff --git a/management/webserver/api/cert.go b/management/webserver/api/cert.go index 40c532f3..2dc044c8 100644 --- a/management/webserver/api/cert.go +++ b/management/webserver/api/cert.go @@ -8,6 +8,8 @@ import ( "net/http" "os" "path/filepath" + "strconv" + "time" "github.com/gin-gonic/gin" @@ -21,6 +23,41 @@ type postSSLCertRequest struct { Hostname string `json:"hostname"` } +const ( + // certGenerationThreshold is how many key pairs a client address may ask + // for within certGenerationWindow before it has to wait. + certGenerationThreshold = 5 + + // certGenerationWindow is how long a generation is counted. + certGenerationWindow = 10 * time.Minute + + // certGenerationMaxLockout caps the wait imposed on a caller that keeps + // asking. + certGenerationMaxLockout = 5 * time.Minute +) + +// certGenerationThrottle bounds how often this endpoint may spend a key +// generation. +// +// Every accepted request generates a fresh 4096 bit RSA key in the request +// goroutine; the random file name prefix makes the existence check in +// WriteCertIfNotExist a no-op, so nothing is ever reused. An authenticated +// session that loops over this route therefore keeps the process busy with RSA +// key generation and fills the certificate directory of the site, which is the +// same process that compiles and pushes the detection rules. The throttle is +// counted per client address, and one generation is counted whether it +// succeeds or not. +var certGenerationThrottle = newThrottle(certGenerationThreshold, certGenerationWindow, certGenerationMaxLockout) + +// certGenerationKeys returns the throttle keys of a certificate request. +func certGenerationKeys(clientIP string) []string { + if clientIP == "" { + return nil + } + + return []string{fmt.Sprintf("sslcert:%s", clientIP)} +} + // SSLCertDir is the dir of tengine conf, not mgt-api nginx certs dir defined by constants.CertsPath const ( CRT = ".crt" @@ -123,6 +160,20 @@ func PostSSLCert(c *gin.Context) { return } + clientIP := c.ClientIP() + throttleKeys := certGenerationKeys(clientIP) + if key, wait := certGenerationThrottle.lockout(throttleKeys); wait > 0 { + logger.Warnf("Certificate generation throttled by %s, retry in %s", key, wait) + c.Header("Retry-After", strconv.FormatInt(int64(wait.Seconds())+1, 10)) + response.Error(c, response.JSONBody{Err: response.ErrTooManyAttempts, Msg: "Too many certificate requests, please try again later"}, http.StatusTooManyRequests) + return + } + + // The generation below is the expensive part of this handler, so the + // attempt is recorded up front: a caller that only ever produces failing + // requests must not get an unlimited number of key generations either. + certGenerationThrottle.fail(throttleKeys) + filePrefix := utils.RandStr(16) certFilename := fmt.Sprintf("%s_backend.crt", filePrefix) keyFilename := fmt.Sprintf("%s_backend.key", filePrefix) diff --git a/management/webserver/api/common.go b/management/webserver/api/common.go index 3aac1c47..a9847e05 100644 --- a/management/webserver/api/common.go +++ b/management/webserver/api/common.go @@ -6,6 +6,7 @@ import ( "io" "io/ioutil" "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -24,6 +25,27 @@ const VersionInfoEntrypoint = "/release/latest/version.json" // answer from a misbehaving (or hostile) endpoint must not be buffered whole. const maxVersionInfoBytes = 1 << 20 +// maxLoggedBodyBytes bounds how much of an answer from another host may be put +// into the log. +const maxLoggedBodyBytes = 256 + +// loggableBody renders a bounded, quoted, single line version of a response +// body that came from another host. +// +// The body is remote input, and the log is read by people and by tools that +// follow it: a verbatim body lets the other end forge log lines (or worse, if +// the log is read in a terminal) with a carriage return or an escape sequence. +// strconv.Quote escapes the newlines and the control characters, and the +// truncation keeps a misbehaving endpoint from filling the disk through the +// log file. +func loggableBody(body []byte) string { + if len(body) > maxLoggedBodyBytes { + return fmt.Sprintf("%s... (%d bytes in total)", strconv.Quote(string(body[:maxLoggedBodyBytes])), len(body)) + } + + return strconv.Quote(string(body)) +} + type idsRequest struct { IDs []uint `json:"ids" form:"ids"` } @@ -77,7 +99,7 @@ func GetUpgradeTips(ctx *gin.Context) { versionInfo := &versionInfoResponse{} err = json.Unmarshal(body, versionInfo) if err != nil { - logger.Warnf("err: %v, body: %s", err, body) + logger.Warnf("Failed to parse %s: %v, body: %s", config.GlobalConfig.PlatformAddr+VersionInfoEntrypoint, err, loggableBody(body)) response.Success(ctx, gin.H{"upgrade_tips": constants.NotUpgrade}) return } @@ -87,7 +109,7 @@ func GetUpgradeTips(ctx *gin.Context) { recVersionCmp := semver.Compare(currentVersion, versionInfo.RecVersion) if semver.Compare(versionInfo.LatestVersion, versionInfo.RecVersion) == -1 || latestVersionCmp == 1 { logger.Warnf("The version number is invalid, current version: %s, latest version: %s, rec version: %s", - currentVersion, versionInfo.LatestVersion, versionInfo.RecVersion) + currentVersion, strconv.Quote(versionInfo.LatestVersion), strconv.Quote(versionInfo.RecVersion)) response.Success(ctx, gin.H{"upgrade_tips": constants.NotUpgrade}) return } diff --git a/management/webserver/api/login_guard.go b/management/webserver/api/login_guard.go index ac4cbc17..29b13681 100644 --- a/management/webserver/api/login_guard.go +++ b/management/webserver/api/login_guard.go @@ -31,23 +31,43 @@ type loginAttempt struct { lastFailure time.Time } -// loginGuard throttles the login endpoint per account and per client address. +// loginGuard throttles an endpoint that an unauthenticated or partially +// authenticated caller can drive per account and per client address. // // The management console has a single account and the passcode is its only // credential, so a caller that is allowed to try passcodes without limit can -// simply enumerate them. +// simply enumerate them. The same guard covers the TFA bootstrap, where the +// only thing between a caller and the secret of the account is a token +// comparison. type loginGuard struct { mu sync.Mutex attempts map[string]*loginAttempt + // failureThreshold is how many failures are tolerated before the caller has + // to wait, failureWindow how long a failure is remembered and maxLockout + // caps the wait that is imposed. + failureThreshold int + failureWindow time.Duration + maxLockout time.Duration + // now is a seam for the tests. now func() time.Time } +// newLoginGuard returns the throttle of the login endpoint. func newLoginGuard() *loginGuard { + return newThrottle(loginFailureThreshold, loginFailureWindow, loginMaxLockout) +} + +// newThrottle returns a throttle with its own thresholds, which lets an +// endpoint that is cheaper to reach than the login pick stricter numbers. +func newThrottle(threshold int, window, maxLockout time.Duration) *loginGuard { return &loginGuard{ - attempts: make(map[string]*loginAttempt), - now: time.Now, + attempts: make(map[string]*loginAttempt), + failureThreshold: threshold, + failureWindow: window, + maxLockout: maxLockout, + now: time.Now, } } @@ -86,11 +106,11 @@ func (g *loginGuard) lockout(keys []string) (string, time.Duration) { for _, key := range keys { attempt, ok := g.attempts[key] - if !ok || attempt.failures < loginFailureThreshold { + if !ok || attempt.failures < g.failureThreshold { continue } - if remaining := loginBackoff(attempt.failures) - now.Sub(attempt.lastFailure); remaining > wait { + if remaining := g.backoff(attempt.failures) - now.Sub(attempt.lastFailure); remaining > wait { blockedKey, wait = key, remaining } } @@ -108,7 +128,7 @@ func (g *loginGuard) fail(keys []string) { for _, key := range keys { attempt, ok := g.attempts[key] - if !ok || now.Sub(attempt.lastFailure) > loginFailureWindow { + if !ok || now.Sub(attempt.lastFailure) > g.failureWindow { attempt = &loginAttempt{} g.attempts[key] = attempt } @@ -131,7 +151,7 @@ func (g *loginGuard) reset(keys []string) { // prune drops records that are no longer useful and keeps the map bounded. func (g *loginGuard) prune(now time.Time) { for key, attempt := range g.attempts { - if now.Sub(attempt.lastFailure) > loginFailureWindow+loginMaxLockout { + if now.Sub(attempt.lastFailure) > g.failureWindow+g.maxLockout { delete(g.attempts, key) } } @@ -153,10 +173,10 @@ func (g *loginGuard) prune(now time.Time) { } } -// loginBackoff returns how long a caller that failed n times in a row has to -// wait after its last failure. The delay doubles per failure and is capped. -func loginBackoff(failures int) time.Duration { - shift := failures - loginFailureThreshold +// backoff returns how long a caller that failed n times in a row has to wait +// after its last failure. The delay doubles per failure and is capped. +func (g *loginGuard) backoff(failures int) time.Duration { + shift := failures - g.failureThreshold if shift < 0 { shift = 0 } @@ -165,8 +185,8 @@ func loginBackoff(failures int) time.Duration { } backoff := time.Second << uint(shift) - if backoff > loginMaxLockout { - backoff = loginMaxLockout + if backoff > g.maxLockout { + backoff = g.maxLockout } return backoff diff --git a/management/webserver/main.go b/management/webserver/main.go index ce1fcb82..034ec710 100644 --- a/management/webserver/main.go +++ b/management/webserver/main.go @@ -155,6 +155,16 @@ func main() { store := cookie.NewStore([]byte(option.Value)) r.Use(sessions.Sessions("session", store)) + // The bootstrap token is the only gate in front of the TFA secret of the + // account. It is optional so that a fresh installation can be bound from + // the console, but the default is worth announcing: with an empty value + // the first caller that reaches the API is the one the account gets bound + // to. + if os.Getenv(api.BootstrapTokenEnv) == "" { + logger.Warnf("%s is not set: the TFA secret of the account is handed to whoever reaches GET %s%s first. Set it to require the %s header on that request.", + api.BootstrapTokenEnv, "/api", api.OTPUrl, api.BootstrapTokenHeader) + } + publicRouters := r.Group("/api") publicRouters.POST(api.Login, api.PostLogin) publicRouters.POST(api.Logout, api.PostLogout) diff --git a/management/webserver/utils/file.go b/management/webserver/utils/file.go index c5e45c46..846cc676 100644 --- a/management/webserver/utils/file.go +++ b/management/webserver/utils/file.go @@ -2,7 +2,6 @@ package utils import ( "fmt" - "io/ioutil" "os" "path/filepath" ) @@ -95,11 +94,14 @@ func EnsureRenameWriteFile(path string, data []byte, mode os.FileMode) error { return RenameWriteFile(path, data, mode) } +// EnsureWriteFile writes a file with the given mode, creating its parent +// directory when needed. +// +// It goes through RenameWriteFile, so the write is atomic and cannot be +// redirected through a symlink: ioutil.WriteFile, which this used to call, +// truncates whatever a link planted at the destination path points at. The +// detection engine byte code is written through here, and a reader either sees +// the complete old file or the complete new one instead of a torn mixture. func EnsureWriteFile(path string, data []byte, mode os.FileMode) error { - err := EnsureFileDir(path) - if err != nil { - return err - } - - return ioutil.WriteFile(path, data, mode) + return EnsureRenameWriteFile(path, data, mode) } diff --git a/mcp_server/pkg/errors/errors.go b/mcp_server/pkg/errors/errors.go index ee6ebae5..64f42a1d 100644 --- a/mcp_server/pkg/errors/errors.go +++ b/mcp_server/pkg/errors/errors.go @@ -28,11 +28,16 @@ type Error struct { } // Error Implement error interface +// +// The message never carries the source location: it is what the MCP client +// receives as the text of a failed tool call, and the location is an absolute +// path of the machine the helper runs on. WrapL puts it into the server side +// log, where it belongs, and Location exposes it to callers that need it. func (e *Error) Error() string { if e.msg != "" { - return fmt.Sprintf("%s: %v (at %s)", e.msg, e.err, e.location) + return fmt.Sprintf("%s: %v", e.msg, e.err) } - return fmt.Sprintf("%v (at %s)", e.err, e.location) + return fmt.Sprintf("%v", e.err) } // Unwrap Return original error diff --git a/mcp_server/pkg/logger/logger.go b/mcp_server/pkg/logger/logger.go index e52ad250..c4714d30 100644 --- a/mcp_server/pkg/logger/logger.go +++ b/mcp_server/pkg/logger/logger.go @@ -34,6 +34,11 @@ type Config struct { Development bool `json:"development" yaml:"development"` } +// logFileMode keeps the log file readable for its owner and its group only. +// The tools that run behind this logger pass request arguments through it, and +// those are none of the business of every other account on the host. +const logFileMode os.FileMode = 0640 + // 默认配置 var defaultConfig = Config{ Level: "info", @@ -87,8 +92,13 @@ func Init(cfg *Config) error { // 文件输出 if cfg.FilePath != "" { - fileWriter, err := os.OpenFile(cfg.FilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { + // Do not shadow err: the old code declared a new variable here, so + // a failure to open the log file returned a nil error and left + // defaultLogger unset, which turned every later log call into a nil + // pointer dereference. + fileWriter, openErr := os.OpenFile(cfg.FilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFileMode) + if openErr != nil { + err = openErr return } cores = append(cores, zapcore.NewCore( @@ -132,6 +142,16 @@ func GetLogger() *Logger { if defaultLogger == nil { Init(nil) } + + if defaultLogger == nil { + // Init only runs once: when it failed (or when it was called with a + // configuration that could not be opened) it never installs a logger, + // and the call above cannot repair that. Handing out nil here crashed + // the caller on the next log call, so a no-op logger is returned + // instead. + return &Logger{zl: zap.NewNop()} + } + return defaultLogger } diff --git a/yanshi/src/fsa_anno.cc b/yanshi/src/fsa_anno.cc index 7ae94999..6ac94737 100644 --- a/yanshi/src/fsa_anno.cc +++ b/yanshi/src/fsa_anno.cc @@ -104,10 +104,36 @@ void FsaAnno::co_accessible(const vector* final, vector& mapping) { } void FsaAnno::complement(ComplementExpr* expr) { - if (! deterministic) - fsa = fsa.determinize(NULL, [&](long, const vector&){}); + if (! deterministic) { + // Rebuild the association for the determinized automaton before + // complementing it: Fsa::determinize merges states, so the entries of + // assoc would otherwise keep talking about the numbering of the + // non-deterministic automaton. difference() and intersect() do the same. + decltype(assoc) new_assoc; + auto relate = [&](long id, const vector& xs) { + if (id+1 > new_assoc.size()) + new_assoc.resize(id+1); + auto& as = new_assoc[id]; + for (long x: xs) + as.insert(as.end(), ALL(assoc[x])); + sort_assoc(as); + }; + fsa = fsa.determinize(NULL, relate); + assoc = move(new_assoc); + } fsa = ~ fsa; - assoc.assign(fsa.n(), {}); + // Fsa::operator~ keeps every state at its index and only appends the new + // accepting state, so the association survives. Replacing the vector with + // empty entries (assoc.assign(fsa.n(), {}), as this used to) dropped every + // action that was attached underneath the operator, and generate_transitions + // derives all action bodies from assoc: a rule like + // export foo = ~([a-z] > { puts("entered"); }) + // compiled without the handler the author wrote, silently (CWE-684). The + // same held for the CallExpr/CollapseExpr bookkeeping, which resolves its + // targets through assoc. + assoc.resize(fsa.n()); + if (expr) + add_assoc(*expr); deterministic = true; } From c5bac20cfc46061082b7516668fe8d333bf19d9b Mon Sep 17 00:00:00 2001 From: "jialong.wang" Date: Sun, 20 Sep 2026 11:16:31 +0800 Subject: [PATCH 24/31] fix(management,mcp-server,yanshi): harden the remaining rescan findings - management/webserver/api/auth.go: spend the TOTP passcode with a single conditional update and create the session only for the request that spent it, so two requests that carry the same passcode can no longer both read the same stored time step and both be accepted. The bootstrap secret is installed and adopted the same way, which stops two callers that reach the endpoint together from both being told that the secret they generated is the one the account carries. - management/webserver/pkg/fvm/fvm.go: refuse an empty buffer before taking the address of its first byte, which panics on a zero length slice. - mcp_server/internal/api/client.go: follow a redirect only while it stays on the origin of the configured instance. The deployment token travels in a header that net/http does not strip when a redirect leaves the origin. - yanshi: bound the nesting depth of an expression tree while it is built. A line of adjacent concatenations is reduced into a left leaning chain that is as deep as the line is long, and every later pass over the tree, as well as the code that frees it, recurses along its edges. --- management/webserver/api/auth.go | 148 +++++++++++++++--- management/webserver/pkg/fvm/fvm.go | 12 +- mcp_server/internal/api/client.go | 51 +++++- .../internal/api/client_redirect_test.go | 138 ++++++++++++++++ yanshi/src/common.hh | 7 + yanshi/src/parser.y | 46 ++++-- yanshi/src/syntax.hh | 34 ++-- 7 files changed, 396 insertions(+), 40 deletions(-) create mode 100644 mcp_server/internal/api/client_redirect_test.go diff --git a/management/webserver/api/auth.go b/management/webserver/api/auth.go index c94f7070..ed771750 100644 --- a/management/webserver/api/auth.go +++ b/management/webserver/api/auth.go @@ -115,7 +115,8 @@ func PostLogin(c *gin.Context) { return } - usedStep, valid := verifyPasscode(user.TFASecret, params.Passcode, user.TFALastUsedStep, time.Now()) + now := time.Now() + usedStep, valid := verifyPasscode(user.TFASecret, params.Passcode, user.TFALastUsedStep, now) if !valid { loginThrottle.fail(keys) logger.Warnf("Failed login attempt from %s", c.ClientIP()) @@ -137,17 +138,25 @@ func PostLogin(c *gin.Context) { return } - loginThrottle.reset(keys) + // The passcode is spent here, in one conditional update, and the session + // below is only created when this request is the one that spent it. A plain + // Save after the check would leave a window in which two requests that + // carry the same passcode both read the same stored time step and both + // accept it. + consumed, err := consumePasscode(db, &user, usedStep, now.Unix()/int64(OtpOpts.Period), now) + if err != nil { + logger.Error(err) + response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when creating sessions"}, http.StatusInternalServerError) + return + } + if !consumed { + loginThrottle.fail(keys) + logger.Warnf("Refused a login from %s: the passcode of time step %d was already used", c.ClientIP(), usedStep) + response.Error(c, response.JSONBody{Err: response.ErrWrongPasscode, Msg: "Failed to verify your passcode"}, http.StatusUnauthorized) + return + } - user.LastLoginTime = time.Now().Unix() - user.IsEnabled = true - // The bootstrap is over: the secret is bound, so it is no longer pending - // and no longer tied to the address that fetched it. - user.TFABootstrapIP = "" - // Remember the time step of the accepted passcode so that the same one - // cannot be used a second time. - user.TFALastUsedStep = usedStep - db.Save(&user) + loginThrottle.reset(keys) session := sessions.Default(c) session.Options(sessionopts.Options(c)) @@ -214,11 +223,18 @@ func GetOTPUrl(c *gin.Context) { // An installation that already carries a pending secret from a // release that did not record the address keeps working: the first // caller adopts it, exactly as it did before. - user.TFABootstrapIP = clientIP - db.Save(&user) + adopted, err := adoptBootstrapSecret(db, user.TFASecret, clientIP) + if err != nil { + logger.Error(err) + response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when generating otp qrcode"}, http.StatusInternalServerError) + return + } + if !adopted { + refusePendingSecret(c, clientIP) + return + } } else if user.TFABootstrapIP != clientIP { - logger.Warnf("Refused to hand out the pending TFA secret to %s: it was issued to %s", clientIP, user.TFABootstrapIP) - response.Error(c, response.JSONBody{Err: response.ErrLoginRequired, Msg: "The pending TFA secret belongs to another address, run 'mgt -reset_user ' to bind a new one"}, http.StatusForbidden) + refusePendingSecret(c, clientIP) return } @@ -234,13 +250,109 @@ func GetOTPUrl(c *gin.Context) { return } - user.TFASecret = otpKey.Secret() - user.TFABootstrapIP = clientIP - db.Save(&user) + // The secret is installed by an update that only matches while the row + // still has none, so two calls that arrive together cannot both be told + // that the secret they generated is the one the account carries: the + // caller that loses reads the stored secret below and hands that one out + // when it belongs to the same address, which is what a console that + // retries produces, and is refused otherwise. + issued, err := claimBootstrapSecret(db, clientIP, otpKey.Secret()) + if err != nil { + logger.Error(err) + response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when generating otp qrcode"}, http.StatusInternalServerError) + return + } + if !issued { + logger.Warnf("Lost the TFA bootstrap race to another caller, %s is served the stored secret if it is its own", clientIP) + var stored model.User + db.Where(&model.User{Username: constants.SuperUser}).First(&stored) + if stored.TFASecret == "" || stored.TFABootstrapIP != clientIP { + refusePendingSecret(c, clientIP) + return + } + + response.Success(c, gin.H{"url": otpURL(stored.TFASecret)}) + return + } + logger.Warnf("Issued a new TFA secret to %s; it can only be bound and used from that address until the first login", clientIP) response.Success(c, gin.H{"url": otpKey.URL()}) } +// refusePendingSecret answers a caller that may not be handed the pending TFA +// secret, because it was issued to another address. +func refusePendingSecret(c *gin.Context, clientIP string) { + logger.Warnf("Refused to hand out the pending TFA secret to %s: it belongs to another address", clientIP) + response.Error(c, response.JSONBody{Err: response.ErrLoginRequired, Msg: "The pending TFA secret belongs to another address, run 'mgt -reset_user ' to bind a new one"}, http.StatusForbidden) +} + +// claimBootstrapSecret installs secret as the pending TFA secret of the +// account and reports whether this call is the one that installed it. +// +// The update matches only while the row carries no secret yet, which is what +// makes the claim exclusive. Reading the row and writing it back would let two +// callers that arrive while the account has no secret both store the one they +// generated, so both would be told the account is bound to the secret they +// hold, while the database keeps only the last write. +func claimBootstrapSecret(db *database.PostgresDB, clientIP, secret string) (bool, error) { + result := db.Model(&model.User{}). + Where("username = ? AND (tfa_secret = '' OR tfa_secret IS NULL)", constants.SuperUser). + Updates(map[string]interface{}{ + "tfa_secret": secret, + "tfa_bootstrap_ip": clientIP, + }) + if result.Error != nil { + return false, result.Error + } + + return result.RowsAffected == 1, nil +} + +// adoptBootstrapSecret records clientIP as the address of a pending secret +// that was stored before the address was recorded. +// +// The update is restricted to the secret that was read, so a caller that lost a +// race against the installation of a new secret adopts nothing. +func adoptBootstrapSecret(db *database.PostgresDB, pending, clientIP string) (bool, error) { + result := db.Model(&model.User{}). + Where("username = ? AND tfa_secret = ? AND (tfa_bootstrap_ip = '' OR tfa_bootstrap_ip IS NULL)", constants.SuperUser, pending). + Updates(map[string]interface{}{ + "tfa_bootstrap_ip": clientIP, + }) + if result.Error != nil { + return false, result.Error + } + + return result.RowsAffected == 1, nil +} + +// consumePasscode records an accepted passcode together with the login it +// belongs to, as a single conditional update. +// +// The update is what decides whether the time step of the passcode was already +// spent: it only matches while the stored step is below the accepted one, so +// the second of two concurrent requests that carry the same passcode affects no +// row and is treated as a replay. currentStep is the step the passcode was +// verified against; a stored step ahead of it means the clock moved backwards, +// which this process treats as stale (see verifyPasscode) and repairs here. +func consumePasscode(db *database.PostgresDB, user *model.User, usedStep, currentStep int64, now time.Time) (bool, error) { + result := db.Model(&model.User{}). + Where("id = ? AND (tfa_last_used_step < ? OR tfa_last_used_step > ?)", user.ID, usedStep, currentStep+1). + Updates(map[string]interface{}{ + "tfa_last_used_step": usedStep, + // The bootstrap is over: the secret is bound, so it is no longer + // pending and no longer tied to the address that fetched it. + "tfa_bootstrap_ip": "", + "last_login_time": now.Unix(), + "is_enabled": true, + }) + if result.Error != nil { + return false, result.Error + } + + return result.RowsAffected == 1, nil +} + // verifyPasscode checks a TOTP passcode and returns the time step it belongs to. // // lastUsedStep is the step of the passcode that was accepted last; every step diff --git a/management/webserver/pkg/fvm/fvm.go b/management/webserver/pkg/fvm/fvm.go index a69aaea4..2ed97ffe 100644 --- a/management/webserver/pkg/fvm/fvm.go +++ b/management/webserver/pkg/fvm/fvm.go @@ -59,8 +59,18 @@ func (u *FVMUpdate) ToBytes() []byte { return C.GoBytes(unsafe.Pointer(u.update.buf.ptr), C.int(u.update.buf.length)) } +// FromBytes builds an update from the serialized byte code, as it is stored in +// the database. +// +// An empty buffer is rejected before the address of its first byte is taken: +// &out[0] panics on a zero length slice, and fvm_update_create would have to +// read the buffer that the length describes. func (u *FVMUpdate) FromBytes(out []byte) error { - ptr := C.build_update(unsafe.Pointer(&out[0]), C.ulong(len(out))) + if len(out) == 0 { + return errors.New("refuse to create an update from an empty buffer") + } + + ptr := C.build_update(maybePointer(out), C.ulong(len(out))) if ptr == nil { return errors.New("failed to create update from db") } diff --git a/mcp_server/internal/api/client.go b/mcp_server/internal/api/client.go index f528f1fa..dd6b6a4d 100644 --- a/mcp_server/internal/api/client.go +++ b/mcp_server/internal/api/client.go @@ -16,8 +16,14 @@ import ( ) const ( + // tokenHeader carries the deployment token of the configured instance. + tokenHeader = "X-SLCE-API-TOKEN" + maxErrorBodySize = 4 << 10 maxResponseBodySize = 4 << 20 + + // maxRedirects bounds how many redirects one request may follow. + maxRedirects = 10 ) // Client is the transport used by a single SafeLine instance. Its request @@ -50,15 +56,58 @@ func newClient(baseURL string, timeout time.Duration, insecureSkipVerify bool, t httpClient: &http.Client{ Timeout: timeout, Transport: transport, + // Every request carries the deployment token in X-SLCE-API-TOKEN. + // net/http drops the Authorization header when a redirect leaves + // the origin, but it knows nothing about this header, so a 3xx + // from the configured instance would hand the token to whatever + // host the Location header names. Redirects are therefore followed + // only while they stay on the configured origin. + CheckRedirect: sameOriginRedirects(parsedURL), }, headers: make(http.Header), } client.headers.Set("Accept", "application/json") client.headers.Set("User-Agent", "SafeLine-MCP/1.0") - client.headers.Set("X-SLCE-API-TOKEN", token) + client.headers.Set(tokenHeader, token) return client, nil } +// sameOriginRedirects returns the redirect policy of a client: a redirect is +// followed only while it stays on the scheme, host and port of the configured +// instance. Anything else fails the request instead of replaying the token. +func sameOriginRedirects(base *url.URL) func(*http.Request, []*http.Request) error { + return func(req *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return fmt.Errorf("stopped after %d redirects", maxRedirects) + } + if !sameOrigin(base, req.URL) { + return fmt.Errorf("refused to follow a redirect to %s: it would send the instance token to another origin", req.URL.Redacted()) + } + + return nil + } +} + +// sameOrigin reports whether target has the same scheme, host and port as base. +func sameOrigin(base, target *url.URL) bool { + return strings.EqualFold(base.Scheme, target.Scheme) && + strings.EqualFold(base.Hostname(), target.Hostname()) && + effectivePort(base) == effectivePort(target) +} + +// effectivePort returns the port a URL connects to, including the default port +// of its scheme. +func effectivePort(u *url.URL) string { + if port := u.Port(); port != "" { + return port + } + if strings.EqualFold(u.Scheme, "https") { + return "443" + } + + return "80" +} + func (c *Client) get(ctx context.Context, path string, query url.Values, result any) error { endpoint := *c.baseURL endpoint.Path = strings.TrimRight(endpoint.Path, "/") + path diff --git a/mcp_server/internal/api/client_redirect_test.go b/mcp_server/internal/api/client_redirect_test.go new file mode 100644 index 00000000..c537d11a --- /dev/null +++ b/mcp_server/internal/api/client_redirect_test.go @@ -0,0 +1,138 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" +) + +// The client sends the deployment token in a header of its own, while net/http +// only strips the standard Authorization header when a redirect leaves the +// origin. A 3xx answered by the configured instance would therefore hand the +// token to whatever host the Location header names. +func TestClientRefusesCrossOriginRedirect(t *testing.T) { + var tokenAtOtherOrigin atomic.Value + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenAtOtherOrigin.Store(r.Header.Get(tokenHeader)) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"err":null,"data":{}}`)) + })) + defer other.Close() + + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.URL+"/stolen", http.StatusFound) + })) + defer origin.Close() + + client, err := newClient(origin.URL, 5*time.Second, false, "deployment-token") + if err != nil { + t.Fatal(err) + } + + var result map[string]any + err = client.get(context.Background(), "/api/open/events", url.Values{}, &result) + if err == nil { + t.Fatal("a redirect to another origin has to fail the request") + } + if !strings.Contains(err.Error(), "refused to follow a redirect") { + t.Fatalf("unexpected error: %v", err) + } + if got := tokenAtOtherOrigin.Load(); got != nil { + t.Fatalf("the token reached another origin: %v", got) + } +} + +func TestClientFollowsSameOriginRedirect(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/open/events", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/moved", http.StatusFound) + }) + var tokenAfterRedirect atomic.Value + mux.HandleFunc("/moved", func(w http.ResponseWriter, r *http.Request) { + tokenAfterRedirect.Store(r.Header.Get(tokenHeader)) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"err":null,"data":{"ok":true}}`)) + }) + server := httptest.NewServer(mux) + defer server.Close() + + client, err := newClient(server.URL, 5*time.Second, false, "deployment-token") + if err != nil { + t.Fatal(err) + } + + var result map[string]any + if err := client.get(context.Background(), "/api/open/events", url.Values{}, &result); err != nil { + t.Fatalf("a same-origin redirect has to be followed: %v", err) + } + if got := tokenAfterRedirect.Load(); got != "deployment-token" { + t.Fatalf("the token was lost on the way: %v", got) + } +} + +func TestSameOrigin(t *testing.T) { + cases := []struct { + base, target string + want bool + }{ + {"https://safeline.example:9443", "https://safeline.example:9443/other", true}, + {"https://safeline.example:9443", "https://SAFELINE.example:9443/other", true}, + {"https://safeline.example", "https://safeline.example:443/other", true}, + {"http://safeline.example", "http://safeline.example:80/other", true}, + {"https://safeline.example", "http://safeline.example/other", false}, + {"https://safeline.example", "https://safeline.example:8443/other", false}, + {"https://safeline.example:9443", "https://evil.example:9443/other", false}, + {"https://safeline.example:9443", "https://safeline.example.evil.example:9443/other", false}, + } + + for _, c := range cases { + base, err := url.Parse(c.base) + if err != nil { + t.Fatal(err) + } + target, err := url.Parse(c.target) + if err != nil { + t.Fatal(err) + } + if got := sameOrigin(base, target); got != c.want { + t.Errorf("sameOrigin(%s, %s) = %v, want %v", c.base, c.target, got, c.want) + } + } +} + +// TestPlainClientLeaksTokenAcrossRedirects documents what the policy above +// prevents: with the default redirect handling of net/http the custom token +// header arrives at the other origin. +func TestPlainClientLeaksTokenAcrossRedirects(t *testing.T) { + var tokenAtOtherOrigin atomic.Value + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenAtOtherOrigin.Store(r.Header.Get(tokenHeader)) + w.Write([]byte(`{}`)) + })) + defer other.Close() + + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.URL+"/stolen", http.StatusFound) + })) + defer origin.Close() + + req, err := http.NewRequest(http.MethodGet, origin.URL, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set(tokenHeader, "deployment-token") + resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + + if got := tokenAtOtherOrigin.Load(); got != "deployment-token" { + t.Fatalf("the plain client was expected to leak the token, got %v", got) + } +} diff --git a/yanshi/src/common.hh b/yanshi/src/common.hh index 51ca0a1c..b31314f5 100644 --- a/yanshi/src/common.hh +++ b/yanshi/src/common.hh @@ -49,6 +49,13 @@ const long MAX_CODEPOINT = 0x10ffff; // defense in depth, because the automaton expansion performed per iteration // makes the cost of a large count grow without bound (CWE-770). const long MAX_REPEAT = 10000; +// Upper bound for the nesting depth of an expression tree. A line of adjacent +// concatenations is reduced one factor at a time into a left leaning chain, so +// the depth of the tree follows the length of the line the compiler is given. +// Every later pass over that tree recurses along its edges, and so does the +// code that frees it, which is why the depth is bounded while the tree is built +// (CWE-674). The bound is far above what a hand written rule reaches. +const long MAX_EXPR_DEPTH = 4096; extern long action_label_base, action_label, call_label_base, call_label, collapse_label_base, collapse_label; void bold(long fd = 1); diff --git a/yanshi/src/parser.y b/yanshi/src/parser.y index 33653a0a..52831cb5 100644 --- a/yanshi/src/parser.y +++ b/yanshi/src/parser.y @@ -87,6 +87,26 @@ int yylex(YYSTYPE* yylval, YYLTYPE* loc, Stmt*& res, long& errors, const Locatio return token; } +// The tree is walked, and freed, by code that recurses along its edges, so its +// depth is bounded while it is built. The node that would exceed the bound is +// reported and replaced by a leaf: the spine that was built so far is dropped +// with it instead of being carried on, which keeps the work and the memory the +// reduction needs proportional to the input. The report is made once per +// parse, because every crossing of the bound would otherwise print the line +// again. +#define BOUND_EXPR_DEPTH(x) \ + do { \ + if ((x)->nest > MAX_EXPR_DEPTH) { \ + if (!expr_depth_exceeded) { \ + expr_depth_exceeded = true; \ + FAIL(yyloc, "expression nesting depth exceeds the limit"); \ + } \ + delete (x); \ + (x) = new DotExpr; \ + (x)->loc = yyloc; \ + } \ + } while (0) + #define gen_repeat(x, inner, low, high) \ if (low < 0) { \ FAIL(yyloc, "negative"); \ @@ -97,7 +117,10 @@ int yylex(YYSTYPE* yylval, YYLTYPE* loc, Stmt*& res, long& errors, const Locatio if (low > MAX_REPEAT || (high != LONG_MAX && high > MAX_REPEAT)) { \ FAIL(yyloc, "repeat count exceeds limit"); \ } \ - x = new RepeatExpr(inner, low, high) + x = new RepeatExpr(inner, low, high); \ + BOUND_EXPR_DEPTH(x) + +bool expr_depth_exceeded = false; %} %% @@ -137,28 +160,28 @@ define_stmt: union_expr: intersect_expr { $$ = $1; } - | union_expr '|' intersect_expr { $$ = new UnionExpr($1, $3); $$->loc = yyloc; } + | union_expr '|' intersect_expr { $$ = new UnionExpr($1, $3); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } union_expr2: intersect_expr { $$ = $1; } - | union_expr2 '|' intersect_expr { $$ = new UnionExpr($1, $3); $$->loc = yyloc; } - | union_expr2 '\n' '|' intersect_expr { $$ = new UnionExpr($1, $4); $$->loc = yyloc; } + | union_expr2 '|' intersect_expr { $$ = new UnionExpr($1, $3); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } + | union_expr2 '\n' '|' intersect_expr { $$ = new UnionExpr($1, $4); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } intersect_expr: difference_expr { $$ = $1; } - | intersect_expr AMPERAMPER difference_expr { $$ = new IntersectExpr($1, $3); $$->loc = yyloc; } + | intersect_expr AMPERAMPER difference_expr { $$ = new IntersectExpr($1, $3); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } difference_expr: concat_expr { $$ = $1; } - | difference_expr '-' concat_expr { $$ = new DifferenceExpr($1, $3); $$->loc = yyloc; } + | difference_expr '-' concat_expr { $$ = new DifferenceExpr($1, $3); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } concat_expr: unop_expr { $$ = $1; } - | concat_expr unop_expr { $$ = new ConcatExpr($1, $2); $$->loc = yyloc; } + | concat_expr unop_expr { $$ = new ConcatExpr($1, $2); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } unop_expr: factor { $$ = $1; } - | '~' unop_expr { $$ = new ComplementExpr($2); $$->loc = yyloc; } + | '~' unop_expr { $$ = new ComplementExpr($2); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } factor: EPSILON { $$ = new EpsilonExpr; $$->loc = yyloc; } @@ -218,9 +241,9 @@ factor: | factor '%' INTEGER action { $$ = $1; $$->leaving.emplace_back($4, $3); } | factor '$' action { $$ = $1; $$->transiting.emplace_back($3, 0L); } | factor '$' INTEGER action { $$ = $1; $$->transiting.emplace_back($4, $3); } - | factor '+' { $$ = new PlusExpr($1); $$->loc = yyloc; } - | factor '?' { $$ = new QuestionExpr($1); $$->loc = yyloc; } - | factor '*' { $$ = new StarExpr($1); $$->loc = yyloc; } + | factor '+' { $$ = new PlusExpr($1); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } + | factor '?' { $$ = new QuestionExpr($1); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } + | factor '*' { $$ = new StarExpr($1); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } repeat: factor '{' INTEGER ',' INTEGER '}' { gen_repeat($$, $1, $3, $5); $$->loc = yyloc; } @@ -262,6 +285,7 @@ int parse(const LocationFile& locfile, Stmt*& res) raw_yylex_init_extra(0, &lexer); YY_BUFFER_STATE buf = raw_yy_scan_bytes(locfile.data.c_str(), locfile.data.size(), lexer); long errors = 0; + expr_depth_exceeded = false; yyparse(res, errors, locfile, &lexer); raw_yy_delete_buffer(buf, lexer); raw_yylex_destroy(lexer); diff --git a/yanshi/src/syntax.hh b/yanshi/src/syntax.hh index 99bf0099..3236d7f0 100644 --- a/yanshi/src/syntax.hh +++ b/yanshi/src/syntax.hh @@ -120,6 +120,7 @@ struct RefAction : Visitable { struct Expr : VisitableBase { Location loc; long pre, post, depth; // set by Compiler + long nest = 1; // set by the parser: number of nodes on the longest path down from here vector anc; // set by Compiler vector> entering, finishing, leaving, transiting; DefineStmt* stmt = NULL; // set by ModuleImportDef @@ -148,6 +149,21 @@ struct Expr : VisitableBase { } }; +// nest_of is the nesting depth of a subtree; a leaf is one node deep. +inline long nest_of(const Expr* expr) +{ + return expr->nest; +} + +// nest_above is the depth of the node that holds the given subtrees. +inline long nest_above(const Expr* lhs, const Expr* rhs = NULL) +{ + long n = nest_of(lhs); + if (rhs && nest_of(rhs) > n) + n = nest_of(rhs); + return n+1; +} + struct BracketExpr : Visitable { DisjointIntervals intervals; BracketExpr(DisjointIntervals* intervals) : intervals(std::move(*intervals)) { delete intervals; } @@ -167,7 +183,7 @@ struct CollapseExpr : Visitable { struct ComplementExpr : Visitable { Expr* inner; - ComplementExpr(Expr* inner) : inner(inner) {} + ComplementExpr(Expr* inner) : inner(inner) { nest = nest_above(inner); } ~ComplementExpr() { delete inner; } @@ -175,7 +191,7 @@ struct ComplementExpr : Visitable { struct ConcatExpr : Visitable { Expr *lhs, *rhs; - ConcatExpr(Expr* lhs, Expr* rhs) : lhs(lhs), rhs(rhs) {} + ConcatExpr(Expr* lhs, Expr* rhs) : lhs(lhs), rhs(rhs) { nest = nest_above(lhs, rhs); } ~ConcatExpr() { delete lhs; delete rhs; @@ -184,7 +200,7 @@ struct ConcatExpr : Visitable { struct DifferenceExpr : Visitable { Expr *lhs, *rhs; - DifferenceExpr(Expr* lhs, Expr* rhs) : lhs(lhs), rhs(rhs) {} + DifferenceExpr(Expr* lhs, Expr* rhs) : lhs(lhs), rhs(rhs) { nest = nest_above(lhs, rhs); } ~DifferenceExpr() { delete lhs; delete rhs; @@ -204,7 +220,7 @@ struct EpsilonExpr : Visitable {}; struct IntersectExpr : Visitable { Expr *lhs, *rhs; - IntersectExpr(Expr* lhs, Expr* rhs) : lhs(lhs), rhs(rhs) {} + IntersectExpr(Expr* lhs, Expr* rhs) : lhs(lhs), rhs(rhs) { nest = nest_above(lhs, rhs); } ~IntersectExpr() { delete lhs; delete rhs; @@ -218,7 +234,7 @@ struct LiteralExpr : Visitable { struct PlusExpr : Visitable { Expr* inner; - PlusExpr(Expr* inner) : inner(inner) {} + PlusExpr(Expr* inner) : inner(inner) { nest = nest_above(inner); } ~PlusExpr() { delete inner; } @@ -227,7 +243,7 @@ struct PlusExpr : Visitable { struct RepeatExpr : Visitable { Expr* inner; long low, high; - RepeatExpr(Expr* inner, long low, long high) : inner(inner), low(low), high(high) {} + RepeatExpr(Expr* inner, long low, long high) : inner(inner), low(low), high(high) { nest = nest_above(inner); } ~RepeatExpr() { delete inner; } @@ -235,7 +251,7 @@ struct RepeatExpr : Visitable { struct QuestionExpr : Visitable { Expr* inner; - QuestionExpr(Expr* inner) : inner(inner) {} + QuestionExpr(Expr* inner) : inner(inner) { nest = nest_above(inner); } ~QuestionExpr() { delete inner; } @@ -243,7 +259,7 @@ struct QuestionExpr : Visitable { struct StarExpr : Visitable { Expr* inner; - StarExpr(Expr* inner) : inner(inner) {} + StarExpr(Expr* inner) : inner(inner) { nest = nest_above(inner); } ~StarExpr() { delete inner; } @@ -251,7 +267,7 @@ struct StarExpr : Visitable { struct UnionExpr : Visitable { Expr *lhs, *rhs; - UnionExpr(Expr* lhs, Expr* rhs) : lhs(lhs), rhs(rhs) {} + UnionExpr(Expr* lhs, Expr* rhs) : lhs(lhs), rhs(rhs) { nest = nest_above(lhs, rhs); } ~UnionExpr() { delete lhs; delete rhs; From 173dcfea18b80bcf3cc70544cfdaf884c0fe90bf Mon Sep 17 00:00:00 2001 From: "jialong.wang" Date: Sun, 20 Sep 2026 12:16:15 +0800 Subject: [PATCH 25/31] fix(management): secure the control channel and bound the TFA bootstrap Two findings of the rescan are closed here. The control channel between the management server and tcontrollerd carried the whole site configuration of the installation in the clear, with the subscription token in the metadata as its only gate. Anything that shares the network of the containers can read that token and subscribe as tcontrollerd, which is a container-to-container privilege escalation rather than a remote issue: 169.254.0.4:9002 is not published to the host. Both ends are now issued certificates by a CA that only this installation knows, the server requires and verifies a client certificate, and the client verifies the server name, so reading the traffic is not enough to take either side. The subscription token stays as the second gate. `mgt -gen_certs` issues the credentials, the server ones stay in the resources directory of the management container, and the ones of tcontrollerd are published next to the token file, which is the directory both containers mount. The switch is `MGT_GRPC_TLS` / `TCD_MGT_TLS`: unset secures the channel as soon as the credentials are there, "1" refuses to serve or connect without them, and "0" keeps the old clear-text behaviour. A deployment whose containers are not upgraded together therefore keeps working, and one that is upgraded together stops being readable. The anonymous TFA bootstrap was open until somebody logged in, so an installation that was deployed and then forgotten was claimable by whoever reached the console port first, permanently. It is now bounded by a window (`MGT_BOOTSTRAP_WINDOW`, 30m by default) that only the setup of an installation and `mgt -reset_user admin` open: a restart does not open it again, and the anchor of an installation that is upgraded keeps the age of its account instead of being refreshed. A secret that is already bound to an address is still served to that address after the window closed, so an administrator cannot lock themselves out of their own authenticator. GetWebsiteServer also returns a server with the embedded unimplemented server set: the generated registration code checks that pointer, and registering a nil one panicked. Verified in a sandbox against the options the daemon serves with: the issued client reaches the subscription handler, a client without a certificate is refused with "tls: certificate required", one from another authority with "tls: unknown certificate authority", one that expects another server name fails the name check, a plain-text client gets EOF, and a wrong token is refused by the token gate. The bootstrap window is covered by probes over a real PostgreSQL, including the read-modify-write race that the conditional update removes. --- compose.yaml | 8 + .../tcontrollerd/controller/controller.go | 19 +- management/tcontrollerd/controller/creds.go | 93 +++++++++ management/tcontrollerd/pkg/config/config.go | 18 ++ management/webserver/api/auth.go | 91 +++++++++ management/webserver/cmd/gen_certs.go | 192 ++++++++++++++++++ management/webserver/cmd/reset_user.go | 11 + management/webserver/main.go | 16 +- management/webserver/model/option.go | 77 +++++++ management/webserver/pkg/constants/option.go | 7 + management/webserver/rpc/main.go | 32 ++- management/webserver/rpc/tls.go | 150 ++++++++++++++ management/webserver/rpc/website.go | 7 +- management/webserver/utils/cert.go | 91 +++++++++ 14 files changed, 798 insertions(+), 14 deletions(-) create mode 100644 management/tcontrollerd/controller/creds.go create mode 100644 management/webserver/rpc/tls.go diff --git a/compose.yaml b/compose.yaml index b1eb269a..08201681 100644 --- a/compose.yaml +++ b/compose.yaml @@ -55,6 +55,14 @@ services: # same value to the request as X-Bootstrap-Token, to keep the binding for # an administrator who has the token. - MGT_BOOTSTRAP_TOKEN=${MGT_BOOTSTRAP_TOKEN:-} + # How long the account may be bound without a credential, measured from + # the moment the installation was set up (or from `mgt -reset_user + # admin`, which opens it again). Defaults to 30m; set it to 0 to leave + # the bootstrap open until the first login, which is what older releases + # did. A console that is bound after the window closed answers with an + # error that names the command to reopen it, and never rejects a pending + # secret that is already bound to the address that asks for it. + - MGT_BOOTSTRAP_WINDOW=${MGT_BOOTSTRAP_WINDOW:-} depends_on: - postgres - fvm diff --git a/management/tcontrollerd/controller/controller.go b/management/tcontrollerd/controller/controller.go index b3796524..4445b7a4 100644 --- a/management/tcontrollerd/controller/controller.go +++ b/management/tcontrollerd/controller/controller.go @@ -39,9 +39,22 @@ func Handle() error { return err } - gRPCConn, err := grpc.Dial(config.GlobalConfig.MgtWebserver, []grpc.DialOption{ - grpc.WithTransportCredentials(insecure.NewCredentials()), - }...) + dialOptions := []grpc.DialOption{} + + // The token below proves that this is tcontrollerd, and the certificate + // proves that the peer is the management server of this installation: + // reading the traffic of the deployment is not enough to take either side. + creds, err := transportCreds() + if err != nil { + return err + } + if creds != nil { + dialOptions = append(dialOptions, grpc.WithTransportCredentials(creds)) + } else { + dialOptions = append(dialOptions, grpc.WithTransportCredentials(insecure.NewCredentials())) + } + + gRPCConn, err := grpc.Dial(config.GlobalConfig.MgtWebserver, dialOptions...) if err != nil { logger.Errorf("Fail to dial: %v", err) return err diff --git a/management/tcontrollerd/controller/creds.go b/management/tcontrollerd/controller/creds.go new file mode 100644 index 00000000..ba2c2ab1 --- /dev/null +++ b/management/tcontrollerd/controller/creds.go @@ -0,0 +1,93 @@ +package controller + +import ( + "crypto/tls" + "crypto/x509" + "os" + "path/filepath" + "strings" + + "google.golang.org/grpc/credentials" + + "chaitin.cn/dev/go/errors" + "chaitin.cn/patronus/safeline-2/management/tcontrollerd/pkg/config" +) + +const ( + // TLSEnv names the environment variable that decides how the control + // channel is reached. "1" requires TLS and refuses to start without it, + // "0" reaches the server in the clear, and anything else (including an + // unset value) uses TLS as soon as the management server published the + // credentials, which it does when its container runs `mgt -gen_certs` on + // start. + TLSEnv = "TCD_MGT_TLS" + + // ServerCertFile is the certificate the management server is expected to + // present, ClientCertFile and ClientKeyFile are the credentials issued for + // this side of the control channel, and CertAuthorityFile is the authority + // both of them are issued by. + ServerName = "waf-management-server" + ClientCertFile = "tcd_client.crt" + ClientKeyFile = "tcd_client.key" + CertAuthorityFile = "grpc_ca.crt" +) + +// transportCreds returns the credentials of the control channel. +// +// A nil result means the channel is reached in the clear. The management +// server serves it over TLS and only accepts a client certificate it issued, +// so a deployment in which the two containers were not upgraded together keeps +// working through the switch below, and every other deployment fails the +// handshake rather than subscribing without a certificate. +func transportCreds() (credentials.TransportCredentials, error) { + switch strings.ToLower(strings.TrimSpace(os.Getenv(TLSEnv))) { + case "0", "off", "false", "no": + logger.Warnf("%s is disabled: the control channel is reached in the clear, and the subscription token is readable by anything that shares the network of this container.", TLSEnv) + return nil, nil + } + + creds, err := loadTransportCreds() + if err != nil { + if required := strings.ToLower(strings.TrimSpace(os.Getenv(TLSEnv))); required == "1" || required == "on" || required == "true" || required == "yes" { + return nil, err + } + + logger.Warnf("Reaching the control channel in the clear: %s. Set %s=1 to make this an error.", err, TLSEnv) + return nil, nil + } + + return creds, nil +} + +// loadTransportCreds reads the credentials the management server published for +// this side of the control channel. +func loadTransportCreds() (credentials.TransportCredentials, error) { + dir := config.CertDir() + + certPath := filepath.Join(dir, ClientCertFile) + certificate, err := tls.LoadX509KeyPair(certPath, filepath.Join(dir, ClientKeyFile)) + if err != nil { + return nil, errors.Wrapf(err, "load %s", certPath) + } + + caPath := filepath.Join(dir, CertAuthorityFile) + content, err := os.ReadFile(caPath) + if err != nil { + return nil, errors.Wrapf(err, "read %s", caPath) + } + + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(content) { + return nil, errors.New("no certificate found in " + caPath) + } + + return credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{certificate}, + RootCAs: roots, + // The server is reached at an address of the deployment, which no + // certificate is issued for: the name it is issued for is fixed on + // both sides instead, and it is checked against the certificate. + ServerName: ServerName, + MinVersion: tls.VersionTLS12, + }), nil +} diff --git a/management/tcontrollerd/pkg/config/config.go b/management/tcontrollerd/pkg/config/config.go index ad4ca881..3647e69b 100644 --- a/management/tcontrollerd/pkg/config/config.go +++ b/management/tcontrollerd/pkg/config/config.go @@ -2,6 +2,8 @@ package config import ( "os" + "path/filepath" + "strings" "chaitin.cn/dev/go/settings" ) @@ -16,6 +18,22 @@ var ( // inside the tengine container, which is where tcontrollerd runs. const DefaultMgtTokenFile = "/app/sock/mgt_grpc_token" +// CertDirEnv names the environment variable that holds the directory of the +// credentials the management server published for this side of the control +// channel. The default is below the directory of the token file, which is the +// directory the standard deployment shares between the two containers. +const CertDirEnv = "TCD_MGT_CERT_DIR" + +// CertDir returns the directory the credentials of the control channel are +// read from. +func CertDir() string { + if v, ok := os.LookupEnv(CertDirEnv); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + + return filepath.Join(filepath.Dir(GlobalConfig.MgtTokenFile), "grpc") +} + func InitConfigs(configFilePath string) error { s, err := settings.New(configFilePath) if err != nil { diff --git a/management/webserver/api/auth.go b/management/webserver/api/auth.go index ed771750..9a53c02a 100644 --- a/management/webserver/api/auth.go +++ b/management/webserver/api/auth.go @@ -8,6 +8,7 @@ import ( "net/url" "os" "strconv" + "strings" "time" "github.com/gin-contrib/sessions" @@ -37,6 +38,22 @@ const BootstrapTokenEnv = "MGT_BOOTSTRAP_TOKEN" // BootstrapTokenEnv. const BootstrapTokenHeader = "X-Bootstrap-Token" +// BootstrapWindowEnv names the environment variable that holds how long the +// anonymous TFA bootstrap stays open after it was opened. It accepts a Go +// duration, and "0" or "off" leaves it open until the first login. +// +// The window is what keeps an installation that nobody ever bound from being +// claimable forever: the account can be bound without a credential while the +// window is open, and `mgt -reset_user admin` opens it again, which is what an +// operator does when they want to bind a console that was left alone. +const BootstrapWindowEnv = "MGT_BOOTSTRAP_WINDOW" + +// DefaultBootstrapWindow is how long the anonymous TFA bootstrap stays open +// when BootstrapWindowEnv is not set. It is long enough for the operator of an +// installation that is being set up to scan the code, and short enough that an +// installation which is left unbound does not stay open to the network. +const DefaultBootstrapWindow = 30 * time.Minute + const ( // bootstrapFailureThreshold is how many rejected bootstrap attempts a // client address may make before it has to wait. @@ -181,6 +198,65 @@ func PostLogout(c *gin.Context) { response.Success(c, nil) } +// checkBootstrapWindow returns an error when the window in which the account +// may be bound without a credential has closed. +// +// A passcode is the only credential of the account, so the secret that the +// caller of GET /api/OTPUrl receives is the account. Leaving the endpoint open +// until somebody happens to log in means an installation that is deployed and +// then forgotten can be claimed by whoever reaches the console port first; the +// window bounds that to the time around the setup, and every console that is +// bound afterwards reaches this endpoint only to be told to run +// `mgt -reset_user`. +func checkBootstrapWindow() error { + window := BootstrapWindow() + if window <= 0 { + return nil + } + + openedAt, ok := model.BootstrapOpenedAt() + if !ok { + // The record is written when the installation is set up, so a missing + // one means this database was not written by this release. The account + // is not left bound to nobody for that reason: the window starts being + // measured from the moment the account was created. + var user model.User + database.GetDB().Where(&model.User{Username: constants.SuperUser}).First(&user) + if openedAt, ok = user.CreatedAt, !user.CreatedAt.IsZero(); !ok { + openedAt = time.Now() + } + + logger.Warnf("No record of when the TFA bootstrap was opened, measuring the window of %s from %s", window, openedAt.Format(time.RFC3339)) + } + + if elapsed := time.Since(openedAt); elapsed > window { + return fmt.Errorf("the anonymous TFA bootstrap closed %s after it was opened at %s", window, openedAt.Format(time.RFC3339)) + } + + return nil +} + +// BootstrapWindow returns how long the anonymous TFA bootstrap stays open, and +// zero when it stays open until the first login. +func BootstrapWindow() time.Duration { + value := strings.TrimSpace(os.Getenv(BootstrapWindowEnv)) + if value == "" { + return DefaultBootstrapWindow + } + + if strings.EqualFold(value, "off") { + return 0 + } + + window, err := time.ParseDuration(value) + if err != nil || window < 0 { + logger.Warnf("Ignoring %s=%q: it is not a duration, keeping the window of %s", BootstrapWindowEnv, value, DefaultBootstrapWindow) + return DefaultBootstrapWindow + } + + return window +} + func GetOTPUrl(c *gin.Context) { db := database.GetDB() @@ -223,6 +299,15 @@ func GetOTPUrl(c *gin.Context) { // An installation that already carries a pending secret from a // release that did not record the address keeps working: the first // caller adopts it, exactly as it did before. + // + // The window applies here as well, because adopting the pending + // secret is the same thing as being handed one. + if err := checkBootstrapWindow(); err != nil { + logger.Warnf("Refused to hand out the pending TFA secret to %s: %s", clientIP, err) + response.Error(c, response.JSONBody{Err: response.ErrLoginRequired, Msg: "TFA bootstrap is not allowed for this request"}, http.StatusForbidden) + return + } + adopted, err := adoptBootstrapSecret(db, user.TFASecret, clientIP) if err != nil { logger.Error(err) @@ -243,6 +328,12 @@ func GetOTPUrl(c *gin.Context) { return } + if err := checkBootstrapWindow(); err != nil { + logger.Warnf("Refused to issue a TFA secret to %s: %s; run 'mgt -reset_user admin' to open the bootstrap again", clientIP, err) + response.Error(c, response.JSONBody{Err: response.ErrLoginRequired, Msg: "The TFA bootstrap of this installation is closed, run 'mgt -reset_user admin' to bind a new authenticator"}, http.StatusForbidden) + return + } + otpKey, err := totp.Generate(OtpOpts) if err != nil { logger.Error(err) diff --git a/management/webserver/cmd/gen_certs.go b/management/webserver/cmd/gen_certs.go index 509e6e44..489d11fc 100644 --- a/management/webserver/cmd/gen_certs.go +++ b/management/webserver/cmd/gen_certs.go @@ -1,11 +1,16 @@ package cmd import ( + "crypto/x509" "crypto/x509/pkix" + "encoding/pem" + "os" "path/filepath" + "time" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/config" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/constants" + "chaitin.cn/patronus/safeline-2/management/webserver/rpc" "chaitin.cn/patronus/safeline-2/management/webserver/utils" ) @@ -16,9 +21,196 @@ func GenCerts() error { if err := genClientCACert(); err != nil { return err } + if err := genControlChannelCerts(); err != nil { + return err + } return nil } +// genControlChannelCerts issues the credentials of the control channel between +// the management server and tcontrollerd. +// +// The channel carries the complete site configuration of the installation, and +// it used to be served in the clear with a token in the metadata as its only +// gate: the token is readable by anything that shares the network of the +// containers, and a caller that reads it can subscribe as tcontrollerd. Both +// ends are therefore issued by a CA that only this installation knows, and each +// end checks the other against it. +// +// The server credentials stay in the resources directory, which only the +// management container mounts. The credentials of tcontrollerd are published in +// the directory both containers mount, next to the token file, because +// tcontrollerd runs in the tengine container. +func genControlChannelCerts() error { + caCert, caKey, err := controlChannelCA() + if err != nil { + return err + } + + // The name is fixed on both sides: tcontrollerd dials an address of the + // deployment, which no certificate can be issued for. The addresses of the + // standard deployment are listed as well, so that a client which verifies + // the address instead of the name still works. + hostnames := []string{rpc.ServerName, "mgt", "safeline-mgt", "localhost"} + + if err := ensureSignedCert( + filepath.Join(rpc.CertDir(), rpc.ServerCertFile), + filepath.Join(rpc.CertDir(), rpc.ServerKeyFile), + caCert, + caKey, + hostnames, + &pkix.Name{ + Country: []string{"CN"}, + Province: []string{"Beijing"}, + Locality: []string{"Beijing"}, + Organization: []string{"Beijing WAF Technology Co., Ltd."}, + OrganizationalUnit: []string{"Service Infrastructure Department"}, + CommonName: rpc.ServerName, + }, + false, + ); err != nil { + return err + } + + clientDir := rpc.ClientCertDir() + + if err := ensureSignedCert( + filepath.Join(clientDir, rpc.ClientCertFile), + filepath.Join(clientDir, rpc.ClientKeyFile), + caCert, + caKey, + []string{}, + &pkix.Name{ + Country: []string{"CN"}, + Province: []string{"Beijing"}, + Locality: []string{"Beijing"}, + Organization: []string{"Beijing WAF Technology Co., Ltd."}, + OrganizationalUnit: []string{"Service Infrastructure Department"}, + CommonName: "tcontrollerd", + }, + true, + ); err != nil { + return err + } + + // tcontrollerd verifies the server with the CA, so it needs a copy. A + // certificate is public, unlike the key that never leaves the management + // container. + return utils.EnsureRenameWriteFile(filepath.Join(clientDir, rpc.CertAuthorityFile), caCert, 0644) +} + +// controlChannelCA reads the certificate authority of the control channel, +// creating it when it is missing. +func controlChannelCA() ([]byte, []byte, error) { + certPath := filepath.Join(rpc.CertDir(), rpc.CertAuthorityFile) + keyPath := filepath.Join(rpc.CertDir(), "grpc_ca.key") + + if err := utils.WriteCertIfNotExist( + certPath, + keyPath, + func() ([]byte, []byte, error) { + return utils.GenerateCert( + []string{}, + 3650, + 4096, + &pkix.Name{ + Country: []string{"CN"}, + Province: []string{"Beijing"}, + Locality: []string{"Beijing"}, + Organization: []string{"Beijing WAF Technology Co., Ltd."}, + OrganizationalUnit: []string{"Service Infrastructure Department"}, + CommonName: "WAF Control Channel Certificate Authority", + }, + true, + ) + }); err != nil { + return nil, nil, err + } + + cert, err := os.ReadFile(certPath) + if err != nil { + return nil, nil, err + } + + key, err := os.ReadFile(keyPath) + if err != nil { + return nil, nil, err + } + + return cert, key, nil +} + +// ensureSignedCert writes a certificate signed by the CA of the control +// channel, computing it again when the one on disk does not belong to that CA. +// +// Both ends check the other against the CA, so a certificate that was issued by +// an older CA, or that is about to expire, only shows up as a handshake that +// fails for no visible reason. It is replaced here instead; the key of the +// management server credentials is kept to the owner, the one of tcontrollerd +// is read by the tengine container and is kept to the owner as well. +func ensureSignedCert(certPath, keyPath string, caCert, caKey []byte, hostnames []string, subject *pkix.Name, client bool) error { + reusable, err := signedByAuthority(certPath, caCert) + if err != nil { + return err + } + + if reusable { + if _, err := os.Stat(keyPath); err == nil { + return nil + } + } + + cert, key, err := utils.GenerateSignedCert(caCert, caKey, hostnames, 3650, 4096, subject, client) + if err != nil { + return err + } + + if err := utils.EnsureRenameWriteFile(certPath, cert, 0644); err != nil { + return err + } + + return utils.EnsureRenameWriteFile(keyPath, key, 0600) +} + +// signedByAuthority reports whether the certificate at path is signed by the +// given authority and is not about to expire. +func signedByAuthority(path string, caCertPEM []byte) (bool, error) { + content, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + + return false, err + } + + block, _ := pem.Decode(content) + if block == nil { + return false, nil + } + + certificate, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return false, nil + } + + caBlock, _ := pem.Decode(caCertPEM) + if caBlock == nil { + return false, nil + } + + authority, err := x509.ParseCertificate(caBlock.Bytes) + if err != nil { + return false, nil + } + + if err := certificate.CheckSignatureFrom(authority); err != nil { + return false, nil + } + + return time.Now().Add(24 * time.Hour).Before(certificate.NotAfter), nil +} + func genServerCert() error { certPath := filepath.Join(config.GlobalConfig.MgtResDir, constants.CertsPath, "server.crt") keyPath := filepath.Join(config.GlobalConfig.MgtResDir, constants.CertsPath, "server.key") diff --git a/management/webserver/cmd/reset_user.go b/management/webserver/cmd/reset_user.go index cad81f1e..5519136f 100644 --- a/management/webserver/cmd/reset_user.go +++ b/management/webserver/cmd/reset_user.go @@ -3,8 +3,11 @@ package cmd import ( "chaitin.cn/patronus/safeline-2/management/webserver/model" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/database" + "chaitin.cn/patronus/safeline-2/management/webserver/pkg/log" ) +var logger = log.GetLogger("cmd") + // ResetUser clears the credentials of a user so that the next visit of the // console binds a new TFA secret. // @@ -12,6 +15,10 @@ import ( // a secret that was handed to somebody else cannot be bound by the // administrator, and starting the bootstrap over from the shell is the only // way out of that state. +// +// It also opens the anonymous bootstrap window again, because this command is +// how an operator asks for it: the account is unbound at the end of the call, +// and the caller is at the console now. func ResetUser(username string) { db := database.GetDB() var user model.User @@ -21,4 +28,8 @@ func ResetUser(username string) { user.TFABootstrapIP = "" user.TFALastUsedStep = 0 db.Save(&user) + + if err := model.OpenBootstrapWindow(); err != nil { + logger.Error(err) + } } diff --git a/management/webserver/main.go b/management/webserver/main.go index 034ec710..e6758c62 100644 --- a/management/webserver/main.go +++ b/management/webserver/main.go @@ -157,12 +157,18 @@ func main() { // The bootstrap token is the only gate in front of the TFA secret of the // account. It is optional so that a fresh installation can be bound from - // the console, but the default is worth announcing: with an empty value - // the first caller that reaches the API is the one the account gets bound - // to. + // the console, and what limits the exposure when it is empty is the window + // the account may be bound in: announce both. if os.Getenv(api.BootstrapTokenEnv) == "" { - logger.Warnf("%s is not set: the TFA secret of the account is handed to whoever reaches GET %s%s first. Set it to require the %s header on that request.", - api.BootstrapTokenEnv, "/api", api.OTPUrl, api.BootstrapTokenHeader) + logger.Warnf("%s is not set: the TFA secret of the account is handed to whoever reaches GET %s%s first, for %s after the bootstrap was opened. Set it to require the %s header on that request.", + api.BootstrapTokenEnv, "/api", api.OTPUrl, api.BootstrapWindow(), api.BootstrapTokenHeader) + } + if window := api.BootstrapWindow(); window <= 0 { + logger.Warnf("%s is off: the account can be bound from the network until somebody logs in. A console that is left unbound stays claimable.", + api.BootstrapWindowEnv) + } else if openedAt, ok := model.BootstrapOpenedAt(); ok { + logger.Infof("The TFA bootstrap of the account is open until %s (%s after it was opened at %s); run 'mgt -reset_user admin' to open it again.", + openedAt.Add(window).Format(time.RFC3339), window, openedAt.Format(time.RFC3339)) } publicRouters := r.Group("/api") diff --git a/management/webserver/model/option.go b/management/webserver/model/option.go index 7dcd4f8d..88ee80d7 100644 --- a/management/webserver/model/option.go +++ b/management/webserver/model/option.go @@ -5,6 +5,9 @@ import ( "encoding/json" "net/http" "regexp" + "strconv" + "strings" + "time" "gorm.io/gorm/clause" @@ -30,6 +33,14 @@ func initOptions() error { secretKey := Options{Key: constants.SecretKey, Value: utils.RandHex(32)} db.Clauses(clause.OnConflict{DoNothing: true}).Create(&secretKey) + // The account exists but has never been bound, so the anonymous TFA + // bootstrap is open from now on: an installation that is left unbound has + // to stop being claimable by whoever reaches the console first, and this + // record is what that window is measured from. + if err := OpenBootstrapWindowIfMissing(); err != nil { + return err + } + machineId := Options{Key: constants.MachineID, Value: utils.RandStr(32)} _ = db.Clauses(clause.OnConflict{DoNothing: true}).Create(&machineId) go NotifyInstallation(machineId.Value) @@ -69,6 +80,72 @@ func rotatePredictableSecretKey() error { return db.Save(&secretKey).Error } +// OpenBootstrapWindow records now as the moment the anonymous TFA bootstrap of +// the account was opened. +// +// This is deliberately not tied to the start of the process: a deployment that +// is restarted, upgraded or power cycled would otherwise open the window again +// on every boot, and the window is what keeps an installation that nobody ever +// bound from being claimable forever. Only the setup of an installation and +// `mgt -reset_user admin` move it. +func OpenBootstrapWindow() error { + return setOption(constants.BootstrapOpenedAt, strconv.FormatInt(time.Now().Unix(), 10)) +} + +// BootstrapOpenedAt returns the moment the anonymous TFA bootstrap was opened +// and whether a record exists. +func BootstrapOpenedAt() (time.Time, bool) { + db := database.GetDB() + + var option Options + if err := db.Where(&Options{Key: constants.BootstrapOpenedAt}).First(&option).Error; err != nil { + return time.Time{}, false + } + + seconds, err := strconv.ParseInt(strings.TrimSpace(option.Value), 10, 64) + if err != nil { + return time.Time{}, false + } + + return time.Unix(seconds, 0), true +} + +// OpenBootstrapWindowIfMissing records the moment the anonymous TFA bootstrap +// was opened, and leaves the record of an installation that already has one +// alone. It is called when the daemon starts, which is why a restart does not +// open the window again. +// +// A fresh installation has just created the account, so the window starts now. +// An installation that is being upgraded keeps the age of its account instead: +// a console that nobody bound in months must not become claimable again because +// an upgrade restarted the daemon, and `mgt -reset_user admin` is what opens +// the window when an operator does want to bind it. +func OpenBootstrapWindowIfMissing() error { + db := database.GetDB() + + openedAt := time.Now() + + var user User + if err := db.Where(&User{Username: constants.SuperUser}).First(&user).Error; err == nil && !user.CreatedAt.IsZero() { + openedAt = user.CreatedAt + } + + opened := Options{Key: constants.BootstrapOpenedAt, Value: strconv.FormatInt(openedAt.Unix(), 10)} + + // A restart must not open the window again, so an existing record wins. + return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&opened).Error +} + +// setOption stores value under key, replacing what is there. +func setOption(key, value string) error { + db := database.GetDB() + + return db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "key"}}, + DoUpdates: clause.AssignmentColumns([]string{"value"}), + }).Create(&Options{Key: key, Value: value}).Error +} + func NotifyInstallation(machineId string) { logger.Info("Notify installation") tr := pkg.TelemetryRequest{ diff --git a/management/webserver/pkg/constants/option.go b/management/webserver/pkg/constants/option.go index e959897c..876d9bdf 100644 --- a/management/webserver/pkg/constants/option.go +++ b/management/webserver/pkg/constants/option.go @@ -5,4 +5,11 @@ const ( MachineID = "machine_id" PolicyGroupGlobal = "policy_group_global" SrcIPConfig = "src_ip_config" + + // BootstrapOpenedAt records when the anonymous TFA bootstrap of the + // account was opened, which is what bounds how long a caller may claim the + // TFA secret without a credential (see api.GetOTPUrl). It is written when + // the installation is set up and by `mgt -reset_user`, and never by a + // restart. + BootstrapOpenedAt = "tfa_bootstrap_opened_at" ) diff --git a/management/webserver/rpc/main.go b/management/webserver/rpc/main.go index b20c4137..14fa99e0 100644 --- a/management/webserver/rpc/main.go +++ b/management/webserver/rpc/main.go @@ -26,6 +26,29 @@ const ( var logger = log.GetLogger("grpc") +// ServerOptions returns the options the control channel is served with. +// +// The channel carries the complete site configuration of the installation and +// republishes it on every change, so a subscription has to prove that it is +// tcontrollerd: the client certificate proves that the caller holds a +// credential of this installation, and the token is the second gate, which is +// what keeps a caller that reaches the port without one out. +func ServerOptions() ([]grpc.ServerOption, error) { + opts := []grpc.ServerOption{ + grpc.StreamInterceptor(subscribeAuthInterceptor), + } + + creds, err := ServerCreds() + if err != nil { + return nil, err + } + if creds != nil { + opts = append(opts, creds) + } + + return opts, nil +} + func StartGRPCSever() error { lis, err := net.Listen("tcp", config.GlobalConfig.GPRC.ListenAddr) if err != nil { @@ -38,12 +61,11 @@ func StartGRPCSever() error { logger.Errorf("Failed to init the control channel token: %s", err) } - opts := []grpc.ServerOption{ - // The control channel carries the complete site configuration of the - // installation and republishes it on every change, so a subscription - // has to prove that it is tcontrollerd. - grpc.StreamInterceptor(subscribeAuthInterceptor), + opts, err := ServerOptions() + if err != nil { + return err } + grpcServer := grpc.NewServer(opts...) pb.RegisterWebsiteServer(grpcServer, GetWebsiteServer()) go func() { diff --git a/management/webserver/rpc/tls.go b/management/webserver/rpc/tls.go new file mode 100644 index 00000000..7094db12 --- /dev/null +++ b/management/webserver/rpc/tls.go @@ -0,0 +1,150 @@ +package rpc + +import ( + "crypto/tls" + "crypto/x509" + "os" + "path/filepath" + "strings" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + + "chaitin.cn/dev/go/errors" + "chaitin.cn/patronus/safeline-2/management/webserver/pkg/config" + "chaitin.cn/patronus/safeline-2/management/webserver/pkg/constants" +) + +const ( + // TLSEnv names the environment variable that decides how the control + // channel is secured. "1" requires TLS and refuses to start the channel + // without it, "0" serves the channel in the clear, and anything else + // (including an unset value) secures it as soon as the credentials of the + // installation are there, which is the case of every deployment whose + // container runs `mgt -gen_certs` on start. + TLSEnv = "MGT_GRPC_TLS" + + // CertDirEnv names the environment variable that holds the directory of + // the server credentials. + CertDirEnv = "MGT_GRPC_CERT_DIR" + + // ClientCertDirEnv names the environment variable that holds the directory + // the credentials of tcontrollerd are published in. + ClientCertDirEnv = "MGT_GRPC_CLIENT_CERT_DIR" + + // ServerCertFile, ServerKeyFile and CertAuthorityFile are the names of the + // server credentials inside CertDir(). + ServerCertFile = "grpc_server.crt" + ServerKeyFile = "grpc_server.key" + CertAuthorityFile = "grpc_ca.crt" + + // ClientCertFile and ClientKeyFile are the names of the credentials of + // tcontrollerd inside ClientCertDir(). + ClientCertFile = "tcd_client.crt" + ClientKeyFile = "tcd_client.key" + + // ServerName is the name the server certificate is issued for. + // + // tcontrollerd reaches the management server at an address of the + // deployment (169.254.0.4:9002 in the standard compose file), which no + // certificate can be issued for. The name is therefore fixed on both sides + // and checked against the certificate, which is what makes the peer + // verifiable: the address may change, the name may not. + ServerName = "waf-management-server" +) + +// CertDir returns the directory the server credentials of the control channel +// are stored in. +func CertDir() string { + if dir := strings.TrimSpace(os.Getenv(CertDirEnv)); dir != "" { + return dir + } + + return filepath.Join(config.GlobalConfig.MgtResDir, constants.CertsPath) +} + +// ClientCertDir returns the directory the credentials of tcontrollerd are +// published in. +// +// It is a directory below the one both containers mount, because the tengine +// container is where tcontrollerd runs and it does not mount the resources +// directory of the management container. +func ClientCertDir() string { + if dir := strings.TrimSpace(os.Getenv(ClientCertDirEnv)); dir != "" { + return dir + } + + return filepath.Join(filepath.Dir(TokenFileFromEnv()), "grpc") +} + +// ServerCreds returns the transport credentials of the control channel. +// +// A nil option means the channel is served in the clear: either the deployment +// asked for that, or the credentials of the installation are not there, which +// is what a management container that was started without `-gen_certs` looks +// like. A deployment that wants to be sure it is not served in the clear sets +// TLSEnv=1, and then a missing credential is an error instead. +func ServerCreds() (grpc.ServerOption, error) { + switch strings.ToLower(strings.TrimSpace(os.Getenv(TLSEnv))) { + case "0", "off", "false", "no": + logger.Warnf("%s is disabled: the control channel is served in the clear, and the subscription token is the only gate in front of it. Anything that shares the network of this container can read it.", TLSEnv) + return nil, nil + } + + creds, err := loadServerCreds() + if err != nil { + if required := strings.ToLower(strings.TrimSpace(os.Getenv(TLSEnv))); required == "1" || required == "on" || required == "true" || required == "yes" { + return nil, err + } + + logger.Warnf("Serving the control channel in the clear: %s. Set %s=1 to make this an error.", err, TLSEnv) + return nil, nil + } + + logger.Infof("The control channel is served over TLS and only accepts a client certificate issued by %s", filepath.Join(CertDir(), CertAuthorityFile)) + + return grpc.Creds(creds), nil +} + +// loadServerCreds reads the server credentials and builds the TLS configuration +// of the control channel from them. +func loadServerCreds() (credentials.TransportCredentials, error) { + certPath := filepath.Join(CertDir(), ServerCertFile) + keyPath := filepath.Join(CertDir(), ServerKeyFile) + + certificate, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, errors.Wrapf(err, "load %s", certPath) + } + + clientCAs, err := certPool(filepath.Join(CertDir(), CertAuthorityFile)) + if err != nil { + return nil, err + } + + return credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{certificate}, + // The subscription token already keeps a caller that only reaches the + // port out, but it travels over this connection: without a client + // certificate, anyone who can read the traffic of the deployment can + // replay it and subscribe as tcontrollerd. + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: clientCAs, + MinVersion: tls.VersionTLS12, + }), nil +} + +// certPool reads a PEM encoded certificate authority. +func certPool(path string) (*x509.CertPool, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, errors.Wrapf(err, "read %s", path) + } + + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(content) { + return nil, errors.New("no certificate found in " + path) + } + + return pool, nil +} diff --git a/management/webserver/rpc/website.go b/management/webserver/rpc/website.go index 33e05292..ddb8de95 100644 --- a/management/webserver/rpc/website.go +++ b/management/webserver/rpc/website.go @@ -184,7 +184,12 @@ type WebsiteServer struct { } func GetWebsiteServer() *WebsiteServer { - return &WebsiteServer{} + // UnimplementedWebsiteServer is embedded through a pointer, and the code + // that registers a service checks that the pointer is not nil: the check + // is there because a nil pointer would only be noticed when a method that + // this server does not implement is called. It has to be set here, or the + // registration panics as soon as the generated code carries that check. + return &WebsiteServer{UnimplementedWebsiteServer: &pb.UnimplementedWebsiteServer{}} } func publishFullWebsite() error { diff --git a/management/webserver/utils/cert.go b/management/webserver/utils/cert.go index cfe86106..8b38be1b 100644 --- a/management/webserver/utils/cert.go +++ b/management/webserver/utils/cert.go @@ -11,6 +11,8 @@ import ( "math/big" "os" "time" + + "chaitin.cn/dev/go/errors" ) func GenerateCert(hostnames []string, days int64, keyBits int, subject *pkix.Name, isCA bool) ([]byte, []byte, error) { @@ -117,3 +119,92 @@ func genCertIfNotExist(certFilePath, keyFilePath string, generator func() ([]byt return cert, key, nil } } + +// GenerateSignedCert issues a certificate for hostnames that is signed by the +// certificate authority that certPEM and keyPEM belong to. +// +// GenerateCert produces a self signed certificate, which is what the console +// uses: the browser is told to accept it. The control channel cannot work that +// way, because both ends have to recognise each other without trusting the +// network they talk over, so the management server issues the credentials of +// both ends from a CA that only this installation knows. +func GenerateSignedCert(certPEM, keyPEM []byte, hostnames []string, days int64, keyBits int, subject *pkix.Name, client bool) ([]byte, []byte, error) { + caCert, caKey, err := parseCertAuthority(certPEM, keyPEM) + if err != nil { + return nil, nil, err + } + + privateKey, err := rsa.GenerateKey(rand.Reader, keyBits) + if err != nil { + return nil, nil, err + } + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return nil, nil, err + } + + extKeyUsage := []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth} + keyUsage := x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment + if client { + extKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth} + } + + notBefore := time.Now() + template := x509.Certificate{ + SerialNumber: serialNumber, + NotBefore: notBefore, + NotAfter: notBefore.Add(time.Duration(days) * 24 * time.Hour), + DNSNames: hostnames, + BasicConstraintsValid: true, + IsCA: false, + ExtKeyUsage: extKeyUsage, + KeyUsage: keyUsage, + } + if subject != nil { + template.Subject = *subject + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, caCert, &privateKey.PublicKey, caKey) + if err != nil { + return nil, nil, err + } + + var certBuffer bytes.Buffer + if err = pem.Encode(&certBuffer, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { + return nil, nil, err + } + + var keyBuffer bytes.Buffer + if err = pem.Encode(&keyBuffer, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}); err != nil { + return nil, nil, err + } + + return certBuffer.Bytes(), keyBuffer.Bytes(), nil +} + +// parseCertAuthority reads the certificate and the private key of a CA. +func parseCertAuthority(certPEM, keyPEM []byte) (*x509.Certificate, *rsa.PrivateKey, error) { + certBlock, _ := pem.Decode(certPEM) + if certBlock == nil { + return nil, nil, errors.New("the certificate authority is not PEM encoded") + } + + caCert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return nil, nil, err + } + + keyBlock, _ := pem.Decode(keyPEM) + if keyBlock == nil { + return nil, nil, errors.New("the private key of the certificate authority is not PEM encoded") + } + + caKey, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + if err != nil { + return nil, nil, err + } + + return caCert, caKey, nil +} From 76a49f71570cf22824d1d07df7c7833a6f28bfe9 Mon Sep 17 00:00:00 2001 From: "jialong.wang" Date: Sun, 20 Sep 2026 12:41:51 +0800 Subject: [PATCH 26/31] fix(management,mcp-server): attribute control channel answers and bound the outbound calls Five findings of the rescan are closed here. Publish exchanged a request and its answer over a single slot that carried no request id, so two overlapping callers could each read the answer of the other: one committed a database change that tcontrollerd refused, the other rolled back one it applied. The Subscribe full push held that slot for up to WaitRspTimeout, which made the overlap the normal case rather than the corner one. The exchange is now serialised, the slot is emptied before a request is sent, a request that times out drops the stream instead of leaving an answer that the next caller would read as its own, and the loop that reads the answers no longer blocks on a full slot, where it also stopped reading the keepalive pongs. Adding an id to the protocol was not necessary: with one exchange in flight, and a stream that is torn down as soon as one is left unanswered, the answer in the slot can only belong to the request that is waiting. publishFullWebsite ignored the error of the query behind the full push, and a failed query leaves an empty list, which tcontrollerd applies by deleting every nginx configuration of the installation. The error now stops the push, the payload is always an array, and tcontrollerd refuses a payload that carries no list at all and keeps the configuration it has. The shared HTTP client had no deadline of any kind, and two of the endpoints that use it are reachable without a credential: an upstream that accepts the connection and stops answering held the handler goroutine and its socket for as long as the connection lived, and any caller could repeat that. Every phase of a request is now bounded. The FSL string escaper wrote "\x%x", which is not a fixed width: a control byte followed by a hex digit was read by the lexer as one escape, so the source that was handed to the rule compiler differed from the string the management server meant to write. The MCP client could only trust a SafeLine instance by switching verification off, because the console serves the certificate it generated for itself. A ca_file per instance now keeps verification on, and a configuration that sets both is refused. The check that refuses insecure_skip_verify for a public host is deliberately not implemented: it cannot be decided from a host name without resolving it, and a resolver-based guess would break deployments whose address is public while the instance is not. Verified with probes over a real PostgreSQL and a scripted control channel peer: two overlapping writes now keep one exchange in flight where the previous code had two, an answer left behind by a request that gave up is no longer read as the answer of the next one, a silent device drops the stream after the timeout, the answer loop reads past a full slot, a failed query stops the full push, a payload without a list is refused while an empty list is applied, a silent upstream ends the request after the response header timeout where the client without deadlines never returned, and every escaped byte is two digits wide where the previous form turned "\x05a" into "\x5a". The credential, TFA bootstrap and control channel probes of the previous round still pass. --- management/tcontrollerd/controller/website.go | 11 +++ management/webserver/pkg/fvm/fsl/quote.go | 6 +- management/webserver/rpc/website.go | 74 ++++++++++++++++- management/webserver/utils/httpclient.go | 35 +++++++- mcp_server/README.md | 13 ++- mcp_server/config.yaml | 6 ++ mcp_server/internal/api/client.go | 44 ++++++++++- .../internal/api/client_redirect_test.go | 4 +- mcp_server/internal/api/client_tls_test.go | 79 +++++++++++++++++++ mcp_server/internal/api/service.go | 2 +- mcp_server/internal/config/config.go | 15 ++++ mcp_server/internal/config/config_test.go | 61 ++++++++++++++ 12 files changed, 338 insertions(+), 12 deletions(-) create mode 100644 mcp_server/internal/api/client_tls_test.go diff --git a/management/tcontrollerd/controller/website.go b/management/tcontrollerd/controller/website.go index 8eaf52ac..694aef5d 100644 --- a/management/tcontrollerd/controller/website.go +++ b/management/tcontrollerd/controller/website.go @@ -12,6 +12,7 @@ import ( "path/filepath" "strings" + "chaitin.cn/dev/go/errors" "chaitin.cn/patronus/safeline-2/management/tcontrollerd/model" "chaitin.cn/patronus/safeline-2/management/tcontrollerd/pkg/ngcmd" pb "chaitin.cn/patronus/safeline-2/management/tcontrollerd/proto/website" @@ -132,6 +133,16 @@ func generateFullConfigAndReload(msg []byte) error { return err } + // "null" is what a query that returned nothing looks like on the wire, and + // a full push that carries it would delete every configuration of the + // installation. The management server always sends an array, so a payload + // that is not a list is refused and the current configuration is kept: an + // installation that really has no site sends an empty array, which is + // applied. + if websites == nil { + return errors.New("the full push does not carry a list of sites, keeping the current configuration") + } + if err := utils.EnsureDir(nginxConfigPath); err != nil { return err } diff --git a/management/webserver/pkg/fvm/fsl/quote.go b/management/webserver/pkg/fvm/fsl/quote.go index fdfea494..d4ed9079 100644 --- a/management/webserver/pkg/fvm/fsl/quote.go +++ b/management/webserver/pkg/fvm/fsl/quote.go @@ -28,7 +28,11 @@ func appendEscapedByte(buf []byte, b byte) []byte { case '\\': return append(buf, `\\`...) default: - return append(buf, fmt.Sprintf("\\x%x", b)...) + // The escape has to be two digits wide: "\x5" followed by a literal + // "a" is read by the lexer as "\x5a" ("Z"), so a rule whose comment or + // header value ends with a control byte would silently change the + // source that is handed to the compiler. + return append(buf, fmt.Sprintf("\\x%02x", b)...) } } diff --git a/management/webserver/rpc/website.go b/management/webserver/rpc/website.go index ddb8de95..932db6be 100644 --- a/management/webserver/rpc/website.go +++ b/management/webserver/rpc/website.go @@ -31,6 +31,20 @@ func Publish(msg []byte, eventType string) error { if sub == nil { return errors.New("Service is abnormal, and the nginx conf cannot be updated for the time being. Please go to the shell to check the relevant logs") } + + // Only one exchange may be outstanding on the stream. The response carries + // no request id, so two overlapping callers that share the single slot below + // would each read the answer of the other: one would commit a database + // change that tcontrollerd refused, the other would roll back one it + // applied. The generated client does not allow concurrent sends on one + // stream either. + sub.exchangeMu.Lock() + defer sub.exchangeMu.Unlock() + + // A response whose own request already gave up is still in the slot, and + // this call would read it as its own answer. + discardPendingResponses(sub) + err := sub.stream.Send(&pb.Event{ Type: eventType, Msg: msg, @@ -59,11 +73,37 @@ func Publish(msg []byte, eventType string) error { return nil } case <-rspTimeoutTicker.C: + // The device did not answer, so what it applied is unknown: an + // answer that arrives later would be read by the next request as + // if it belonged to it. The stream is dropped instead, which + // leaves the slot this call abandons unreachable and makes + // tcontrollerd reconnect and synchronise with a full push. + clearSubscriber(sub) + select { + case sub.errCh <- errors.New("Wait timeout for updating result"): + default: + } + return errors.New("Wait timeout for updating result") } } } +// discardPendingResponses drops the responses that are sitting in the slot. +// +// It is called while the exchange of the caller is held, so nothing in the slot +// can belong to a request that is still waiting. +func discardPendingResponses(sc *StreamClient) { + for { + select { + case rsp := <-sc.rspCh: + logger.Warnf("Discarding an unclaimed control channel response of type %s", rsp.GetType()) + default: + return + } + } +} + // StreamClient is the instance for every client stream connected type StreamClient struct { stream pb.Website_SubscribeServer @@ -71,6 +111,11 @@ type StreamClient struct { errCh chan error rspCh chan *pb.Response quit chan struct{} // quit stream client gracefully + + // exchangeMu serialises the send-and-wait of Publish. The response of a + // request is not labelled with the request it answers, so at most one + // request may be in flight at a time. + exchangeMu sync.Mutex } // currentSubscriber returns the active subscriber, or nil when no stream is @@ -171,7 +216,18 @@ func (sc *StreamClient) recvLoop() { if rsp.Type != Pong { // receive response logger.Infof("Recv updating website rsp: err(%t), msg(%s)", rsp.GetErr(), rsp.GetMsg()) - sc.rspCh <- rsp + + // This loop is also the goroutine that reads the keepalive pongs + // and resets the timer, so it must not block on a full slot: it + // would disconnect a stream whose peer is perfectly healthy. Only + // one exchange is outstanding at a time (see Publish) and it + // empties the slot before it sends, so a slot that is already full + // holds a response nobody is waiting for. + select { + case sc.rspCh <- rsp: + default: + logger.Warn("Discarding a control channel response that no request is waiting for") + } continue } sc.timer.Reset(KeepaliveTimeout) @@ -195,7 +251,21 @@ func GetWebsiteServer() *WebsiteServer { func publishFullWebsite() error { var websites []model.Website db := database.GetDB().DB - db.Model(&model.Website{}).Find(&websites) + + // A failed query leaves the slice empty, and an empty list means "this + // installation has no sites" to tcontrollerd: it would delete every nginx + // configuration it manages. The error has to stop the push instead. + if err := db.Model(&model.Website{}).Find(&websites).Error; err != nil { + return errors.Wrap(err, "load websites") + } + + if websites == nil { + // A nil slice marshals to "null", which tcontrollerd reads as a + // malformed push. An installation that really has no site sends an + // empty list. + websites = []model.Website{} + } + byteWebsites, err := json.Marshal(&websites) if err != nil { return err diff --git a/management/webserver/utils/httpclient.go b/management/webserver/utils/httpclient.go index a8dc1f0c..89a4a9ae 100644 --- a/management/webserver/utils/httpclient.go +++ b/management/webserver/utils/httpclient.go @@ -1,6 +1,7 @@ package utils import ( + "net" "net/http" "net/url" "os" @@ -10,6 +11,21 @@ import ( const proxyName = "HTTPS_PROXY" +const ( + // httpClientTimeout bounds one request end to end, from the connection + // attempt to the last byte of the body. + httpClientTimeout = 15 * time.Second + + // httpClientDialTimeout bounds the connection attempt, and + // httpClientTLSHandshakeTimeout the handshake that follows it. + httpClientDialTimeout = 5 * time.Second + httpClientTLSHandshakeTimeout = 5 * time.Second + + // httpClientResponseHeaderTimeout bounds the wait for the response header + // once the request was written. + httpClientResponseHeaderTimeout = 10 * time.Second +) + var ( httpClientOnce sync.Once httpClient *http.Client @@ -20,14 +36,27 @@ var ( // The client is built once: the API handlers and the cron jobs call this at the // same time, and the unsynchronized lazy assignment used before let two of them // build (and see) a client at the same time. +// +// Every phase of a request is bounded. The client is used by endpoints that any +// caller can reach, such as the upgrade tip lookup, and the endpoints it talks +// to are remote: an upstream that accepts the connection and then stops +// answering would otherwise hold the handler goroutine and its socket for as +// long as the connection lives, which a caller can repeat without limit. func GetHTTPClient() *http.Client { httpClientOnce.Do(func() { // Certificates are verified: this client talks to the telemetry // endpoint and to the upgrade server, both of which are remote and can // be spoofed by a man in the middle when verification is disabled. tr := &http.Transport{ - MaxIdleConns: 10, - IdleConnTimeout: 30 * time.Second, + DialContext: (&net.Dialer{ + Timeout: httpClientDialTimeout, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: httpClientTLSHandshakeTimeout, + ResponseHeaderTimeout: httpClientResponseHeaderTimeout, + ExpectContinueTimeout: 1 * time.Second, } proxyUrl, existed := os.LookupEnv(proxyName) @@ -36,7 +65,7 @@ func GetHTTPClient() *http.Client { tr.Proxy = http.ProxyURL(uri) } - httpClient = &http.Client{Transport: tr} + httpClient = &http.Client{Transport: tr, Timeout: httpClientTimeout} }) return httpClient diff --git a/mcp_server/README.md b/mcp_server/README.md index 1042a221..ea126585 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -44,6 +44,7 @@ instances: token_file: "/run/secrets/production-a.token" timeout: 30 debug: false + ca_file: "" insecure_skip_verify: false - id: "production-b" @@ -52,15 +53,23 @@ instances: token_file: "/run/secrets/production-b.token" timeout: 30 debug: false + ca_file: "" insecure_skip_verify: false ``` `LISTEN_ADDRESS` and `LISTEN_PORT` override listener settings. There is deliberately no global `SAFELINE_API_TOKEN`: every instance uses a separate token file. +`ca_file` is the certificate authority that signed the certificate of the +instance. A SafeLine console serves the certificate it generated for itself, +which no public authority signed: point `ca_file` at that certificate (or at the +authority that issued it) and the connection stays verified. Relative paths are +resolved from the directory of the configuration file. + `insecure_skip_verify` disables TLS verification for the SafeLine API. The API Token is an administrator credential that is sent on every request, so leave the -setting at `false` and give the instance a certificate the server trusts. Enable -it only for a lab instance whose certificate cannot be replaced. +setting at `false` and use `ca_file` for an instance with a self-signed +certificate. Setting it to `true` logs a warning at startup, and it cannot be +combined with `ca_file`. Each `display_name` must be unique, ignoring letter case, and must not match another instance's `id`. During server discovery (or legacy initialization), the server publishes only the `display_name` to `instance_id` mappings as Server Instructions. This lets a user refer to a friendly name while the AI still calls `get_attack_events` with the stable, explicit `instance_id`. Instance addresses and credentials are never included in those instructions. diff --git a/mcp_server/config.yaml b/mcp_server/config.yaml index 37eaa8a1..1d8c234e 100644 --- a/mcp_server/config.yaml +++ b/mcp_server/config.yaml @@ -21,6 +21,11 @@ instances: token_file: "/run/secrets/safeline-a.token" timeout: 30 debug: false + # ca_file is the certificate authority that signed the certificate of the + # instance. A SafeLine console serves the certificate it generated for + # itself, which no public authority signed: point this at that certificate + # (or at its CA) instead of switching the check off. + ca_file: "" # Leave TLS verification enabled: the SafeLine API Token is an # administrator credential and disabling verification lets anyone on the # path read it. Set this to true only for a lab instance with an @@ -33,4 +38,5 @@ instances: token_file: "/run/secrets/safeline-b.token" timeout: 30 debug: false + ca_file: "" insecure_skip_verify: false diff --git a/mcp_server/internal/api/client.go b/mcp_server/internal/api/client.go index dd6b6a4d..2b2fd8ed 100644 --- a/mcp_server/internal/api/client.go +++ b/mcp_server/internal/api/client.go @@ -3,11 +3,13 @@ package api import ( "context" "crypto/tls" + "crypto/x509" "encoding/json" "fmt" "io" "net/http" "net/url" + "os" "strings" "time" @@ -26,6 +28,25 @@ const ( maxRedirects = 10 ) +// loadRootCAs reads the certificate authority the instance is issued by. +// +// It is a file rather than the system pool because the installation that the +// MCP server talks to usually carries the certificate it generated for itself, +// which no system pool knows about. +func loadRootCAs(path string) (*x509.CertPool, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, errors.Wrap(err, "read ca_file failed") + } + + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(content) { + return nil, errors.New("ca_file does not contain a PEM encoded certificate") + } + + return roots, nil +} + // Client is the transport used by a single SafeLine instance. Its request // method is intentionally unexported; APIClient exposes only approved read-only // operations. @@ -35,7 +56,7 @@ type Client struct { headers http.Header } -func newClient(baseURL string, timeout time.Duration, insecureSkipVerify bool, token string) (*Client, error) { +func newClient(baseURL string, timeout time.Duration, insecureSkipVerify bool, caFile, token string) (*Client, error) { parsedURL, err := url.Parse(strings.TrimSpace(baseURL)) if err != nil { return nil, errors.Wrap(err, "parse base_url failed") @@ -51,6 +72,27 @@ func newClient(baseURL string, timeout time.Duration, insecureSkipVerify bool, t transport := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: insecureSkipVerify}, // #nosec G402 -- deployment-controlled compatibility setting } + + if caFile != "" { + // The console of a SafeLine installation serves a certificate that no + // public authority signed, so the choice used to be between switching + // verification off and failing every request. Pinning the authority of + // the instance keeps it on, which is what keeps the deployment token in + // the request header out of the hands of anything on the path. + roots, err := loadRootCAs(caFile) + if err != nil { + return nil, err + } + transport.TLSClientConfig.RootCAs = roots + } + + if insecureSkipVerify { + logger.With("base_url", parsedURL.Redacted()).Warn( + "insecure_skip_verify is set: the certificate of this instance is not checked, " + + "so anything on the path can read the API token of the deployment. " + + "Set ca_file to the certificate authority of the instance instead.") + } + client := &Client{ baseURL: parsedURL, httpClient: &http.Client{ diff --git a/mcp_server/internal/api/client_redirect_test.go b/mcp_server/internal/api/client_redirect_test.go index c537d11a..4b4aa8e0 100644 --- a/mcp_server/internal/api/client_redirect_test.go +++ b/mcp_server/internal/api/client_redirect_test.go @@ -29,7 +29,7 @@ func TestClientRefusesCrossOriginRedirect(t *testing.T) { })) defer origin.Close() - client, err := newClient(origin.URL, 5*time.Second, false, "deployment-token") + client, err := newClient(origin.URL, 5*time.Second, false, "", "deployment-token") if err != nil { t.Fatal(err) } @@ -61,7 +61,7 @@ func TestClientFollowsSameOriginRedirect(t *testing.T) { server := httptest.NewServer(mux) defer server.Close() - client, err := newClient(server.URL, 5*time.Second, false, "deployment-token") + client, err := newClient(server.URL, 5*time.Second, false, "", "deployment-token") if err != nil { t.Fatal(err) } diff --git a/mcp_server/internal/api/client_tls_test.go b/mcp_server/internal/api/client_tls_test.go new file mode 100644 index 00000000..b01c292f --- /dev/null +++ b/mcp_server/internal/api/client_tls_test.go @@ -0,0 +1,79 @@ +package api + +import ( + "context" + "encoding/pem" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + "time" +) + +// The console of a SafeLine installation serves the certificate it generated +// for itself, so an MCP deployment that wants verification on has to be able to +// name the authority of that instance. ca_file is that, and it exists so that +// insecure_skip_verify is not the only way to reach an installation with a +// self-signed certificate. +func TestClientUsesTheAuthorityOfTheInstance(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"err":null,"data":{"ok":true}}`)) + })) + defer server.Close() + + // The certificate is not known without the authority, and the request is + // refused rather than sent in the clear or unverified. + untrusted, err := newClient(server.URL, 5*time.Second, false, "", "deployment-token") + if err != nil { + t.Fatal(err) + } + + var result struct { + Data struct { + OK bool `json:"ok"` + } `json:"data"` + Err any `json:"err"` + } + if err := untrusted.get(context.Background(), "/api/open/events", url.Values{}, &result); err == nil { + t.Fatal("the instance was reached without trusting its certificate authority") + } + + caPath := filepath.Join(t.TempDir(), "instance.crt") + encoded := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + if err := os.WriteFile(caPath, encoded, 0o600); err != nil { + t.Fatal(err) + } + + trusted, err := newClient(server.URL, 5*time.Second, false, caPath, "deployment-token") + if err != nil { + t.Fatal(err) + } + + if err := trusted.get(context.Background(), "/api/open/events", url.Values{}, &result); err != nil { + t.Fatalf("the request with the authority of the instance failed: %v", err) + } + if !result.Data.OK || result.Err != nil { + t.Fatalf("the request did not reach the instance: %+v", result) + } +} + +func TestClientRefusesACAFileWithoutACertificate(t *testing.T) { + caPath := filepath.Join(t.TempDir(), "not-a-certificate.crt") + if err := os.WriteFile(caPath, []byte("this is not a certificate\n"), 0o600); err != nil { + t.Fatal(err) + } + + if _, err := newClient("https://instance.example:9443", 5*time.Second, false, caPath, "token"); err == nil { + t.Fatal("a ca_file without a certificate was accepted") + } +} + +func TestClientReportsAMissingCAFile(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing.crt") + if _, err := newClient("https://instance.example:9443", 5*time.Second, false, missing, "token"); err == nil { + t.Fatal("a missing ca_file was accepted") + } +} diff --git a/mcp_server/internal/api/service.go b/mcp_server/internal/api/service.go index 1d14fb77..fa554f22 100644 --- a/mcp_server/internal/api/service.go +++ b/mcp_server/internal/api/service.go @@ -137,7 +137,7 @@ func newAPIClient(cfg *config.InstanceConfig) (*APIClient, error) { if cfg.Timeout > 0 { timeout = cfg.Timeout } - client, err := newClient(cfg.BaseURL, time.Duration(timeout)*time.Second, cfg.InsecureSkipVerify, token) + client, err := newClient(cfg.BaseURL, time.Duration(timeout)*time.Second, cfg.InsecureSkipVerify, cfg.CAFile, token) if err != nil { return nil, err } diff --git a/mcp_server/internal/config/config.go b/mcp_server/internal/config/config.go index 17125089..80774a26 100644 --- a/mcp_server/internal/config/config.go +++ b/mcp_server/internal/config/config.go @@ -27,6 +27,7 @@ type InstanceConfig struct { DisplayName string `yaml:"display_name"` BaseURL string `yaml:"base_url"` TokenFile string `yaml:"token_file"` + CAFile string `yaml:"ca_file"` Timeout int `yaml:"timeout"` Debug bool `yaml:"debug"` InsecureSkipVerify bool `yaml:"insecure_skip_verify"` @@ -130,6 +131,7 @@ func normalizeInstances(cfg *Config, configDir string) error { instance.DisplayName = strings.TrimSpace(instance.DisplayName) instance.BaseURL = strings.TrimSpace(instance.BaseURL) instance.TokenFile = strings.TrimSpace(instance.TokenFile) + instance.CAFile = strings.TrimSpace(instance.CAFile) if instance.ID == "" { return errors.New(fmt.Sprintf("instances[%d].id is required", index)) } @@ -150,6 +152,19 @@ func normalizeInstances(cfg *Config, configDir string) error { if instance.TokenFile != "" && !filepath.IsAbs(instance.TokenFile) { instance.TokenFile = filepath.Clean(filepath.Join(configDir, instance.TokenFile)) } + if instance.CAFile != "" && !filepath.IsAbs(instance.CAFile) { + instance.CAFile = filepath.Clean(filepath.Join(configDir, instance.CAFile)) + } + + // Both settings decide how the certificate of the instance is checked, + // and one of them switches the check off: accepting them together would + // make it impossible to tell which one the operator meant. + if instance.InsecureSkipVerify && instance.CAFile != "" { + return errors.New(fmt.Sprintf( + "instances[%d] sets both ca_file and insecure_skip_verify; use ca_file to trust the certificate of the instance and remove insecure_skip_verify", + index, + )) + } } for index, instance := range cfg.Instances { diff --git a/mcp_server/internal/config/config_test.go b/mcp_server/internal/config/config_test.go index ea6cca33..3a7142f5 100644 --- a/mcp_server/internal/config/config_test.go +++ b/mcp_server/internal/config/config_test.go @@ -146,3 +146,64 @@ logger: {level: info} t.Fatalf("Load() error = %v, want missing instance error", err) } } + +func TestLoadResolvesCAFileRelativeToTheConfiguration(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + contents := ` +server: + name: SafeLine MCP + version: 1.0.0 + host: 127.0.0.1 + port: 5678 +logger: + level: info +instances: + - id: dev152 + base_url: https://dev152:9443 + token_file: secrets/dev152_token + ca_file: certs/instance.crt +` + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + if err := Load(path); err != nil { + t.Fatalf("load: %v", err) + } + + instances := GetInstances() + if len(instances) != 1 { + t.Fatalf("got %d instances, want 1", len(instances)) + } + want := filepath.Join(dir, "certs", "instance.crt") + if instances[0].CAFile != want { + t.Fatalf("ca_file is %q, want %q", instances[0].CAFile, want) + } +} + +func TestLoadRejectsCAFileTogetherWithSkippedVerification(t *testing.T) { + path := writeConfigFile(t, ` +server: + name: SafeLine MCP + version: 1.0.0 + host: 127.0.0.1 + port: 5678 +logger: + level: info +instances: + - id: dev152 + base_url: https://dev152:9443 + token_file: /run/secrets/dev152_token + ca_file: /etc/safeline/instance.crt + insecure_skip_verify: true +`) + + err := Load(path) + if err == nil { + t.Fatal("a configuration that both trusts an authority and skips verification was accepted") + } + if !strings.Contains(err.Error(), "ca_file") { + t.Fatalf("the error does not name the setting: %v", err) + } +} From 6ec3a6ec9b031fbd2dcb73530e177732ff76555f Mon Sep 17 00:00:00 2001 From: "jialong.wang" Date: Sun, 20 Sep 2026 13:13:10 +0800 Subject: [PATCH 27/31] fix(management,mcp-server): stop the request chain on a failed session Sixth scan round of main: the findings that are real are closed here, the rest are recorded as false positives with the round. AuthRequired answered 500 when the session could not be saved and then returned without Abort. Gin runs the handlers that follow a middleware from the same loop that called it, so the request still reached the protected handler after the middleware had decided to refuse it. The middleware probe of this round fails on the code before this commit ("the protected handler ran even though the session could not be saved") and passes on the code after it; the control shows that a session small enough to be written still passes. The branch needs a session above the 4096 bytes the cookie store accepts, which the single account whose only value is the user id cannot produce today, so what this closes is a contract that a future session entry would otherwise break silently. GetWebsite rendered site ids with strconv.Itoa(int(id)). The identifier is a uint, so going through int is lossless on the 64 bit builds the product ships and a truncation on a 32 bit one; strconv.FormatUint is both. The directory that holds the control channel token is created 0750 instead of 0755. The token file itself was already 0600, so this only stops a caller from listing a directory it could not read either way. The errors the scan reported as unhandled are discarded explicitly now: the close and the removal of the temporary file in both rename-write helpers, the close and the deferred close of an upload destination, the close of the token file, the deferred close of CopyFile, whose error branch was empty, and the initialisation of the MCP logger, which is guarded by sync.Once and therefore cannot repair anything on the second call. gosec: webserver 35 -> 27, tcontrollerd 10 -> 7, mcp_server 7 -> 6. --- management/tcontrollerd/utils/file.go | 13 +++++-------- management/webserver/api/cert.go | 6 ++++-- management/webserver/api/website.go | 6 ++++-- management/webserver/middleware/auth.go | 5 +++++ management/webserver/rpc/auth.go | 8 ++++++-- management/webserver/utils/file.go | 6 +++--- mcp_server/pkg/logger/logger.go | 2 +- 7 files changed, 28 insertions(+), 18 deletions(-) diff --git a/management/tcontrollerd/utils/file.go b/management/tcontrollerd/utils/file.go index ed42ae64..0aeec06d 100644 --- a/management/tcontrollerd/utils/file.go +++ b/management/tcontrollerd/utils/file.go @@ -84,16 +84,16 @@ func RenameCopyFromIO(src io.Reader, dstPath string, perm os.FileMode) error { renamed := false defer func() { if !renamed { - os.Remove(tmpName) + _ = os.Remove(tmpName) } }() if err = tmpFile.Chmod(perm); err != nil { - tmpFile.Close() + _ = tmpFile.Close() return err } if _, err = io.Copy(tmpFile, src); err != nil { - tmpFile.Close() + _ = tmpFile.Close() return err } if err = tmpFile.Close(); err != nil { @@ -131,11 +131,8 @@ func CopyFile(srcPath, dstPath string) error { if err != nil { return err } - defer func(srcFile *os.File) { - err := srcFile.Close() - if err != nil { - - } + defer func(f *os.File) { + _ = f.Close() }(srcFile) fileInfo, err := srcFile.Stat() diff --git a/management/webserver/api/cert.go b/management/webserver/api/cert.go index 2dc044c8..145e99fc 100644 --- a/management/webserver/api/cert.go +++ b/management/webserver/api/cert.go @@ -122,7 +122,9 @@ func saveUploadedFile(file *multipart.FileHeader, dstPath string, mode os.FileMo if err != nil { return err } - defer src.Close() + defer func() { + _ = src.Close() + }() dst, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) if err != nil { @@ -130,7 +132,7 @@ func saveUploadedFile(file *multipart.FileHeader, dstPath string, mode os.FileMo } if _, err = io.Copy(dst, src); err != nil { - dst.Close() + _ = dst.Close() return err } diff --git a/management/webserver/api/website.go b/management/webserver/api/website.go index c79ce7d5..b7268a06 100644 --- a/management/webserver/api/website.go +++ b/management/webserver/api/website.go @@ -202,13 +202,15 @@ func GetWebsite(ctx *gin.Context) { var statistics []model.SystemStatistics var websiteIds []string for _, i := range websiteList { - websiteIds = append(websiteIds, strconv.Itoa(int(i.ID))) + // FormatUint renders the identifier without narrowing it: it is a uint, + // and going through int would truncate it on a 32 bit build. + websiteIds = append(websiteIds, strconv.FormatUint(uint64(i.ID), 10)) } db.Where("created_at >= date_trunc('day',now())").Where("website in (?)", websiteIds).Find(&statistics) for i, website := range websiteList { for _, j := range statistics { - if strconv.Itoa(int(website.ID)) == j.Website { + if strconv.FormatUint(uint64(website.ID), 10) == j.Website { if j.Type == "website-req" { websiteList[i].ReqValue = j.Value } else if j.Type == "website-denied" { diff --git a/management/webserver/middleware/auth.go b/management/webserver/middleware/auth.go index 2fbf3171..e77757d5 100644 --- a/management/webserver/middleware/auth.go +++ b/management/webserver/middleware/auth.go @@ -26,6 +26,11 @@ func AuthRequired(c *gin.Context) { if err := session.Save(); err != nil { response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when creating sessions"}, http.StatusInternalServerError) + // The request has to stop here. Gin runs the handlers that follow a + // middleware from the same loop that called this one, so returning + // without Abort would still let the request reach the protected handler + // even though the answer above already reported a failure. + c.Abort() return } diff --git a/management/webserver/rpc/auth.go b/management/webserver/rpc/auth.go index 66cb1eed..fc7163a1 100644 --- a/management/webserver/rpc/auth.go +++ b/management/webserver/rpc/auth.go @@ -90,7 +90,11 @@ func loadOrCreateToken(path string) (string, error) { } value := hex.EncodeToString(token) - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + // 0750: the directory holds the secret of the control channel, so it is not + // left traversable by other users. It is a shared mount point whose mode is + // normally set by the deployment, so this only applies when this call is the + // one that creates it. + if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { return "", err } @@ -105,7 +109,7 @@ func loadOrCreateToken(path string) (string, error) { } if _, err = file.WriteString(value + "\n"); err != nil { - file.Close() + _ = file.Close() return "", err } diff --git a/management/webserver/utils/file.go b/management/webserver/utils/file.go index 846cc676..0fde0d62 100644 --- a/management/webserver/utils/file.go +++ b/management/webserver/utils/file.go @@ -62,16 +62,16 @@ func RenameWriteFile(filename string, data []byte, perm os.FileMode) error { renamed := false defer func() { if !renamed { - os.Remove(tmpName) + _ = os.Remove(tmpName) } }() if err = tmpFile.Chmod(perm); err != nil { - tmpFile.Close() + _ = tmpFile.Close() return err } if _, err = tmpFile.Write(data); err != nil { - tmpFile.Close() + _ = tmpFile.Close() return err } if err = tmpFile.Close(); err != nil { diff --git a/mcp_server/pkg/logger/logger.go b/mcp_server/pkg/logger/logger.go index c4714d30..ae8af43e 100644 --- a/mcp_server/pkg/logger/logger.go +++ b/mcp_server/pkg/logger/logger.go @@ -140,7 +140,7 @@ func Init(cfg *Config) error { // GetLogger 获取日志实例 func GetLogger() *Logger { if defaultLogger == nil { - Init(nil) + _ = Init(nil) } if defaultLogger == nil { From c33b716d8d17eadeb08bb83ac0824df85c6ad4d0 Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 20 Sep 2026 15:40:48 +0800 Subject: [PATCH 28/31] fix(management,mcp-server,yanshi): stop control-channel plaintext fallback Refuse unsigned ret_stack_len in generated C, and fail closed when gRPC TLS credentials are missing unless TLS is explicitly disabled. Document why TFA bootstrap, MCP HTTP, skip-verify, and SameSite remain as-is. --- .../tcontrollerd/controller/controller.go | 1 + management/tcontrollerd/controller/creds.go | 28 ++++++++----------- management/webserver/api/auth.go | 10 +++++++ management/webserver/main.go | 4 +++ .../webserver/pkg/sessionopts/sessionopts.go | 12 +++++--- management/webserver/rpc/tls.go | 28 ++++++++----------- mcp_server/internal/api/client.go | 4 +++ mcp_server/main.go | 7 +++-- mcp_server/pkg/mcp/mcp.go | 5 ++++ yanshi/src/compiler.cc | 8 ++++-- 10 files changed, 66 insertions(+), 41 deletions(-) diff --git a/management/tcontrollerd/controller/controller.go b/management/tcontrollerd/controller/controller.go index 4445b7a4..48647114 100644 --- a/management/tcontrollerd/controller/controller.go +++ b/management/tcontrollerd/controller/controller.go @@ -51,6 +51,7 @@ func Handle() error { if creds != nil { dialOptions = append(dialOptions, grpc.WithTransportCredentials(creds)) } else { + // nil creds only when TCD_MGT_TLS is an explicit off value. dialOptions = append(dialOptions, grpc.WithTransportCredentials(insecure.NewCredentials())) } diff --git a/management/tcontrollerd/controller/creds.go b/management/tcontrollerd/controller/creds.go index ba2c2ab1..7952acce 100644 --- a/management/tcontrollerd/controller/creds.go +++ b/management/tcontrollerd/controller/creds.go @@ -15,11 +15,12 @@ import ( const ( // TLSEnv names the environment variable that decides how the control - // channel is reached. "1" requires TLS and refuses to start without it, - // "0" reaches the server in the clear, and anything else (including an - // unset value) uses TLS as soon as the management server published the - // credentials, which it does when its container runs `mgt -gen_certs` on - // start. + // channel is reached. + // + // - "0" / off / false / no: dial in the clear (warned). + // - anything else, including unset: TLS is required. Credentials are + // those published by `mgt -gen_certs`. Missing files are an error; + // there is no silent fallback to plaintext. TLSEnv = "TCD_MGT_TLS" // ServerCertFile is the certificate the management server is expected to @@ -34,11 +35,11 @@ const ( // transportCreds returns the credentials of the control channel. // -// A nil result means the channel is reached in the clear. The management -// server serves it over TLS and only accepts a client certificate it issued, -// so a deployment in which the two containers were not upgraded together keeps -// working through the switch below, and every other deployment fails the -// handshake rather than subscribing without a certificate. +// A nil result means the channel is reached in the clear, and that only happens +// when TLSEnv is an explicit off value. Unset TLSEnv still requires TLS: the +// management side is expected to have published client credentials via +// `mgt -gen_certs`; missing files are returned as an error instead of a +// plaintext dial. func transportCreds() (credentials.TransportCredentials, error) { switch strings.ToLower(strings.TrimSpace(os.Getenv(TLSEnv))) { case "0", "off", "false", "no": @@ -48,12 +49,7 @@ func transportCreds() (credentials.TransportCredentials, error) { creds, err := loadTransportCreds() if err != nil { - if required := strings.ToLower(strings.TrimSpace(os.Getenv(TLSEnv))); required == "1" || required == "on" || required == "true" || required == "yes" { - return nil, err - } - - logger.Warnf("Reaching the control channel in the clear: %s. Set %s=1 to make this an error.", err, TLSEnv) - return nil, nil + return nil, err } return creds, nil diff --git a/management/webserver/api/auth.go b/management/webserver/api/auth.go index 9a53c02a..2880802d 100644 --- a/management/webserver/api/auth.go +++ b/management/webserver/api/auth.go @@ -257,6 +257,16 @@ func BootstrapWindow() time.Duration { return window } +// GetOTPUrl hands the TFA secret to the first installer. It stays on the +// public /api group so the stock console login page can show a QR code without +// a session; requiring MGT_BOOTSTRAP_TOKEN by default would leave that page +// unable to bind a fresh CE install. +// +// Existing gates, not a missing auth middleware: +// - LastLoginTime > 0: no more secret after the first successful login +// - secret is bound to the first ClientIP (TFABootstrapIP); other IPs are refused +// - MGT_BOOTSTRAP_WINDOW, bootstrapThrottle, claimBootstrapSecret / adoptBootstrapSecret +// Public management ports should still set MGT_BOOTSTRAP_TOKEN. func GetOTPUrl(c *gin.Context) { db := database.GetDB() diff --git a/management/webserver/main.go b/management/webserver/main.go index e6758c62..9e935247 100644 --- a/management/webserver/main.go +++ b/management/webserver/main.go @@ -174,6 +174,10 @@ func main() { publicRouters := r.Group("/api") publicRouters.POST(api.Login, api.PostLogin) publicRouters.POST(api.Logout, api.PostLogout) + // Public on purpose for first-boot QR on the login page. Empty + // MGT_BOOTSTRAP_TOKEN is the CE installer path; GetOTPUrl still + // binds the secret to the first IP, closes after LastLoginTime, + // and honours MGT_BOOTSTRAP_WINDOW. See api.GetOTPUrl. publicRouters.GET(api.OTPUrl, api.GetOTPUrl) publicRouters.GET(api.Version, api.GetVersion) publicRouters.GET(api.UpgradeTips, api.GetUpgradeTips) diff --git a/management/webserver/pkg/sessionopts/sessionopts.go b/management/webserver/pkg/sessionopts/sessionopts.go index 41595292..24f74f20 100644 --- a/management/webserver/pkg/sessionopts/sessionopts.go +++ b/management/webserver/pkg/sessionopts/sessionopts.go @@ -20,10 +20,14 @@ const maxAge = 3600 * 24 * 7 // session. // // HttpOnly keeps the session cookie out of reach of JavaScript, SameSite=Lax -// prevents browsers from attaching the cookie to cross-site requests (which -// would otherwise turn the state changing API endpoints into CSRF targets), -// and Secure keeps the cookie on HTTPS, which is how the console is served -// outside of a development setup. +// prevents browsers from attaching the cookie to cross-site POST/PUT (which +// would otherwise turn the state-changing API into CSRF targets), and Secure +// keeps the cookie on HTTPS outside of a development setup. +// +// There is no separate CSRF header by design: the console is a cookie-session +// SPA, and Lax already drops the cookie on cross-site mutating requests. +// Adding a token would require a frontend change; do not treat the missing +// middleware as an accidental gap. func Options(c *gin.Context) sessions.Options { return sessions.Options{ Path: "/", diff --git a/management/webserver/rpc/tls.go b/management/webserver/rpc/tls.go index 7094db12..821b1968 100644 --- a/management/webserver/rpc/tls.go +++ b/management/webserver/rpc/tls.go @@ -17,11 +17,13 @@ import ( const ( // TLSEnv names the environment variable that decides how the control - // channel is secured. "1" requires TLS and refuses to start the channel - // without it, "0" serves the channel in the clear, and anything else - // (including an unset value) secures it as soon as the credentials of the - // installation are there, which is the case of every deployment whose - // container runs `mgt -gen_certs` on start. + // channel is secured. + // + // - "0" / off / false / no: serve in the clear (warned). + // - anything else, including unset: TLS is required. Credentials come + // from `mgt -gen_certs`. Missing files are an error; there is no + // silent fallback to plaintext (the subscription token would otherwise + // travel on the same network as the containers). TLSEnv = "MGT_GRPC_TLS" // CertDirEnv names the environment variable that holds the directory of @@ -79,11 +81,10 @@ func ClientCertDir() string { // ServerCreds returns the transport credentials of the control channel. // -// A nil option means the channel is served in the clear: either the deployment -// asked for that, or the credentials of the installation are not there, which -// is what a management container that was started without `-gen_certs` looks -// like. A deployment that wants to be sure it is not served in the clear sets -// TLSEnv=1, and then a missing credential is an error instead. +// A nil option means the channel is served in the clear, and that only happens +// when TLSEnv is an explicit off value. Unset TLSEnv still requires TLS: the +// deployment is expected to have run `mgt -gen_certs`; missing credentials are +// returned as an error instead of a plaintext listener. func ServerCreds() (grpc.ServerOption, error) { switch strings.ToLower(strings.TrimSpace(os.Getenv(TLSEnv))) { case "0", "off", "false", "no": @@ -93,12 +94,7 @@ func ServerCreds() (grpc.ServerOption, error) { creds, err := loadServerCreds() if err != nil { - if required := strings.ToLower(strings.TrimSpace(os.Getenv(TLSEnv))); required == "1" || required == "on" || required == "true" || required == "yes" { - return nil, err - } - - logger.Warnf("Serving the control channel in the clear: %s. Set %s=1 to make this an error.", err, TLSEnv) - return nil, nil + return nil, err } logger.Infof("The control channel is served over TLS and only accepts a client certificate issued by %s", filepath.Join(CertDir(), CertAuthorityFile)) diff --git a/mcp_server/internal/api/client.go b/mcp_server/internal/api/client.go index 2b2fd8ed..3f171750 100644 --- a/mcp_server/internal/api/client.go +++ b/mcp_server/internal/api/client.go @@ -70,6 +70,10 @@ func newClient(baseURL string, timeout time.Duration, insecureSkipVerify bool, c parsedURL.Path = strings.TrimRight(parsedURL.Path, "/") transport := &http.Transport{ + // Default insecureSkipVerify is false (config.yaml). Self-signed + // consoles should set ca_file to pin the instance CA. Turning this + // on is an explicit operator choice and logs a warn below; it is + // not the default skip-verify path. TLSClientConfig: &tls.Config{InsecureSkipVerify: insecureSkipVerify}, // #nosec G402 -- deployment-controlled compatibility setting } diff --git a/mcp_server/main.go b/mcp_server/main.go index f5dd09f0..eb14e0cb 100644 --- a/mcp_server/main.go +++ b/mcp_server/main.go @@ -137,9 +137,10 @@ func runServer(args []string, stderr io.Writer) error { return err } if !authEnabled && !isLoopbackHost(serverConfig.Host) { - // Without the bearer gate every reachable listener would proxy SafeLine - // attack telemetry to anyone who can open a socket, so refuse to start - // rather than depend on the deployer noticing. + // Bearer auth is on by default. Combined with ListenAndServe (plain + // HTTP, no cert config on this binary), a non-loopback bind with + // auth off would expose SafeLine telemetry to anyone who can open + // a socket. Refuse to start rather than depend on the deployer noticing. return fmt.Errorf( "MCP authentication is disabled but the listener is not loopback-only (host %q); keep authentication enabled or bind a loopback address", serverConfig.Host, diff --git a/mcp_server/pkg/mcp/mcp.go b/mcp_server/pkg/mcp/mcp.go index c92d3932..afd00fde 100644 --- a/mcp_server/pkg/mcp/mcp.go +++ b/mcp_server/pkg/mcp/mcp.go @@ -80,6 +80,11 @@ func (s *MCPServer) Start(addr string, middleware ...Middleware) error { WriteTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second, } + // Plain HTTP is the supported listener: this process has no + // certificate config path. Auth is on by default; main.go refuses + // to start with auth off unless the bind host is loopback. Put + // HTTPS in front (reverse proxy) for anything not on the machine + // or the operator network. return srv.ListenAndServe() } diff --git a/yanshi/src/compiler.cc b/yanshi/src/compiler.cc index 809961c6..66db42c8 100644 --- a/yanshi/src/compiler.cc +++ b/yanshi/src/compiler.cc @@ -288,8 +288,11 @@ fprintf(output, " u = %ld;\n" , u, call_addr[u].first); if (opt_gen_c) + // C ABI takes a signed length from the caller. Rejecting only the + // upper bound lets a negative *ret_stack_len pass, then + // ret_stack[(*ret_stack_len)++] writes before the array. fprintf(output, -" if (*ret_stack_len >= %ld) return -1;\n" +" if (*ret_stack_len < 0 || *ret_stack_len >= %ld) return -1;\n" " ret_stack[(*ret_stack_len)++] = %ld;\n" , opt_max_return_stack, call_addr[u].second); else @@ -389,7 +392,7 @@ fprintf(output, fprintf(output, "default:\n"); indent(output, 3); fprintf(output, opt_gen_c ? -"if (*ret_stack_len) { u = ret_stack[--*ret_stack_len]; goto again; }\n" +"if (*ret_stack_len > 0) { u = ret_stack[--*ret_stack_len]; goto again; }\n" : "if (ret_stack.size()) { u = ret_stack.back(); ret_stack.pop_back(); goto again; }\n"); indent(output, 3); @@ -732,6 +735,7 @@ static void generate_cxx_export(DefineStmt* stmt) generate_final("", final); generate_final("sub_", stmt2final[stmt]); fprintf(output, opt_gen_c ? +" if (ret_stack_len < 0) return false;\n" " for (long i = ret_stack_len; i; u = ret_stack[--i])\n" : " for (auto i = ret_stack.size(); i; u = ret_stack[--i])\n" From 8f0a7d9d582b5f5a52b26c840d940241d39e7a2a Mon Sep 17 00:00:00 2001 From: Jerry Date: Mon, 21 Sep 2026 13:26:27 +0800 Subject: [PATCH 29/31] fix(management,mcp-server,yanshi): close remaining scan findings Require a TFA bootstrap token (generate one if unset), always serve the control channel over TLS, and check Origin host on mutating API calls. MCP refuses skip-verify and non-loopback HTTP, and requires TLS off loopback. Generated C rejects null stacks and failed fopen. --- compose.yaml | 10 +- .../tcontrollerd/controller/controller.go | 8 +- management/tcontrollerd/controller/creds.go | 27 ++--- management/webserver/api/auth.go | 31 ++---- management/webserver/api/bootstrap.go | 99 +++++++++++++++++++ management/webserver/api/bootstrap_test.go | 77 +++++++++++++++ management/webserver/api/response/error.go | 1 + management/webserver/main.go | 24 ++--- management/webserver/middleware/origin.go | 59 +++++++++++ .../webserver/middleware/origin_test.go | 33 +++++++ management/webserver/rpc/main.go | 5 +- management/webserver/rpc/tls.go | 21 ++-- mcp_server/README.md | 20 ++-- mcp_server/config.yaml | 10 +- mcp_server/docker-compose.yml | 3 + mcp_server/internal/api/client.go | 47 +++++---- .../internal/api/client_redirect_test.go | 4 +- mcp_server/internal/api/client_tls_test.go | 24 ++++- mcp_server/internal/api/service.go | 2 +- mcp_server/internal/config/config.go | 28 ++++-- mcp_server/internal/config/config_test.go | 75 ++++++++++++++ mcp_server/main.go | 18 +++- mcp_server/main_test.go | 34 +++++++ mcp_server/pkg/mcp/mcp.go | 21 +++- yanshi/src/compiler.cc | 8 +- 25 files changed, 540 insertions(+), 149 deletions(-) create mode 100644 management/webserver/api/bootstrap.go create mode 100644 management/webserver/api/bootstrap_test.go create mode 100644 management/webserver/middleware/origin.go create mode 100644 management/webserver/middleware/origin_test.go diff --git a/compose.yaml b/compose.yaml index 08201681..d4242361 100644 --- a/compose.yaml +++ b/compose.yaml @@ -48,12 +48,10 @@ services: # ${SAFELINE_DIR}/resources/sock/mgt_grpc_token, which this container # creates and the tengine container reads. - MGT_GRPC_TOKEN=${MGT_GRPC_TOKEN:-} - # Optional out of band token for the anonymous TFA bootstrap. As long as - # it is empty, GET /api/OTPUrl hands the TFA secret of the account to - # whoever reaches the port first, and the login is then bound to that - # address; the daemon warns about it at startup. Set it, and pass the - # same value to the request as X-Bootstrap-Token, to keep the binding for - # an administrator who has the token. + # Token required on GET /api/OTPUrl as X-Bootstrap-Token. Leave empty + # to let the process generate one into + # ${SAFELINE_DIR}/resources/sock/tfa_bootstrap_token (0600) and log the + # path. The console must send that value; until it does, bind with curl. - MGT_BOOTSTRAP_TOKEN=${MGT_BOOTSTRAP_TOKEN:-} # How long the account may be bound without a credential, measured from # the moment the installation was set up (or from `mgt -reset_user diff --git a/management/tcontrollerd/controller/controller.go b/management/tcontrollerd/controller/controller.go index 48647114..eeda201f 100644 --- a/management/tcontrollerd/controller/controller.go +++ b/management/tcontrollerd/controller/controller.go @@ -6,7 +6,6 @@ import ( "strings" "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" "chaitin.cn/dev/go/errors" @@ -48,12 +47,7 @@ func Handle() error { if err != nil { return err } - if creds != nil { - dialOptions = append(dialOptions, grpc.WithTransportCredentials(creds)) - } else { - // nil creds only when TCD_MGT_TLS is an explicit off value. - dialOptions = append(dialOptions, grpc.WithTransportCredentials(insecure.NewCredentials())) - } + dialOptions = append(dialOptions, grpc.WithTransportCredentials(creds)) gRPCConn, err := grpc.Dial(config.GlobalConfig.MgtWebserver, dialOptions...) if err != nil { diff --git a/management/tcontrollerd/controller/creds.go b/management/tcontrollerd/controller/creds.go index 7952acce..f506ccb2 100644 --- a/management/tcontrollerd/controller/creds.go +++ b/management/tcontrollerd/controller/creds.go @@ -14,13 +14,9 @@ import ( ) const ( - // TLSEnv names the environment variable that decides how the control - // channel is reached. - // - // - "0" / off / false / no: dial in the clear (warned). - // - anything else, including unset: TLS is required. Credentials are - // those published by `mgt -gen_certs`. Missing files are an error; - // there is no silent fallback to plaintext. + // TLSEnv names the environment variable that used to allow a plaintext + // control channel. Plaintext is no longer supported: a value of "0" / + // off / false / no is an error. TLSEnv = "TCD_MGT_TLS" // ServerCertFile is the certificate the management server is expected to @@ -35,24 +31,15 @@ const ( // transportCreds returns the credentials of the control channel. // -// A nil result means the channel is reached in the clear, and that only happens -// when TLSEnv is an explicit off value. Unset TLSEnv still requires TLS: the -// management side is expected to have published client credentials via -// `mgt -gen_certs`; missing files are returned as an error instead of a -// plaintext dial. +// The channel is always reached over TLS. Missing credentials or an explicit +// request for plaintext are errors. func transportCreds() (credentials.TransportCredentials, error) { switch strings.ToLower(strings.TrimSpace(os.Getenv(TLSEnv))) { case "0", "off", "false", "no": - logger.Warnf("%s is disabled: the control channel is reached in the clear, and the subscription token is readable by anything that shares the network of this container.", TLSEnv) - return nil, nil + return nil, errors.New("plaintext is not supported: unset " + TLSEnv + " and use credentials published by mgt -gen_certs") } - creds, err := loadTransportCreds() - if err != nil { - return nil, err - } - - return creds, nil + return loadTransportCreds() } // loadTransportCreds reads the credentials the management server published for diff --git a/management/webserver/api/auth.go b/management/webserver/api/auth.go index 2880802d..eb333e75 100644 --- a/management/webserver/api/auth.go +++ b/management/webserver/api/auth.go @@ -257,16 +257,15 @@ func BootstrapWindow() time.Duration { return window } -// GetOTPUrl hands the TFA secret to the first installer. It stays on the -// public /api group so the stock console login page can show a QR code without -// a session; requiring MGT_BOOTSTRAP_TOKEN by default would leave that page -// unable to bind a fresh CE install. +// GetOTPUrl hands the TFA secret during first-boot binding. // -// Existing gates, not a missing auth middleware: +// The request must carry X-Bootstrap-Token. Remaining gates: // - LastLoginTime > 0: no more secret after the first successful login -// - secret is bound to the first ClientIP (TFABootstrapIP); other IPs are refused +// - secret is bound to the first ClientIP (TFABootstrapIP) // - MGT_BOOTSTRAP_WINDOW, bootstrapThrottle, claimBootstrapSecret / adoptBootstrapSecret -// Public management ports should still set MGT_BOOTSTRAP_TOKEN. +// +// The stock console must send the header. Until that image is updated, bind +// with curl using the token written at startup. func GetOTPUrl(c *gin.Context) { db := database.GetDB() @@ -521,22 +520,12 @@ func otpURL(secret string) string { return bootstrapURL.String() } -// checkBootstrapToken enforces the optional out of band token of the anonymous -// TFA bootstrap. -// -// Installations that do not want the account to be bindable by whoever reaches -// the API first set MGT_BOOTSTRAP_TOKEN and deliver it to the administrator -// through a channel of their choice (the installation output, for example). -// -// The empty value keeps the bootstrap open on purpose: the console that ships -// in the management image asks this endpoint for the secret without knowing it, -// so requiring a token here would leave a fresh installation without any way to -// bind the account. That trade off is announced at startup (see main.go) so -// that it is a visible choice rather than a silent default. +// checkBootstrapToken requires X-Bootstrap-Token to match the configured +// bootstrap token. An empty expected value is a server misconfiguration. func checkBootstrapToken(c *gin.Context) error { - expected := os.Getenv(BootstrapTokenEnv) + expected := bootstrapToken if expected == "" { - return nil + return fmt.Errorf("bootstrap token is not configured") } if subtle.ConstantTimeCompare([]byte(c.GetHeader(BootstrapTokenHeader)), []byte(expected)) == 1 { diff --git a/management/webserver/api/bootstrap.go b/management/webserver/api/bootstrap.go new file mode 100644 index 00000000..778c1ff5 --- /dev/null +++ b/management/webserver/api/bootstrap.go @@ -0,0 +1,99 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" +) + +// BootstrapTokenFileEnv names the file the bootstrap token is read from when +// BootstrapTokenEnv is empty. +const BootstrapTokenFileEnv = "MGT_BOOTSTRAP_TOKEN_FILE" + +// DefaultBootstrapTokenFile is next to the control-channel token on the +// shared sock volume. +const DefaultBootstrapTokenFile = "/app/sock/tfa_bootstrap_token" + +const bootstrapTokenBytes = 32 +const bootstrapTokenFileMode os.FileMode = 0600 + +// bootstrapToken is the value GET /api/OTPUrl must present. It is never empty +// after a successful EnsureBootstrapToken. +var bootstrapToken string + +// BootstrapTokenFile returns the path of the persisted bootstrap token. +func BootstrapTokenFile() string { + if path := strings.TrimSpace(os.Getenv(BootstrapTokenFileEnv)); path != "" { + return path + } + return DefaultBootstrapTokenFile +} + +// EnsureBootstrapToken loads MGT_BOOTSTRAP_TOKEN or a persisted file, creating +// the file once when neither is set. The console must send the value as +// X-Bootstrap-Token; an empty token is never accepted. +func EnsureBootstrapToken() (created bool, path string, err error) { + if token := strings.TrimSpace(os.Getenv(BootstrapTokenEnv)); token != "" { + bootstrapToken = token + return false, "", nil + } + + path = BootstrapTokenFile() + token, created, err := loadOrCreateBootstrapToken(path) + if err != nil { + return false, path, err + } + if token == "" { + return false, path, fmt.Errorf("bootstrap token is empty") + } + bootstrapToken = token + return created, path, nil +} + +func loadOrCreateBootstrapToken(path string) (string, bool, error) { + if content, err := os.ReadFile(path); err == nil { + if token := strings.TrimSpace(string(content)); token != "" { + return token, false, nil + } + } + + raw := make([]byte, bootstrapTokenBytes) + if _, err := rand.Read(raw); err != nil { + return "", false, err + } + value := hex.EncodeToString(raw) + + if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { + return "", false, err + } + + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, bootstrapTokenFileMode) + if err != nil { + if !os.IsExist(err) { + return "", false, err + } + content, readErr := os.ReadFile(path) + if readErr != nil { + return "", false, readErr + } + if token := strings.TrimSpace(string(content)); token != "" { + return token, false, nil + } + // Empty leftover file: replace it. Do not truncate a non-empty secret. + if err := os.WriteFile(path, []byte(value+"\n"), bootstrapTokenFileMode); err != nil { + return "", false, err + } + return value, true, nil + } + if _, err = file.WriteString(value + "\n"); err != nil { + _ = file.Close() + return "", false, err + } + if err = file.Close(); err != nil { + return "", false, err + } + return value, true, nil +} diff --git a/management/webserver/api/bootstrap_test.go b/management/webserver/api/bootstrap_test.go new file mode 100644 index 00000000..35a34241 --- /dev/null +++ b/management/webserver/api/bootstrap_test.go @@ -0,0 +1,77 @@ +package api + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureBootstrapTokenCreatesAndReusesFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tfa_bootstrap_token") + t.Setenv(BootstrapTokenEnv, "") + t.Setenv(BootstrapTokenFileEnv, path) + + created, gotPath, err := EnsureBootstrapToken() + if err != nil { + t.Fatal(err) + } + if !created || gotPath != path || bootstrapToken == "" { + t.Fatalf("created=%v path=%q token empty=%v", created, gotPath, bootstrapToken == "") + } + first := bootstrapToken + + created, _, err = EnsureBootstrapToken() + if err != nil { + t.Fatal(err) + } + if created { + t.Fatal("second call rotated the token") + } + if bootstrapToken != first { + t.Fatal("token changed") + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(content) == "" { + t.Fatal("file empty") + } +} + +func TestEnsureBootstrapTokenPrefersEnv(t *testing.T) { + t.Setenv(BootstrapTokenEnv, "from-env") + t.Setenv(BootstrapTokenFileEnv, filepath.Join(t.TempDir(), "unused")) + created, path, err := EnsureBootstrapToken() + if err != nil { + t.Fatal(err) + } + if created || path != "" || bootstrapToken != "from-env" { + t.Fatalf("created=%v path=%q token=%q", created, path, bootstrapToken) + } +} + +func TestEnsureBootstrapTokenReplacesEmptyFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "tfa_bootstrap_token") + if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(BootstrapTokenEnv, "") + t.Setenv(BootstrapTokenFileEnv, path) + created, _, err := EnsureBootstrapToken() + if err != nil { + t.Fatal(err) + } + if !created || bootstrapToken == "" { + t.Fatalf("created=%v token empty", created) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(content)) != bootstrapToken { + t.Fatalf("file=%q token=%q", content, bootstrapToken) + } +} diff --git a/management/webserver/api/response/error.go b/management/webserver/api/response/error.go index 6373cff7..4d03fbd3 100644 --- a/management/webserver/api/response/error.go +++ b/management/webserver/api/response/error.go @@ -9,4 +9,5 @@ const ( ErrWrongFileType = "wrong-filetype" ErrReadOnly = "read-only" ErrTooManyAttempts = "too-many-attempts" + ErrForbidden = "forbidden" ) diff --git a/management/webserver/main.go b/management/webserver/main.go index 9e935247..6790a5f4 100644 --- a/management/webserver/main.go +++ b/management/webserver/main.go @@ -155,13 +155,16 @@ func main() { store := cookie.NewStore([]byte(option.Value)) r.Use(sessions.Sessions("session", store)) - // The bootstrap token is the only gate in front of the TFA secret of the - // account. It is optional so that a fresh installation can be bound from - // the console, and what limits the exposure when it is empty is the window - // the account may be bound in: announce both. - if os.Getenv(api.BootstrapTokenEnv) == "" { - logger.Warnf("%s is not set: the TFA secret of the account is handed to whoever reaches GET %s%s first, for %s after the bootstrap was opened. Set it to require the %s header on that request.", - api.BootstrapTokenEnv, "/api", api.OTPUrl, api.BootstrapWindow(), api.BootstrapTokenHeader) + created, tokenPath, err := api.EnsureBootstrapToken() + if err != nil { + logger.Fatalln("Failed to initialize the TFA bootstrap token: ", err) + } + if os.Getenv(api.BootstrapTokenEnv) != "" { + logger.Infof("GET /api%s requires the %s header from %s.", api.OTPUrl, api.BootstrapTokenHeader, api.BootstrapTokenEnv) + } else if created { + logger.Infof("Wrote the TFA bootstrap token to %s (mode 0600). GET /api%s requires the %s header. The stock console must send it; until then bind with curl.", tokenPath, api.OTPUrl, api.BootstrapTokenHeader) + } else { + logger.Infof("GET /api%s requires the %s header; token loaded from %s.", api.OTPUrl, api.BootstrapTokenHeader, tokenPath) } if window := api.BootstrapWindow(); window <= 0 { logger.Warnf("%s is off: the account can be bound from the network until somebody logs in. A console that is left unbound stays claimable.", @@ -172,12 +175,10 @@ func main() { } publicRouters := r.Group("/api") + publicRouters.Use(middleware.SameOrigin) publicRouters.POST(api.Login, api.PostLogin) publicRouters.POST(api.Logout, api.PostLogout) - // Public on purpose for first-boot QR on the login page. Empty - // MGT_BOOTSTRAP_TOKEN is the CE installer path; GetOTPUrl still - // binds the secret to the first IP, closes after LastLoginTime, - // and honours MGT_BOOTSTRAP_WINDOW. See api.GetOTPUrl. + // First-boot QR still has no session, but requires X-Bootstrap-Token. publicRouters.GET(api.OTPUrl, api.GetOTPUrl) publicRouters.GET(api.Version, api.GetVersion) publicRouters.GET(api.UpgradeTips, api.GetUpgradeTips) @@ -189,6 +190,7 @@ func main() { }) limitedRouters := r.Group("/api") + limitedRouters.Use(middleware.SameOrigin) if envEnabled(os.Getenv("NO_AUTH")) { logger.Warn("Authentication is disabled because NO_AUTH is enabled") } else { diff --git a/management/webserver/middleware/origin.go b/management/webserver/middleware/origin.go new file mode 100644 index 00000000..35e06158 --- /dev/null +++ b/management/webserver/middleware/origin.go @@ -0,0 +1,59 @@ +package middleware + +import ( + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" + + "chaitin.cn/patronus/safeline-2/management/webserver/api/response" +) + +// SameOrigin rejects cross-site state-changing requests using the Origin +// header. The management console is a cookie-session SPA; Lax already drops +// the cookie on cross-site POST, and this check covers non-browser clients +// that still send Origin, plus browsers that attach the cookie on same-site +// confusing cases. +// +// Safe methods are left alone. Requests without a usable Origin are refused: +// Referrer-Policy is no-referrer, so Referer is not a fallback. +func SameOrigin(c *gin.Context) { + switch c.Request.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + c.Next() + return + } + + origin := strings.TrimSpace(c.GetHeader("Origin")) + if origin == "" || strings.EqualFold(origin, "null") { + response.Error(c, response.JSONBody{Err: response.ErrForbidden, Msg: "cross-origin request is not allowed"}, http.StatusForbidden) + c.Abort() + return + } + + parsed, err := url.Parse(origin) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + response.Error(c, response.JSONBody{Err: response.ErrForbidden, Msg: "cross-origin request is not allowed"}, http.StatusForbidden) + c.Abort() + return + } + + if !originMatchesRequest(parsed, c) { + response.Error(c, response.JSONBody{Err: response.ErrForbidden, Msg: "cross-origin request is not allowed"}, http.StatusForbidden) + c.Abort() + return + } + + c.Next() +} + +func originMatchesRequest(origin *url.URL, c *gin.Context) bool { + // Scheme is not compared: the console is reached over HTTPS while gin + // often sees HTTP behind the container TLS terminator. Host still has + // to match, so a foreign site cannot satisfy this check. + if !strings.EqualFold(origin.Scheme, "http") && !strings.EqualFold(origin.Scheme, "https") { + return false + } + return strings.EqualFold(origin.Host, c.Request.Host) +} diff --git a/management/webserver/middleware/origin_test.go b/management/webserver/middleware/origin_test.go new file mode 100644 index 00000000..bfb68596 --- /dev/null +++ b/management/webserver/middleware/origin_test.go @@ -0,0 +1,33 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestOriginMatchesRequestIgnoresScheme(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "http://127.0.0.1:9001/api/Login", nil) + c.Request.Host = "example.com:9443" + + origin, err := url.Parse("https://example.com:9443") + if err != nil { + t.Fatal(err) + } + if !originMatchesRequest(origin, c) { + t.Fatal("https Origin should match Host behind HTTP gin") + } + + evil, err := url.Parse("https://evil.example") + if err != nil { + t.Fatal(err) + } + if originMatchesRequest(evil, c) { + t.Fatal("foreign Origin must not match") + } +} diff --git a/management/webserver/rpc/main.go b/management/webserver/rpc/main.go index 14fa99e0..dc65e55a 100644 --- a/management/webserver/rpc/main.go +++ b/management/webserver/rpc/main.go @@ -42,10 +42,7 @@ func ServerOptions() ([]grpc.ServerOption, error) { if err != nil { return nil, err } - if creds != nil { - opts = append(opts, creds) - } - + opts = append(opts, creds) return opts, nil } diff --git a/management/webserver/rpc/tls.go b/management/webserver/rpc/tls.go index 821b1968..ab4b1d80 100644 --- a/management/webserver/rpc/tls.go +++ b/management/webserver/rpc/tls.go @@ -16,14 +16,9 @@ import ( ) const ( - // TLSEnv names the environment variable that decides how the control - // channel is secured. - // - // - "0" / off / false / no: serve in the clear (warned). - // - anything else, including unset: TLS is required. Credentials come - // from `mgt -gen_certs`. Missing files are an error; there is no - // silent fallback to plaintext (the subscription token would otherwise - // travel on the same network as the containers). + // TLSEnv names the environment variable that used to allow a plaintext + // control channel. Plaintext is no longer supported: a value of "0" / + // off / false / no is an error. Unset it and run `mgt -gen_certs`. TLSEnv = "MGT_GRPC_TLS" // CertDirEnv names the environment variable that holds the directory of @@ -81,15 +76,13 @@ func ClientCertDir() string { // ServerCreds returns the transport credentials of the control channel. // -// A nil option means the channel is served in the clear, and that only happens -// when TLSEnv is an explicit off value. Unset TLSEnv still requires TLS: the -// deployment is expected to have run `mgt -gen_certs`; missing credentials are -// returned as an error instead of a plaintext listener. +// The channel is always served over TLS. Missing credentials or an explicit +// request for plaintext are errors: the subscription token would otherwise +// travel on the same network as the containers. func ServerCreds() (grpc.ServerOption, error) { switch strings.ToLower(strings.TrimSpace(os.Getenv(TLSEnv))) { case "0", "off", "false", "no": - logger.Warnf("%s is disabled: the control channel is served in the clear, and the subscription token is the only gate in front of it. Anything that shares the network of this container can read it.", TLSEnv) - return nil, nil + return nil, errors.New("plaintext is not supported: unset " + TLSEnv + " and run mgt -gen_certs") } creds, err := loadServerCreds() diff --git a/mcp_server/README.md b/mcp_server/README.md index ea126585..e2653865 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -45,7 +45,6 @@ instances: timeout: 30 debug: false ca_file: "" - insecure_skip_verify: false - id: "production-b" display_name: "Production B" @@ -54,7 +53,6 @@ instances: timeout: 30 debug: false ca_file: "" - insecure_skip_verify: false ``` `LISTEN_ADDRESS` and `LISTEN_PORT` override listener settings. There is deliberately no global `SAFELINE_API_TOKEN`: every instance uses a separate token file. @@ -65,11 +63,9 @@ which no public authority signed: point `ca_file` at that certificate (or at the authority that issued it) and the connection stays verified. Relative paths are resolved from the directory of the configuration file. -`insecure_skip_verify` disables TLS verification for the SafeLine API. The API -Token is an administrator credential that is sent on every request, so leave the -setting at `false` and use `ca_file` for an instance with a self-signed -certificate. Setting it to `true` logs a warning at startup, and it cannot be -combined with `ca_file`. +`insecure_skip_verify` is rejected at startup. Use `ca_file` for a self-signed +instance certificate. `base_url` must be `https`, or `http` only when the host +is loopback (`127.0.0.1`, `localhost`, `::1`). Each `display_name` must be unique, ignoring letter case, and must not match another instance's `id`. During server discovery (or legacy initialization), the server publishes only the `display_name` to `instance_id` mappings as Server Instructions. This lets a user refer to a friendly name while the AI still calls `get_attack_events` with the stable, explicit `instance_id`. Instance addresses and credentials are never included in those instructions. @@ -99,12 +95,10 @@ Clients send the generated token on every MCP request: Authorization: Bearer slmcp_... ``` -The built-in listener is plain HTTP. Docker Compose publishes it on -`127.0.0.1` by default so the reusable Bearer Token does not cross a network -in cleartext. For access from another machine, put the endpoint behind a -trusted HTTPS-terminating gateway or keep it on an equivalently protected -private transport. Do not set `MCP_PUBLISH_ADDRESS=0.0.0.0` on an untrusted -network without TLS termination. +Loopback (`127.0.0.1` / `localhost` / `::1`) may listen on plain HTTP. +A non-loopback bind, including Docker `LISTEN_ADDRESS=0.0.0.0`, requires +`MCP_TLS_CERT` and `MCP_TLS_KEY` (or `tls_cert_file` / `tls_key_file`). +The process refuses to start without them. The server refuses to start while this state is missing, so a deployment cannot serve any request before the token exists. Explicit rotation is available diff --git a/mcp_server/config.yaml b/mcp_server/config.yaml index 1d8c234e..bed6c1c8 100644 --- a/mcp_server/config.yaml +++ b/mcp_server/config.yaml @@ -4,6 +4,9 @@ server: # LISTEN_ADDRESS and LISTEN_PORT can override these listener values. host: "127.0.0.1" port: 5678 + # Required when host is not loopback. MCP_TLS_CERT / MCP_TLS_KEY override these. + tls_cert_file: "" + tls_key_file: "" logger: level: "info" @@ -26,12 +29,6 @@ instances: # itself, which no public authority signed: point this at that certificate # (or at its CA) instead of switching the check off. ca_file: "" - # Leave TLS verification enabled: the SafeLine API Token is an - # administrator credential and disabling verification lets anyone on the - # path read it. Set this to true only for a lab instance with an - # unchangeable self-signed certificate. - insecure_skip_verify: false - - id: "safeline-b" display_name: "SafeLine B" base_url: "https://safeline-b.example:9443" @@ -39,4 +36,3 @@ instances: timeout: 30 debug: false ca_file: "" - insecure_skip_verify: false diff --git a/mcp_server/docker-compose.yml b/mcp_server/docker-compose.yml index 2ad91e8e..be26bc6b 100644 --- a/mcp_server/docker-compose.yml +++ b/mcp_server/docker-compose.yml @@ -9,6 +9,9 @@ services: environment: LISTEN_ADDRESS: "0.0.0.0" LISTEN_PORT: "${LISTEN_PORT:-5678}" + # Required because LISTEN_ADDRESS is not loopback. + MCP_TLS_CERT: "${MCP_TLS_CERT:-}" + MCP_TLS_KEY: "${MCP_TLS_KEY:-}" # Authentication is enabled by default. Only set MCP_AUTH_DISABLED=true # for a loopback-only deployment; a non-loopback listener without a token # is refused at startup. diff --git a/mcp_server/internal/api/client.go b/mcp_server/internal/api/client.go index 3f171750..d094efa2 100644 --- a/mcp_server/internal/api/client.go +++ b/mcp_server/internal/api/client.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "net/url" "os" @@ -56,7 +57,23 @@ type Client struct { headers http.Header } -func newClient(baseURL string, timeout time.Duration, insecureSkipVerify bool, caFile, token string) (*Client, error) { +func loopbackURLHost(host string) bool { + host = strings.TrimSpace(host) + if host == "" { + return false + } + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.Trim(host, "[]") + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func newClient(baseURL string, timeout time.Duration, caFile, token string) (*Client, error) { parsedURL, err := url.Parse(strings.TrimSpace(baseURL)) if err != nil { return nil, errors.Wrap(err, "parse base_url failed") @@ -64,38 +81,28 @@ func newClient(baseURL string, timeout time.Duration, insecureSkipVerify bool, c if (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" { return nil, errors.New("base_url must be an absolute http or https URL") } + if parsedURL.Scheme == "http" && !loopbackURLHost(parsedURL.Host) { + return nil, errors.New("deployment token must not be sent over non-loopback HTTP") + } if parsedURL.RawQuery != "" || parsedURL.Fragment != "" { return nil, errors.New("base_url must not contain a query or fragment") } parsedURL.Path = strings.TrimRight(parsedURL.Path, "/") - transport := &http.Transport{ - // Default insecureSkipVerify is false (config.yaml). Self-signed - // consoles should set ca_file to pin the instance CA. Turning this - // on is an explicit operator choice and logs a warn below; it is - // not the default skip-verify path. - TLSClientConfig: &tls.Config{InsecureSkipVerify: insecureSkipVerify}, // #nosec G402 -- deployment-controlled compatibility setting - } - + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} if caFile != "" { // The console of a SafeLine installation serves a certificate that no - // public authority signed, so the choice used to be between switching - // verification off and failing every request. Pinning the authority of - // the instance keeps it on, which is what keeps the deployment token in - // the request header out of the hands of anything on the path. + // public authority signed. Pinning the authority of the instance keeps + // verification on, which is what keeps the deployment token in the + // request header out of the hands of anything on the path. roots, err := loadRootCAs(caFile) if err != nil { return nil, err } - transport.TLSClientConfig.RootCAs = roots + tlsConfig.RootCAs = roots } - if insecureSkipVerify { - logger.With("base_url", parsedURL.Redacted()).Warn( - "insecure_skip_verify is set: the certificate of this instance is not checked, " + - "so anything on the path can read the API token of the deployment. " + - "Set ca_file to the certificate authority of the instance instead.") - } + transport := &http.Transport{TLSClientConfig: tlsConfig} client := &Client{ baseURL: parsedURL, diff --git a/mcp_server/internal/api/client_redirect_test.go b/mcp_server/internal/api/client_redirect_test.go index 4b4aa8e0..81af567d 100644 --- a/mcp_server/internal/api/client_redirect_test.go +++ b/mcp_server/internal/api/client_redirect_test.go @@ -29,7 +29,7 @@ func TestClientRefusesCrossOriginRedirect(t *testing.T) { })) defer origin.Close() - client, err := newClient(origin.URL, 5*time.Second, false, "", "deployment-token") + client, err := newClient(origin.URL, 5*time.Second, "", "deployment-token") if err != nil { t.Fatal(err) } @@ -61,7 +61,7 @@ func TestClientFollowsSameOriginRedirect(t *testing.T) { server := httptest.NewServer(mux) defer server.Close() - client, err := newClient(server.URL, 5*time.Second, false, "", "deployment-token") + client, err := newClient(server.URL, 5*time.Second, "", "deployment-token") if err != nil { t.Fatal(err) } diff --git a/mcp_server/internal/api/client_tls_test.go b/mcp_server/internal/api/client_tls_test.go index b01c292f..814847ef 100644 --- a/mcp_server/internal/api/client_tls_test.go +++ b/mcp_server/internal/api/client_tls_test.go @@ -26,7 +26,7 @@ func TestClientUsesTheAuthorityOfTheInstance(t *testing.T) { // The certificate is not known without the authority, and the request is // refused rather than sent in the clear or unverified. - untrusted, err := newClient(server.URL, 5*time.Second, false, "", "deployment-token") + untrusted, err := newClient(server.URL, 5*time.Second, "", "deployment-token") if err != nil { t.Fatal(err) } @@ -47,7 +47,7 @@ func TestClientUsesTheAuthorityOfTheInstance(t *testing.T) { t.Fatal(err) } - trusted, err := newClient(server.URL, 5*time.Second, false, caPath, "deployment-token") + trusted, err := newClient(server.URL, 5*time.Second, caPath, "deployment-token") if err != nil { t.Fatal(err) } @@ -66,14 +66,30 @@ func TestClientRefusesACAFileWithoutACertificate(t *testing.T) { t.Fatal(err) } - if _, err := newClient("https://instance.example:9443", 5*time.Second, false, caPath, "token"); err == nil { + if _, err := newClient("https://instance.example:9443", 5*time.Second, caPath, "token"); err == nil { t.Fatal("a ca_file without a certificate was accepted") } } func TestClientReportsAMissingCAFile(t *testing.T) { missing := filepath.Join(t.TempDir(), "missing.crt") - if _, err := newClient("https://instance.example:9443", 5*time.Second, false, missing, "token"); err == nil { + if _, err := newClient("https://instance.example:9443", 5*time.Second, missing, "token"); err == nil { t.Fatal("a missing ca_file was accepted") } } + +func TestClientRejectsNonLoopbackHTTP(t *testing.T) { + _, err := newClient("http://example.com:9443", 5*time.Second, "", "token") + if err == nil || err.Error() != "deployment token must not be sent over non-loopback HTTP" { + t.Fatalf("error = %v", err) + } +} + +func TestClientAllowsLoopbackHTTP(t *testing.T) { + if _, err := newClient("http://127.0.0.1:9443", 5*time.Second, "", "token"); err != nil { + t.Fatal(err) + } + if _, err := newClient("http://localhost:9443", 5*time.Second, "", "token"); err != nil { + t.Fatal(err) + } +} diff --git a/mcp_server/internal/api/service.go b/mcp_server/internal/api/service.go index fa554f22..e06c6172 100644 --- a/mcp_server/internal/api/service.go +++ b/mcp_server/internal/api/service.go @@ -137,7 +137,7 @@ func newAPIClient(cfg *config.InstanceConfig) (*APIClient, error) { if cfg.Timeout > 0 { timeout = cfg.Timeout } - client, err := newClient(cfg.BaseURL, time.Duration(timeout)*time.Second, cfg.InsecureSkipVerify, cfg.CAFile, token) + client, err := newClient(cfg.BaseURL, time.Duration(timeout)*time.Second, cfg.CAFile, token) if err != nil { return nil, err } diff --git a/mcp_server/internal/config/config.go b/mcp_server/internal/config/config.go index 80774a26..51c0aec4 100644 --- a/mcp_server/internal/config/config.go +++ b/mcp_server/internal/config/config.go @@ -35,10 +35,12 @@ type InstanceConfig struct { // ServerConfig is the MCP server listener configuration. type ServerConfig struct { - Name string `yaml:"name"` - Version string `yaml:"version"` - Port int `yaml:"port"` - Host string `yaml:"host"` + Name string `yaml:"name"` + Version string `yaml:"version"` + Port int `yaml:"port"` + Host string `yaml:"host"` + TLSCertFile string `yaml:"tls_cert_file"` + TLSKeyFile string `yaml:"tls_key_file"` } // LoggerConfig is the logging configuration. @@ -87,6 +89,17 @@ func Load(path string) error { if next.Server != nil { next.Server.Host = getEnvString("LISTEN_ADDRESS", next.Server.Host) next.Server.Port = getEnvInt("LISTEN_PORT", next.Server.Port) + // Empty compose values must not wipe yaml paths. + if cert := strings.TrimSpace(os.Getenv("MCP_TLS_CERT")); cert != "" { + next.Server.TLSCertFile = cert + } else { + next.Server.TLSCertFile = strings.TrimSpace(next.Server.TLSCertFile) + } + if key := strings.TrimSpace(os.Getenv("MCP_TLS_KEY")); key != "" { + next.Server.TLSKeyFile = key + } else { + next.Server.TLSKeyFile = strings.TrimSpace(next.Server.TLSKeyFile) + } } if next.Server == nil { @@ -156,12 +169,9 @@ func normalizeInstances(cfg *Config, configDir string) error { instance.CAFile = filepath.Clean(filepath.Join(configDir, instance.CAFile)) } - // Both settings decide how the certificate of the instance is checked, - // and one of them switches the check off: accepting them together would - // make it impossible to tell which one the operator meant. - if instance.InsecureSkipVerify && instance.CAFile != "" { + if instance.InsecureSkipVerify { return errors.New(fmt.Sprintf( - "instances[%d] sets both ca_file and insecure_skip_verify; use ca_file to trust the certificate of the instance and remove insecure_skip_verify", + "instances[%d].insecure_skip_verify is not supported; set ca_file to trust a self-signed instance certificate", index, )) } diff --git a/mcp_server/internal/config/config_test.go b/mcp_server/internal/config/config_test.go index 3a7142f5..53253e5b 100644 --- a/mcp_server/internal/config/config_test.go +++ b/mcp_server/internal/config/config_test.go @@ -207,3 +207,78 @@ instances: t.Fatalf("the error does not name the setting: %v", err) } } + +func TestLoadRejectsInsecureSkipVerify(t *testing.T) { + path := writeConfigFile(t, ` +server: + name: SafeLine MCP + version: 1.0.0 + host: 127.0.0.1 + port: 5678 +logger: + level: info +instances: + - id: dev152 + base_url: https://dev152:9443 + token_file: /run/secrets/dev152_token + insecure_skip_verify: true +`) + err := Load(path) + if err == nil || !strings.Contains(err.Error(), "insecure_skip_verify is not supported") { + t.Fatalf("Load() error = %v", err) + } +} + +func TestLoadTLSCertFromEnv(t *testing.T) { + path := writeConfigFile(t, ` +server: + name: SafeLine MCP + version: 1.0.0 + host: 127.0.0.1 + port: 5678 + tls_cert_file: /yaml/cert.pem + tls_key_file: /yaml/key.pem +logger: + level: info +instances: + - id: dev152 + base_url: https://dev152:9443 + token_file: /run/secrets/dev152_token +`) + t.Setenv("MCP_TLS_CERT", "/env/cert.pem") + t.Setenv("MCP_TLS_KEY", "/env/key.pem") + if err := Load(path); err != nil { + t.Fatal(err) + } + server := GetServer() + if server.TLSCertFile != "/env/cert.pem" || server.TLSKeyFile != "/env/key.pem" { + t.Fatalf("tls paths = %q %q", server.TLSCertFile, server.TLSKeyFile) + } +} + +func TestLoadEmptyTLSEnvDoesNotWipeYAML(t *testing.T) { + path := writeConfigFile(t, ` +server: + name: SafeLine MCP + version: 1.0.0 + host: 127.0.0.1 + port: 5678 + tls_cert_file: /yaml/cert.pem + tls_key_file: /yaml/key.pem +logger: + level: info +instances: + - id: dev152 + base_url: https://dev152:9443 + token_file: /run/secrets/dev152_token +`) + t.Setenv("MCP_TLS_CERT", "") + t.Setenv("MCP_TLS_KEY", "") + if err := Load(path); err != nil { + t.Fatal(err) + } + server := GetServer() + if server.TLSCertFile != "/yaml/cert.pem" || server.TLSKeyFile != "/yaml/key.pem" { + t.Fatalf("tls paths = %q %q", server.TLSCertFile, server.TLSKeyFile) + } +} diff --git a/mcp_server/main.go b/mcp_server/main.go index eb14e0cb..28cac8f4 100644 --- a/mcp_server/main.go +++ b/mcp_server/main.go @@ -137,15 +137,22 @@ func runServer(args []string, stderr io.Writer) error { return err } if !authEnabled && !isLoopbackHost(serverConfig.Host) { - // Bearer auth is on by default. Combined with ListenAndServe (plain - // HTTP, no cert config on this binary), a non-loopback bind with - // auth off would expose SafeLine telemetry to anyone who can open - // a socket. Refuse to start rather than depend on the deployer noticing. return fmt.Errorf( "MCP authentication is disabled but the listener is not loopback-only (host %q); keep authentication enabled or bind a loopback address", serverConfig.Host, ) } + certFile := strings.TrimSpace(serverConfig.TLSCertFile) + keyFile := strings.TrimSpace(serverConfig.TLSKeyFile) + if (certFile == "") != (keyFile == "") { + return fmt.Errorf("MCP_TLS_CERT and MCP_TLS_KEY (or tls_cert_file and tls_key_file) must be set together") + } + if !isLoopbackHost(serverConfig.Host) && certFile == "" { + return fmt.Errorf( + "non-loopback MCP listener %q requires TLS; set MCP_TLS_CERT and MCP_TLS_KEY", + serverConfig.Host, + ) + } instances := config.GetInstances() logger.With("instances", len(instances)).Info("initializing SafeLine API clients") @@ -173,5 +180,8 @@ func runServer(args []string, stderr io.Writer) error { addr := fmt.Sprintf("%s:%d", serverConfig.Host, serverConfig.Port) logger.With("addr", addr).With("endpoint", mcpserver.EndpointPath).Info("starting MCP server") + if certFile != "" { + return s.StartTLS(addr, certFile, keyFile, middleware...) + } return s.Start(addr, middleware...) } diff --git a/mcp_server/main_test.go b/mcp_server/main_test.go index 1caae6b5..bbdc889c 100644 --- a/mcp_server/main_test.go +++ b/mcp_server/main_test.go @@ -135,3 +135,37 @@ func TestIsLoopbackHost(t *testing.T) { } } } + +func TestNonLoopbackListenerRequiresTLS(t *testing.T) { + dir := t.TempDir() + stateDir := filepath.Join(dir, "state") + if err := os.Mkdir(stateDir, 0o700); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(dir, "config.yaml") + contents := ` +server: + name: SafeLine MCP + version: 1.0.0 + host: 0.0.0.0 + port: 5678 +logger: + level: info +instances: + - id: dev152 + base_url: https://dev152:9443 + token_file: /run/secrets/dev152_token +` + if err := os.WriteFile(configPath, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + stateFile := filepath.Join(stateDir, "auth.json") + if _, _, err := auth.Initialize(stateFile); err != nil { + t.Fatal(err) + } + t.Setenv(auth.StateFileEnv, stateFile) + err := run([]string{"--config", configPath}, &bytes.Buffer{}, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "requires TLS") { + t.Fatalf("run() error = %v, want TLS requirement", err) + } +} diff --git a/mcp_server/pkg/mcp/mcp.go b/mcp_server/pkg/mcp/mcp.go index afd00fde..a64d04e4 100644 --- a/mcp_server/pkg/mcp/mcp.go +++ b/mcp_server/pkg/mcp/mcp.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "strings" "time" "github.com/chaitin/SafeLine/mcp_server/pkg/logger" @@ -67,6 +68,18 @@ func (s *MCPServer) Handler() http.Handler { } func (s *MCPServer) Start(addr string, middleware ...Middleware) error { + return s.listen(addr, "", "", middleware...) +} + +// StartTLS serves the same handler over HTTPS. +func (s *MCPServer) StartTLS(addr, certFile, keyFile string, middleware ...Middleware) error { + if strings.TrimSpace(certFile) == "" || strings.TrimSpace(keyFile) == "" { + return fmt.Errorf("tls cert and key are required") + } + return s.listen(addr, certFile, keyFile, middleware...) +} + +func (s *MCPServer) listen(addr, certFile, keyFile string, middleware ...Middleware) error { var handler http.Handler = s.Handler() for i := len(middleware) - 1; i >= 0; i-- { handler = middleware[i](handler) @@ -80,11 +93,9 @@ func (s *MCPServer) Start(addr string, middleware ...Middleware) error { WriteTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second, } - // Plain HTTP is the supported listener: this process has no - // certificate config path. Auth is on by default; main.go refuses - // to start with auth off unless the bind host is loopback. Put - // HTTPS in front (reverse proxy) for anything not on the machine - // or the operator network. + if certFile != "" { + return srv.ListenAndServeTLS(certFile, keyFile) + } return srv.ListenAndServe() } diff --git a/yanshi/src/compiler.cc b/yanshi/src/compiler.cc index 66db42c8..e543cfd0 100644 --- a/yanshi/src/compiler.cc +++ b/yanshi/src/compiler.cc @@ -278,7 +278,11 @@ void generate_transitions(DefineStmt* stmt) fprintf(output, ")\n" "{\n" -" long v = -1;\n" +" long v = -1;\n"); + if (opt_gen_c) + fprintf(output, +" if (!ret_stack || !ret_stack_len) return -1;\n"); + fprintf(output, "again:\n" " switch (u) {\n"); REP(u, anno.fsa.n()) { @@ -736,6 +740,7 @@ static void generate_cxx_export(DefineStmt* stmt) generate_final("sub_", stmt2final[stmt]); fprintf(output, opt_gen_c ? " if (ret_stack_len < 0) return false;\n" +" if (ret_stack_len > 0 && !ret_stack) return false;\n" " for (long i = ret_stack_len; i; u = ret_stack[--i])\n" : " for (auto i = ret_stack.size(); i; u = ret_stack[--i])\n" @@ -815,6 +820,7 @@ void generate_cxx(Module* mo) " utf8 = argv[1];\n" " else {\n" " FILE* f = argc == 1 ? stdin : fopen(argv[1], \"r\");\n" +" if (!f) return 1;\n" " while ((c = fgetc(f)) != EOF)\n" " utf8 += c;\n" " fclose(f);\n" From 3afe35f2e5bd6d6f03674959f9def81b654a8c40 Mon Sep 17 00:00:00 2001 From: Jerry Date: Tue, 22 Sep 2026 16:23:27 +0800 Subject: [PATCH 30/31] fix(management,mcp-server,yanshi): close the 2026-09-22 scan findings Disable policy rules through an explicit column update, publish the saved website only after Save succeeds, and reject empty policy patterns. Bound certificate uploads, refuse symlink follows on the bootstrap token and logs, and stop yanshi from overflowing label ranges or recursing without a depth cap. --- management/tcontrollerd/pkg/log/log.go | 3 +- management/webserver/api/bootstrap.go | 50 +++++++++++++++++++---- management/webserver/api/cert.go | 25 +++++++++++- management/webserver/api/errors.go | 7 +++- management/webserver/api/policyrule.go | 31 +++++++++++++- management/webserver/api/website.go | 8 +++- management/webserver/pkg/fvm/fvm.go | 10 +++++ management/webserver/pkg/fvm/generator.go | 7 ++++ management/webserver/pkg/fvm/helper.go | 6 +++ management/webserver/pkg/log/log.go | 5 ++- mcp_server/pkg/logger/logger.go | 3 +- yanshi/src/compiler.cc | 14 ++++--- yanshi/src/fsa.cc | 9 +++- yanshi/src/fsa_anno.cc | 3 ++ yanshi/src/loader.cc | 18 ++++++-- yanshi/src/main.cc | 4 ++ 16 files changed, 175 insertions(+), 28 deletions(-) diff --git a/management/tcontrollerd/pkg/log/log.go b/management/tcontrollerd/pkg/log/log.go index 4abb6d38..ec2af6af 100644 --- a/management/tcontrollerd/pkg/log/log.go +++ b/management/tcontrollerd/pkg/log/log.go @@ -5,6 +5,7 @@ import ( "os" "runtime" "strings" + "syscall" "github.com/sirupsen/logrus" @@ -43,7 +44,7 @@ func InitLogger() error { return err } - fileFlag := os.O_WRONLY | os.O_APPEND | os.O_SYNC + fileFlag := os.O_WRONLY | os.O_APPEND | os.O_SYNC | syscall.O_NOFOLLOW if !exist { if err := utils.EnsureFileDir(config.GlobalConfig.Log.Output); err != nil { return err diff --git a/management/webserver/api/bootstrap.go b/management/webserver/api/bootstrap.go index 778c1ff5..61214fc2 100644 --- a/management/webserver/api/bootstrap.go +++ b/management/webserver/api/bootstrap.go @@ -4,9 +4,11 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "io" "os" "path/filepath" "strings" + "syscall" ) // BootstrapTokenFileEnv names the file the bootstrap token is read from when @@ -54,10 +56,8 @@ func EnsureBootstrapToken() (created bool, path string, err error) { } func loadOrCreateBootstrapToken(path string) (string, bool, error) { - if content, err := os.ReadFile(path); err == nil { - if token := strings.TrimSpace(string(content)); token != "" { - return token, false, nil - } + if token, err := readBootstrapToken(path); err == nil && token != "" { + return token, false, nil } raw := make([]byte, bootstrapTokenBytes) @@ -75,15 +75,16 @@ func loadOrCreateBootstrapToken(path string) (string, bool, error) { if !os.IsExist(err) { return "", false, err } - content, readErr := os.ReadFile(path) + token, readErr := readBootstrapToken(path) if readErr != nil { return "", false, readErr } - if token := strings.TrimSpace(string(content)); token != "" { + if token != "" { return token, false, nil } - // Empty leftover file: replace it. Do not truncate a non-empty secret. - if err := os.WriteFile(path, []byte(value+"\n"), bootstrapTokenFileMode); err != nil { + // Empty leftover file: replace it without following a symlink. Do not + // truncate a non-empty secret. + if err := writeBootstrapTokenNoFollow(path, value); err != nil { return "", false, err } return value, true, nil @@ -97,3 +98,36 @@ func loadOrCreateBootstrapToken(path string) (string, bool, error) { } return value, true, nil } + +// readBootstrapToken reads the token without following a symlink. A link placed +// on the shared volume must not be treated as the token file. +func readBootstrapToken(path string) (string, error) { + file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0) + if err != nil { + return "", err + } + defer func() { + _ = file.Close() + }() + // Read the whole file. A prefix must not be treated as the token: the console + // sends the file contents, and a truncated value would never match. + content, err := io.ReadAll(file) + if err != nil { + return "", err + } + return strings.TrimSpace(string(content)), nil +} + +// writeBootstrapTokenNoFollow overwrites an existing regular file. O_NOFOLLOW +// refuses a symlink instead of writing through it. +func writeBootstrapTokenNoFollow(path, value string) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC|syscall.O_NOFOLLOW, bootstrapTokenFileMode) + if err != nil { + return err + } + if _, err = file.WriteString(value + "\n"); err != nil { + _ = file.Close() + return err + } + return file.Close() +} diff --git a/management/webserver/api/cert.go b/management/webserver/api/cert.go index 145e99fc..5948b545 100644 --- a/management/webserver/api/cert.go +++ b/management/webserver/api/cert.go @@ -34,6 +34,10 @@ const ( // certGenerationMaxLockout caps the wait imposed on a caller that keeps // asking. certGenerationMaxLockout = 5 * time.Minute + + // maxUploadedCertBytes is the largest certificate or key this endpoint will + // write. A PEM certificate or key is far smaller than this. + maxUploadedCertBytes int64 = 1 << 20 ) // certGenerationThrottle bounds how often this endpoint may spend a key @@ -49,6 +53,10 @@ const ( // succeeds or not. var certGenerationThrottle = newThrottle(certGenerationThreshold, certGenerationWindow, certGenerationMaxLockout) +// certUploadThrottle bounds how often an authenticated session may write into +// the certificate directory. Generation already has a throttle; upload did not. +var certUploadThrottle = newThrottle(certGenerationThreshold, certGenerationWindow, certGenerationMaxLockout) + // certGenerationKeys returns the throttle keys of a certificate request. func certGenerationKeys(clientIP string) []string { if clientIP == "" { @@ -91,6 +99,21 @@ func PostUploadSSLCert(c *gin.Context) { return } + if file.Size <= 0 || file.Size > maxUploadedCertBytes { + response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Certificate file is empty or too large"}, http.StatusBadRequest) + return + } + + clientIP := c.ClientIP() + throttleKeys := certGenerationKeys(clientIP) + if key, wait := certUploadThrottle.lockout(throttleKeys); wait > 0 { + logger.Warnf("Certificate upload throttled by %s, retry in %s", key, wait) + c.Header("Retry-After", strconv.FormatInt(int64(wait.Seconds())+1, 10)) + response.Error(c, response.JSONBody{Err: response.ErrTooManyAttempts, Msg: "Too many certificate uploads, please try again later"}, http.StatusTooManyRequests) + return + } + certUploadThrottle.fail(throttleKeys) + var dstPath string filename := fmt.Sprintf("%s_%s", utils.RandStr(16), filepath.Base(file.Filename)) if config.GlobalConfig.Server.DevMode { @@ -131,7 +154,7 @@ func saveUploadedFile(file *multipart.FileHeader, dstPath string, mode os.FileMo return err } - if _, err = io.Copy(dst, src); err != nil { + if _, err = io.Copy(dst, io.LimitReader(src, maxUploadedCertBytes)); err != nil { _ = dst.Close() return err } diff --git a/management/webserver/api/errors.go b/management/webserver/api/errors.go index f856502b..059cc812 100644 --- a/management/webserver/api/errors.go +++ b/management/webserver/api/errors.go @@ -11,8 +11,9 @@ import ( // response.ErrorInternal: its text describes the inside of the deployment and // belongs in the server log, where the operator can read it. var ( - errDataNotExist = errors.New("Data queried does not exist") - errRulesCompile = errors.New("Rules compile error, please check your params.") + errDataNotExist = errors.New("Data queried does not exist") + errRulesCompile = errors.New("Rules compile error, please check your params.") + errPolicyRuleEmpty = errors.New("Policy rule must contain at least one pattern") ) // responseMessage returns the text that may be sent to the caller. @@ -22,6 +23,8 @@ func responseMessage(err error) string { return errDataNotExist.Error() case errRulesCompile: return errRulesCompile.Error() + case errPolicyRuleEmpty: + return errPolicyRuleEmpty.Error() default: return response.ErrorInternal.Msg } diff --git a/management/webserver/api/policyrule.go b/management/webserver/api/policyrule.go index 8bf4ea13..8b1b0e32 100644 --- a/management/webserver/api/policyrule.go +++ b/management/webserver/api/policyrule.go @@ -1,11 +1,13 @@ package api import ( + "encoding/json" "net/http" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/fvm" "github.com/gin-gonic/gin" + "gorm.io/datatypes" "gorm.io/gorm" "chaitin.cn/patronus/safeline-2/management/webserver/api/response" @@ -26,6 +28,11 @@ func PostPolicyRule(ctx *gin.Context) { return } + if err := validatePolicyRulePatterns(params.Pattern); err != nil { + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusBadRequest) + return + } + db := database.GetDB() err := db.Transaction(func(tx *gorm.DB) error { policyRule := &model.PolicyRule{Action: params.Action, Comment: params.Comment, IsEnabled: params.IsEnabled, Pattern: params.Pattern} @@ -59,7 +66,11 @@ func PutSwitchPolicyRule(ctx *gin.Context) { db := database.GetDB() err := db.Transaction(func(tx *gorm.DB) error { - res := tx.Model(&model.PolicyRule{}).Where(params.IDs).Updates(model.PolicyRule{IsEnabled: params.IsEnabled}) + // IsEnabled=false is the Go zero value. GORM skips zero-value fields in + // Updates(struct), so a disable request would leave the row enabled. + res := tx.Model(&model.PolicyRule{}).Where(params.IDs). + Select("is_enabled"). + Updates(model.PolicyRule{IsEnabled: params.IsEnabled}) if res.Error != nil { return res.Error } @@ -88,6 +99,11 @@ func PutPolicyRule(ctx *gin.Context) { return } + if err := validatePolicyRulePatterns(params.Pattern); err != nil { + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusBadRequest) + return + } + db := database.GetDB() err := db.Transaction(func(tx *gorm.DB) error { var policyRule model.PolicyRule @@ -153,6 +169,19 @@ func DeletePolicyRule(ctx *gin.Context) { response.Success(ctx, nil) } +// validatePolicyRulePatterns rejects a rule that would compile to a selector +// with no WHERE. An empty pattern list matches every request. +func validatePolicyRulePatterns(raw datatypes.JSON) error { + if len(raw) == 0 { + return errPolicyRuleEmpty + } + var patterns []model.PolicyRulePattern + if err := json.Unmarshal(raw, &patterns); err != nil || len(patterns) == 0 { + return errPolicyRuleEmpty + } + return nil +} + func GetPolicyRule(ctx *gin.Context) { var params pageRequest if err := ctx.BindQuery(¶ms); err != nil { diff --git a/management/webserver/api/website.go b/management/webserver/api/website.go index b7268a06..2f9e3168 100644 --- a/management/webserver/api/website.go +++ b/management/webserver/api/website.go @@ -122,13 +122,17 @@ func PutWebsite(ctx *gin.Context) { website.CertFilename = params.CertFilename website.KeyFilename = params.KeyFilename website.IsEnabled = true - tx.Save(&website) + if err := tx.Save(&website).Error; err != nil { + return err + } if config.GlobalConfig.Server.DevMode { return nil } - err := publishWebsiteConfig(¶ms) + // Publish the row that was just saved, not the request body. The saved + // record forces IsEnabled and may differ from params. + err := publishWebsiteConfig(&website) if err != nil { return err } diff --git a/management/webserver/pkg/fvm/fvm.go b/management/webserver/pkg/fvm/fvm.go index 2ed97ffe..b9dcf7ef 100644 --- a/management/webserver/pkg/fvm/fvm.go +++ b/management/webserver/pkg/fvm/fvm.go @@ -56,6 +56,9 @@ func maybePointer(array []byte) unsafe.Pointer { } func (u *FVMUpdate) ToBytes() []byte { + if u == nil || u.update == nil { + return nil + } return C.GoBytes(unsafe.Pointer(u.update.buf.ptr), C.int(u.update.buf.length)) } @@ -89,7 +92,11 @@ func (u *FVMUpdate) MergeUpdate(patch *FVMUpdate) error { } func (u *FVMUpdate) Release() { + if u == nil || u.update == nil { + return + } C.fvm_update_destroy(u.update) + u.update = nil } func (r *FVMRe) ToBytes() []byte { @@ -189,6 +196,9 @@ func ReleaseOutput(out *FVMOutput) { } func (f *FVM) PushFsl(server string, update *FVMUpdate) error { + if update == nil || update.update == nil { + return errors.New("refuse to push a nil policy update") + } log.Infof("Push FSL to %s", server) var length = uint32(update.update.buf.length) diff --git a/management/webserver/pkg/fvm/generator.go b/management/webserver/pkg/fvm/generator.go index e638333f..8f946fd3 100644 --- a/management/webserver/pkg/fvm/generator.go +++ b/management/webserver/pkg/fvm/generator.go @@ -205,6 +205,13 @@ func PolicyRuleTable(db *gorm.DB) (prFSL string, err error) { } } + // An empty pattern list would compile to a selector with no WHERE, which + // matches every request. Skip it instead of emitting an unconditional + // DROP or ACCEPT. + if len(wheres) == 0 { + continue + } + // save log only when deny and dry_run logOption := "1" attackType := "-3" diff --git a/management/webserver/pkg/fvm/helper.go b/management/webserver/pkg/fvm/helper.go index 187c6ab5..e3f6e9c0 100644 --- a/management/webserver/pkg/fvm/helper.go +++ b/management/webserver/pkg/fvm/helper.go @@ -24,6 +24,9 @@ func CompileAndSave(text string) error { defer ReleaseOutput(output) update := Serialize(output, false, 0, 1) + if update == nil { + return errors.New("failed to serialize rules") + } defer update.Release() // save to file. The bytecode is not secret, but nothing but the management @@ -44,6 +47,9 @@ func CompileAndPush(text, serverAddr string) error { defer ReleaseOutput(output) update := Serialize(output, false, 0, 1) + if update == nil { + return errors.New("failed to serialize rules") + } defer update.Release() // save to file. The bytecode is not secret, but nothing but the management diff --git a/management/webserver/pkg/log/log.go b/management/webserver/pkg/log/log.go index a68a98ae..b6e3ad70 100644 --- a/management/webserver/pkg/log/log.go +++ b/management/webserver/pkg/log/log.go @@ -5,6 +5,7 @@ import ( "os" "runtime" "strings" + "syscall" "github.com/sirupsen/logrus" @@ -43,7 +44,9 @@ func InitLogger() error { return err } - fileFlag := os.O_WRONLY | os.O_APPEND | os.O_SYNC + // O_NOFOLLOW closes the window between Stat and OpenFile: a symlink swapped + // in for the log path is refused instead of followed. + fileFlag := os.O_WRONLY | os.O_APPEND | os.O_SYNC | syscall.O_NOFOLLOW if !exist { if err := utils.EnsureFileDir(config.GlobalConfig.Log.Output); err != nil { return err diff --git a/mcp_server/pkg/logger/logger.go b/mcp_server/pkg/logger/logger.go index ae8af43e..5a6e4b30 100644 --- a/mcp_server/pkg/logger/logger.go +++ b/mcp_server/pkg/logger/logger.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "sync" + "syscall" "time" "go.uber.org/zap" @@ -96,7 +97,7 @@ func Init(cfg *Config) error { // a failure to open the log file returned a nil error and left // defaultLogger unset, which turned every later log call into a nil // pointer dereference. - fileWriter, openErr := os.OpenFile(cfg.FilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFileMode) + fileWriter, openErr := os.OpenFile(cfg.FilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY|syscall.O_NOFOLLOW, logFileMode) if openErr != nil { err = openErr return diff --git a/yanshi/src/compiler.cc b/yanshi/src/compiler.cc index e543cfd0..22004cca 100644 --- a/yanshi/src/compiler.cc +++ b/yanshi/src/compiler.cc @@ -430,9 +430,13 @@ bool compile_export(DefineStmt* stmt) unordered_map start2stmt; vector starts; vector sub_final; - function allocate = [&](DefineStmt* stmt) { + function allocate = [&](DefineStmt* stmt, long depth) { if (stmt2offset.count(stmt)) return; + // stmt2offset stops cycles. It does not stop a deep acyclic embed graph, + // which still recurses once per statement. + if (depth > MAX_EXPR_DEPTH) + err_exit(EX_DATAERR, "define graph deeper than %ld", MAX_EXPR_DEPTH); DP(4, "Allocate %ld to %s", allo, stmt->lhs.c_str()); FsaAnno& anno = compiled[stmt]; long base = stmt2offset[stmt] = allo; @@ -455,10 +459,10 @@ bool compile_export(DefineStmt* stmt) if (has_start(aa.second)) { if (auto* e = dynamic_cast(aa.first)) { DefineStmt* v = e->define_stmt; - allocate(v); + allocate(v, depth+1); } else if (auto* e = dynamic_cast(aa.first)) { DefineStmt* v = e->define_stmt; - allocate(v); + allocate(v, depth+1); // (i@{CollapseExpr,...}, special, _) -> ({CollapseExpr,...}, epsilon, CollapseExpr.define_stmt.start) sorted_emplace(adj[i], epsilon, stmt2offset[v]+compiled[v].fsa.start); } @@ -474,7 +478,7 @@ bool compile_export(DefineStmt* stmt) for (auto aa: assoc[v]) if (has_final(aa.second) && (e = dynamic_cast(aa.first))) { DefineStmt* w = e->define_stmt; - allocate(w); + allocate(w, depth+1); // (_, special, v@{CollapseExpr,...}) -> (CollapseExpr.define_stmt.final, epsilon, v) for (long f: compiled[w].fsa.finals) { long g = stmt2offset[w]+f; @@ -488,7 +492,7 @@ bool compile_export(DefineStmt* stmt) adj[i].resize(j); } }; - allocate(stmt); + allocate(stmt, 1); anno.fsa.adj = move(adj); anno.assoc = move(assoc); anno.deterministic = false; diff --git a/yanshi/src/fsa.cc b/yanshi/src/fsa.cc index 42c3e0d1..2a415ab5 100644 --- a/yanshi/src/fsa.cc +++ b/yanshi/src/fsa.cc @@ -375,8 +375,13 @@ Fsa Fsa::determinize(const vector* starts, function= 0) live.insert(ev.second); - else - live.erase(live.find(~ ev.second)); + else { + // A reversed interval (signed overflow of end = start+1) can close an + // event that was never opened. erase(end()) is undefined. + auto it = live.find(~ ev.second); + if (it != live.end()) + live.erase(it); + } } } sort(ALL(r.finals)); diff --git a/yanshi/src/fsa_anno.cc b/yanshi/src/fsa_anno.cc index 6ac94737..c0fcd4af 100644 --- a/yanshi/src/fsa_anno.cc +++ b/yanshi/src/fsa_anno.cc @@ -447,6 +447,9 @@ FsaAnno FsaAnno::embed(EmbedExpr& expr) { r.fsa.start = 0; r.fsa.finals = {1}; r.fsa.adj.resize(2); + // macro_value+1 overflows at LONG_MAX and inverts the interval. + if (expr.macro_value == LONG_MAX) + err_exit(EX_DATAERR, "macro value %ld cannot be used as a label", expr.macro_value); r.fsa.adj[0].emplace_back(make_pair(expr.macro_value, expr.macro_value+1), 1); r.assoc.resize(2); r.add_assoc(expr); diff --git a/yanshi/src/loader.cc b/yanshi/src/loader.cc index 602c6602..ceb1c99a 100644 --- a/yanshi/src/loader.cc +++ b/yanshi/src/loader.cc @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -223,7 +224,11 @@ struct ModuleUse : PrePostActionExprStmtVisitor { // enlarge alphabet expr.define_stmt = NULL; expr.macro_value = d->value; - AB = max(AB, d->value+1); + if (d->value == LONG_MAX) { + n_errors++; + mo.locfile.error(expr.loc, "macro value %ld cannot be used as a label", d->value); + } else + AB = max(AB, d->value+1); } else if (auto d = dynamic_cast(r)) { depended_by[d].push_back(stmt); used_as_embed[d].push_back(&expr); @@ -317,7 +322,12 @@ static vector topo_define_stmts(long& n_errors) vector st; unordered_map vis; // 0: unvisited; 1: in stack; 2: visited; 3: in a cycle unordered_map cnt; - function dfs = [&](DefineStmt* u) { + function dfs = [&](DefineStmt* u, long depth) { + if (depth > MAX_EXPR_DEPTH) { + u->module->locfile.error_context(u->loc, "'%s': embed graph deeper than %ld", u->lhs.c_str(), MAX_EXPR_DEPTH); + n_errors++; + return true; + } if (vis[u] == 2) return false; if (vis[u] == 3) @@ -341,7 +351,7 @@ static vector topo_define_stmts(long& n_errors) st.push_back(u); bool cycle = false; for (auto v: depended_by[u]) - if (dfs(v)) + if (dfs(v, depth+1)) cycle = true; else cnt[u] += cnt[v]; @@ -351,7 +361,7 @@ static vector topo_define_stmts(long& n_errors) return cycle; }; for (auto& d: depended_by) - if (! vis[d.first] && dfs(d.first)) // detected cycle + if (! vis[d.first] && dfs(d.first, 1)) // detected cycle or an embed graph that is too deep n_errors++; reverse(ALL(topo)); if (opt_dump_embed) { diff --git a/yanshi/src/main.cc b/yanshi/src/main.cc index bac75850..bdb2b17c 100644 --- a/yanshi/src/main.cc +++ b/yanshi/src/main.cc @@ -135,6 +135,10 @@ int main(int argc, char *argv[]) case 1005: opt_dump_tree = true; break; case 1006: opt_max_return_stack = get_long(optarg); + // The value is the bound of a generated C array. Reject non-positive and + // absurd sizes before they are written into the output. + if (opt_max_return_stack <= 0 || opt_max_return_stack > 1000000) + err_exit(EX_USAGE, "--max-return-stack must be in (0, 1000000]"); break; case 1007: opt_gen_extern_c = true; break; case '?': From 9b72ba6ad4d7e042e7a79ee2e062305179312c98 Mon Sep 17 00:00:00 2001 From: wenwen Date: Thu, 22 Oct 2026 10:30:55 +0800 Subject: [PATCH 31/31] Update SafeLine production-ready statistics in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根据最新数据,更新了“Is SafeLine Production-Ready”标题下的三项核心指标: - 全球安装量:由 700,000 更新为 700,000 - 防护网站数:由 1,000,000 更新为 2,000,000 - 每日处理 HTTP 请求量:由 30,000,000,000 更新为 50,000,000,000 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1fa4f1dd..464cd3a8 100644 --- a/README.md +++ b/README.md @@ -117,9 +117,9 @@ to see [Configuration](https://docs.waf.chaitin.com/en/GetStarted/AddApplication Yes, SafeLine is production-ready. -- Over [400,000 installations worldwide](https://cyberpress.org/safeline-waf-reaches-400000-global-deployments/) -- Protecting over 1,000,000 Websites -- Handling over 30,000,000,000 HTTP Requests Daily +- Over 700,000 installations worldwide +- Protecting over 2,000,000 Websites +- Handling over 50,000,000,000 HTTP Requests Daily #### 🌟 Community Recognition