From c674db4746bffc2294d064ff236edf16302235a3 Mon Sep 17 00:00:00 2001 From: Nirav Patel Date: Wed, 19 Aug 2026 09:17:26 -0400 Subject: [PATCH] security: harden granthi-link + client against 7 codex findings 1. CRITICAL account-takeover by login collision: persist zitadel_sub -> gitea_login identity map (state.json, 0600, atomic); mapping wins, deleted logins re-created only if service-created, existing unmapped logins bind only on verified email match, else 409; token never minted before binding passes 2. test_mode now gated behind GRANTHI_LINK_ALLOW_TEST_MODE=1 env 3. refuse startup unless config.json is 0600/0400 and owned by service 4. client config created O_CREAT 0600 (no write-then-chmod window) 5. credential-helper command paths shlex-quoted 6. POST bodies capped at 64KB (413); missing/invalid Content-Length rejected 7. Gitea 409 on user create handled idempotently (re-fetch + verify email) Co-Authored-By: Claude Fable 5 --- .../granthi_sync_client.cpython-314.pyc | Bin 23401 -> 0 bytes client/granthi_sync_client.py | 20 +- .../__pycache__/granthi_link.cpython-314.pyc | Bin 18494 -> 0 bytes server/granthi_link.py | 266 ++++++++++++-- tests/__pycache__/test_client.cpython-314.pyc | Bin 16251 -> 0 bytes tests/__pycache__/test_server.cpython-314.pyc | Bin 14805 -> 0 bytes tests/test_client.py | 55 +++ tests/test_server.py | 324 ++++++++++++++++-- 8 files changed, 610 insertions(+), 55 deletions(-) delete mode 100644 client/__pycache__/granthi_sync_client.cpython-314.pyc delete mode 100644 server/__pycache__/granthi_link.cpython-314.pyc delete mode 100644 tests/__pycache__/test_client.cpython-314.pyc delete mode 100644 tests/__pycache__/test_server.cpython-314.pyc diff --git a/client/__pycache__/granthi_sync_client.cpython-314.pyc b/client/__pycache__/granthi_sync_client.cpython-314.pyc deleted file mode 100644 index a8eceaf447577ac7ff4a0df5b73b8d428a659217..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23401 zcmbt+32+&C?j~2S;I5lGQG)el-ksclA7H(o@C;x*#k5v6Yg-!o?6$=q_(EYvAsJp z%IwyD|7&yuq~JKU+Y%qTU%&Ui>wo|Ky4_Ra*{>~bW54#eo&7rE4)*JeyV$Qg z_glh#O(#6ZJ#o)*Z`|9!N#=OzlfvK@y-WGu!RGwEeut7R`hEU)(3ij0?<7I8>@sOj zyD&Ps;#Hr~l3#mYm_Au0S@rtyYRML_k?iqW$r0ZuIpdooS2Y)}leS3iYHo^;Zr-QaSP(r3$25q$a77m2bjb6}#IiZNQsmsTygERD-ltszur+ zZA9vmHYwYrI^4BOn~`=%^+^3v1JX`ut0XcQfhV_1jr%TeTxcW5)p1YS2QL+lM7wX~ zo(wc{yK*n|8NvMXP3+It9K0-W+=LvQiBCrS%C(s>acn9Qo{5X0V0bz@)9G{`iB3-k zXF^K1QxvBnGf#=)6MnxEmM@28@lomYlk^ymJ{6u3A3rhFd$jNL=+N0yeFH2%6uul8 z3yTB2XZoH*b?tgQ5)X#LQ{vg)+4!WGD>6P6y&?uDf{_^|E>6bdF{L{Yh=e+oNjcmZ zjEHSfS=8GZkIDwH_+(g|3JKsDwMjspv#x2B2x6xu4OQ;B@#|F*0KS_;R3A zZ-J&VFczH|k4$uaQ9<)<+xKkW?xV(np%5mbXFNI;3d@IR`UsGy%~(v1Tn@&=0lz;s zdub{%mY+8C25li}yM*N$n-r%pSw1l`F3yC*p>U`}9FxPS$_6?%6`c)$5(3h}4TNQCK5<&q3$`??{pRe=P2V9!uiP?j8084>;)Wc;+1YO zI2(`V209fTqft)=XC}g!obfQA;%6MIfQnauP)45#k8*vWk;Uc6SbXZ57$hw0VDrVw z17ir}14XFQ+I95AaG!Ln@2IG)ConH4hY8Up(8+eRF&33++?WjU+^G|TgDC3v=Vk?a zU@Y8Wz|y8_IxJ6w#Wsb|DHa9xpj!%?w6GEfYG460oF?q-j9n9_!^$MXNv%icP8=KR zlg{|ZrousNeG#Jz#%C3ym7(CJsjwIwUpJH39YO3`0@@*lB3QJEFlJvIp9)SWpdjbs zp{d9v42e+W$mtVebY|*Wr*pt(QtcshD;}8+t9D8g(V1|#=k=fh zB<#(#3$ku$;(XRbdGllb=4pR(XsG#kck`L<=5vp${1XY&lj4w%S1sdH*wX_(t4#G& z^Gx)LOyg0_v+*(24ul?O3t|B&nu%kL%+tz*YKkigm2VN%`G8KO;?uFf*i>*f6!!1l z-WBja3d*VYgON+ocwq0?j_}S)!9D&E=G?#Q(vD01ecQYC`1g1`@Ch)$FYv2O;#j*|C25CaqU6M&M=aB)6XVN8&25wjg z_Y#~DqYs1jb=C1WXdprU-LPPwP)R;lztcH?%=#I9Dv*~NxHH|u=I~zD504IEkfwLt z)yNH-|36zbq^yR*F_*7v2#z7_5D;mOh_&3#n`UCI49B>EEv0>NjwkxC^ceP90Z5HS6ZKZ9N1c7(-5d zZg-lRfLD}?Tsx;jnZP!z<07U8ZZq(X{+-@+z`U7@@M7US)IA7$u>Qe3D;H`Zcro{o z`?rl4Z^Z{|%Rk;sd&B*Z`}Ae)H^LX-aIrpYc@WCTRy}X03H@R6Y|Je=BX69P*P~=a0(%r(5OVmDX-C9=;lX*4H_Za1tT(_ks(?6D4a1Tf%)L z3PEKi?jO1q3nv(8uso0q)JsZhq-zT)j#@^)ps zyXJ+g-F?G*-JA5J+fLoHpUyf;-f`5fdMj=uUQ8snX1on6-j*eAOUB!_rfm1J{lKcT z^hW3P&K0M)@!N@SBvMV^{JpeyciO)D zPM7xk4_*`lkl%dhD!b8hy=TSMxa4a5l;bU}*$vgV$8U~*b27!hZhh7I_2J*Fy322z zzJ7Ye-MHj#d~I*q)lLOlvflEA^WPauHGX?MUD=%WHs7;1|K=kz-u&iMJLld&g{T)v zOHz3y@p9r$Z8DK*IFPRHNqc+VvG-8XgZGswtj1rKwe?kUf4RS|kGK4+%8vV=)!UC% zb3bd{KaQ^WRYeQ#|Eim(^nmA>*Zd0y z-T%UCp|ra3*a7n|@b-Xt4DA`04R=J{KY0tu9O!#qDj30*(=MG-72zwNBMySI8gj&} zdDbMsIs-A#;3uEQ2_&v5PX!GL0#p!kg(jsC<;+vJH~0acMHSdSbsaf7(0}6C=wR>A zaha&JD$wpz%laV)4@Ber(b<_0+k>DX{J26SGq+z{RB%+2#pQ>Q|0MpDULCB60LpKj)nt`XH~2H0v1u+96si|Uwl zUNY@9<~`5TBy$IV>K6oaLvSDFB|(?yimbe8gjwXM=Ac`zpZ{&-&YLcpBul$eWndA?OFfG$Y~Rs}UtW z8ht7ubV4ss`76X~A~R$U7>LZFtk0pEqKayt4n7qQML?CEIgC4cLXvyXU-KLd5FNbvuJcBo3{3%~R0*yehzHsXKQ@5O{ zsymhUggvXys-*RvvwrT#s-t9nY+>h(`mfa|cdUXMJ6?1wG^NV!*_(iw&YBNV^Z4_} z=g%!PFAFsU=j8Jz=apNh-W9}uu%W<>`uhqAokv8P>)^1CY`7hq93$B(*JU38Z!?YYDoxlPzN!+n9g~T+jj+K zL(Di74g-B9i)1aTU>Ge!>$H*9DcJ$H2nr7p?_7830yESdt3HBBzIeZoTSVFTZBR)x z_Bj$g((DYgEx&%3I0VrR0uI@aV(gK6D5zF#0P+APQWb*ogrW)rUlEYe zvUQt=@A4p?v;;TG*OAO|AG)e;9D4Cka(8O$qBXrS@WZWl;{R^jy^TFtpa0I$PtCl$ zd+zAp+T073w;OIYqwq`SgTTt9NH z>^rfv??h(bsr25{Y4@2=%)Iw3{}IPK&hik2Zaj4Tq2$q5PQ83;rOv-p=g-sy(yl;S z2*_pVk+B1C&l#lnKI>}%c{12071ORk1Ml@5u8^P=t3ix;-8D?CGgnr}Jzbv&Vb9co z$Tt=20F;M{PA&w=x;NK@E2uA*55qCS_RQwBdxmQ0Er8@2dkOhA$u2o|(W2ohFt|JK z3!Olx0aYMdF7WOW=qC)h{crGW%@c0r+Vmx|-TCG4xGEg)>phzA;#LWexUU45)z>2T z11vBD25;38Q9w@R$C1NO!e^E*(p!@}rt+i8S~?k?Q3$`u2rNH^QqSN|r;|3v3s;`I z^1`#vJ$p;Keg5Y8+aoteQZ0)unW`Ntwyt+1j!6tXlo_|Z7Z3zy2YLo=gx|8Ux#NrSaGgBR=#fy4nPJhgubR^|M_9&uhZZ<*1>fSo7UE4 zm{v%J_O9z0Fsry#PiML@+xg1{ExMby^RordC9s_kc6t(ja0CW?B{F_3hntrb-NO+a zhgU@mY7P;Y@^!WfJ3agW-D2xZ6m*KbNPf+W0UuIpWQKVX>6YQTpZP{wJDjb#qU_l} zK29Za{(?|zr!(P@!WX-zZ}M5-!EO? zhx@i%4-!u15%H4?53UByGZDHEbp@^&tylL!TUqz@KKV@AmLLx0vul*ps+Cc#+8E^s z-m%?41`2>8c27V?mB}FN5NJ&yeh0$>Gb`ja2s2(YKaBhw-rmHY@*^bJAa1Ut6lB$v zdi+jpw!9%_Or4%JzcuvZWUN}$-_KR+7C2J`<}OP70UFUYw_8;!*`Fr+56`Bn-|jlflTGu=g!RaW^L|e+s602HCnBn#SPi2EvY@Jw#CN9 z{>6Riid}d6&`9YK9-#7#)MmlryRUb@)H8P!=8R=q?R(xj+W2#qrg@3?e8EPG36d#h8HTrmp>Gr8!i6vx&t@CX zrfus2&TEfZJ_{-!Itn&KDK?ZKmF>qsLNJv=IxcL(DY?#<@5*gRaCG;uS!YQhkb97d z5|T$HH>JZC;~ik&T{+%VG;xI{TBzmBARMzwp1kZZWXQ=mGp9FZeF$oq#D4Q;PG4o> z$7it%39Dd`g!dc78lE+{-I!xJ&qUfFS%UV%i5&{ZA-}@m=gei1PxWcWe!?NzjO6Xr z?wAX3&Hlt|NUTx0_1R7*+^KLbc9VmYe0lJCPEEmW3JW6uG9U_s?LHV2bve5u_sBM6 zrxRW%a`JXYp{TUCUvn zMfen1g~UOP%aZR@+nXYsQj_p{Uxx2I96BH>Peo#}$jpS;HWQ_(gEv*g9qWk?P!iMw z=t&&Z0>&<3i*@82!r>4yEo_S9t5n3oSe`~WXm87pQR1gpHjQ_ZoFzYtM0FS)C+Q#V zPZeNCX$Hd~c`r&RBpBsrhekSFjCABQ0}}SXOU3>g3G6HG3QNOkWqtB;rm|(;p^09{ zlEcgPt*fOQlY18TWJspWYluSDZ|{ zPrliv{r;`1GF#D>cDH4{jcI%1Kd!o}$-dK&^=|l%IobPd>-_KCv)3~lV8eZ-2jh9Z zxB9S?`{_oJ(gP++5805uW#uWgv()K2++lvJ%6+)i{8l4R_pKJBYxE78L)yKcm>}EC z!K`NNM+gYej1ltGu@Ucc*BQ8NVxUA4pJ)9SVJHi5F%xkD?JDNsE#>-ma~yPUb1|*O zV8wW^_ds_Uo~c6}>gT!(qA#c?T~{yyxi4lte`#1~!SgAghl8sx&@>GAb z9bH3-*+&emeXxiLX^@n*b=R<6?+aXFrlKua^b&B26>;D(`g)L2Q^S=fkvpj?KrIEb!ZK@RrA_g;8Fdm0F43>kLu*yHBNf!KN`4rW+u%4>I6@+LhB;0B; z1ql`!aVQ^~4vnJFXg-ccehuY_JSa;@=D7DOYvwEGgMV0^3~SojqhERYJ#T5&?V0Oa zEv>lixaEL{fgBIl6E8h8FTC%qc+XvyE#Gjv^JZtNB2(Uc-8Rq92eTD`@{;}6F0O1N zWER(km;7@_vTpB`eCZU#WzLm)1>#8g$r)k@NX`-N-IU0c|YvDIh>gJoOm zs=Z{vk!*UU_2t%7>ta`CV`sWHkoIg}w!;?pnXle+xgS&|Nq}d&U5m+(KF3-B8y25; z#TT=Y6^qBn8*p`ur#Or*3yi=eXrEn>XPAOfAb+r!43%$`&!I%ZONOd~kS9{hC`c?( zmS4V3Ep}=EyyG_NsLt@!@Yrmef^V4KFHcaeEqF;``oDd2ltNWTM>Xx=l#^LCAi1Co z4OMC>x(+I60ji8@#pLe+9h_pUA@9s;DwAZ!p3d8wq3%{)-nFqwWvE1ruF#Iu4SmFhhoH16~qvTo>1VdV3m zBW6`+4*K{z@_@k9e%F?U&HFH0p%>YD7=0?n4&dQ3*c;|5H|rd2$U=N5>oQ5@QiW8B z8LTXv!NM4tfI06kVVEH>%(#G_XiQ#^stW6pcwcn&H%Qg`JV4E`NHuw-;MDJP5ay*? zmIKNS(ZO`l(oujzG1IAUaRn?{+Bhr}GB4ntVHlJ)>GM3v`|1)$)`r4%b#B+gVPQ;} zjLMM&iww}hR`UXVaE#2&T0mx}Do~_rqCOC%I)Tew0WG3-bPWhdoh*`SG!g>y8iT{n z$ICmh)`=~-0Ngdjbvt((IqJpxW?#3MXmAciwE$oi(y3MJ5XpDg?L>&6TFVsnTr|Vz ztQ>(qW;!+%hJ;(X{*}r*5!nFY9Nr-q7|2fEEh5SQ92m7IB0!JlB4PVJ#T%2Gb2rE8T2bx>*T7mFUh*4{OC$_~6#&b(8^R z+JyW~%#d2OW@<(;Uhw`2)uA9{c{CKgGLxvx%^?e7)?5Ubu&sr;%r59d(ZW$rAPaQPaNqRMX+Swz|iQ4qpCZXbMDC5!9LY_?wS%0PxoDo z#F;q3gbvk=IfKgrEn$tP!r>Uqd(2!%k#X{$(OB#u7?GKrVa703Gc-Ddm?w!7x~B+Kbm-dip!(QkAdQyjru^ao3z_?8$6Cc+Yzf@133-z88Hj zwI}V%PLX_n&KI2v`s~Z}!?($pDEX<_NrYm=4%j?tP02yT}Q`7ev@w|DtA@GsK zRKDX=i%{Kl-8FCh$jQ0O7tVd{V76kz!k6z=w4~iFSx?!GiR%*!6G=Ja*)ng=x;*J} z-?Gd1p1TtF3+>CU%~^M4lK=Xd`QylAGZnaFUYt$W?#_7jr0sB2R;H^CWSl)|q31!F zhe!~Ud5Cn8hBJta^;ZEq16!gHfV@pZXA_WLa#jtHVS2v5P)_7l>-8kh`d(mm zyvSSASLBth?+NnCMqpvmF3hH(%QZmzg-%p1|69CcW|o9qKPsVCCbaVzqlM&9)rkV> zM{rejp<<$(9izy(L^W0+{{SywFJZ?+UpaJYPqg13sAF4+3^R$vQFNHP*Q?@kW*-m;Ebyi-+3oB4xsbMrRTUm4a zSm4S3+;I;NY_0OGIWn>am>A=kcD;3R470szJP^NTwJFKmxyRx3Gsp+g|Q*!8) zCtiL6fi9kHpIXe7?TmEV3rVM+Ib+Y=t-K4;`B295aN7Pb;uo0SCD>j#`P@nJiM-PO za{J20j-`zqi{~;McVtWJlA%=3z54Ch(&}Voa^jCS7u@LYK5_`9U7vD-Whcm&#s0#; za{~+elb4o-##O<)U|trOe`edlP_p$~PcEEI?OGHTNA9(Cr&|xe0o+p#^>E>td*xfx zWzDH4eh5nv#^HE~>^l$he^X3J%jp8#C!`z|>H@}w=0Se={g~7cleF@rDo<)4O`*1S z{jP+nI7!lp7v$;R4$&Y@db^Ma4H?!j>VP~I8jKOZLH;G99D*LWVn&w&0u3(B2sEK7 zyNb)tq+otdj}2pq4ymxmx=#wyy+LI1;{oZZ)oZQE-K6JA7La*sA;}|h4S5}FC#^B6 zQJ@IODmO@5aNPyj-KhtzXnwR|0Stj;cgCn7bFU$^G3%LL80X!r#5S5Kw6hL>#-Uh~OuK47;Q{RTA_DsYa?@6D)9b z7^fmxFTt9C5o>E~l&XiVc_+dd@4EM!*1jKU%D1QED^{owW}UPdy{ON_WV7BN)k|VN zuDaOyNqE7D;IV5EGrIoL+bbF~-6(C*z2G?2SQCKPb1<+T0$APFJgXZvkqL8Thh7S9 zAMA9xFrxg*G&A2}P91la#C=L2OAFmeAQts-3)@G*c{^ zgqe{v?wqBvjN=Z{ok9r-J?p{@WSQ~|9_@25&df}oTAS2dffn-~JsSR3g*0o!Agb|c z8}VsGLday!+Jse7{vHX!0CQ~9=2;knE0g5y+9Mgy(X{>OYKiy8`RnIzj9wqrSaPPs z2jiBj|D6|t%D1J zRBgJnJ?p7jP?CGU^-OAbap=zeyP=;<{RrCCDblXatktd{c_cU8t8BjR$U3|WJHNI$ zTfH&abFaEBy}`FwwzzZg%D?GZ?7Yk0-FkP!-FUk4sOBGfD!DbeA>FVy?cR6KzW+l~ z(RSS#zH|KUkvE0Am(#WV8PBn_{g~$Av8Q~CkKGL~yN^B)>T^69S**Qt;-3A`hb;8x zcru)N?2cpE-n}Y#<}WV`8#Mc%H7TIvhGk*<2ha+Ig~^mPYp=L<;r7TkMqX=sz2nu6 zOjTf|YX4Hz{x|ohtM+HA&SdT73wGStM|8;~BQ&O3mINPy#Vw_VfYO=VkhCKl z>9N$Qw0--sutPTx_mAadY@h+hkub-!C8BwA&~uamO$a_{ifc3#vI7b*@(DxOYLST< z9OO)NV(X5D5kmmy;W%s&$|QLY$7W?2HVn-J%MKo6qsQ@vPB&Uc&kda&WV?WQ5dmp- z8m>Iwwk0Q9j-nOFs?+j^=-y#MTH?YuZ{RM4MIEs(tqUy+Kv=BH@ppw24;otS z`uK)1>Y~C`!Fa)v4UG@=a+gGh*m84L%rB*jX*$`4=j*@2M}-=4`3SYou439>F{4|a z{2L-vIb)cHo~NI5EL@dS2oWAJbiVWDMaKaFNgoFcgt-y1ej>O>vhF)h;*ew|c7*5B z{rC;-V{M}c+BI0I-{pV7FgNH=gN9zE=M=++P{@zl z$s!qC%#3Jgzzy7~AF=*wBGCFeC7)3zgZVNbGoc4U5^!CEUZAU<`n#NOakJj*e8dK# z9lY2LWg73W&|zdT;pi5pBZwNBnP41;@g5iziTh}bCeL?%iM)ivIPWYEQ5h0G6V^US zIxC6HH$uMq7wFAA5}%jxBB+v489si^z-58cZd%FvKs1KLn9SFyA}w`M{zB>>ls;1H zH1CLjBU3m*l?#JVaj1wQBbdrpU>K>|SX&u+(rM({S+JnnTy!^VU^w*^MjLuiSX{`m@R574Oz}yjzK^HYcqMSCiM$ zrM_kNHZuNfY)*~;`Gp@`xI6i_HM9Hd^2WjI-g#Sg)0Wg1mo{ym7v>*dve#r?<>?LE zmR;Lc#kSYof8ze$h3}8NF_ICxSHz=B;?cJwY4K=AyfA-sVbhX(GfFRfaTz=d?fH`Z z$$hD=)OhMby0r6McOY9*nY1mJG|>W;BPEDpu z1Mj-G|9TTw`_QL#jQqYUR6b~vGN^0^mI71=bEaJ+1FXNWpNy+*-BeK4rl8scOa3iGd+a9Q`wlg6(FUdqOCX{5SOc7A1d)1T=_D zL-Nm%tnr;XNUUVd%D=|DCx{fhj2k*%;(X!E^Ji|gkhlD`wq>C`pX0yjf30R&*ajA! zc5c5@`RAK|u=$SnuAH_XUKWmIot{;D`PbY`hP<(Ve*Y_$Z(FkNvfK%&Zytc@dh@HB z?^Sfp+gCm1HzF@aUV18deA%=0`;UJ2(N%Zpjgv2)T=>!-o_XI@`t{vZ5zW{emIXxT z74ecA2ACfL>w;G7PYgo4bexE3CKzPq&WDi9#4h5-Yt?&_n4tC*lOzmxD9Vs(Sgee} zdr-#OWz(e!97A^%DB=bAp8?07ms(;ly68J=!o%=4-+#l!t6`i}*~!BMV$;Wm=oW(# z=y~Ajxm&$=ka?t>%+ohy9)zi4{q5 z8$Kw268(NeKG_RatGuW^9-l!PG3?;yU(iSSU<>;AAnOFQmdH*$i=*zdrkFLt`Kp+n zov>@#E7WM7!aktF zC-G!tDDPevHL6MvsTc;tHcEihWlaYI}`q!>_KV;>Fk=S+nLD1Ag%xj73) z1)56pN5?dGnU zL~+ds1ynRfXp9dSqDlDJea0wJGI!-ogt-ysFl7A*EmL2dRpL0Aavv2{eoQ`!-!(^5 zt zNva_A4W3OnPWR%=PxR3k)vk-2@UlKk;9*wI1!zHY;pD$XqFQml9xh$g0w?kWtQyn7 ztL!X|YEDFAYwQtZIl(FN=oLjXO+aGi7yW@XyyrT;zEgYWtDICGk-H(w}dB(GK?%4aTa)3^I8kg-`Sm+my zW$%MM0`lvXSDtC^eyEOrlSm{u@>$+=U_>~JUUr3EFj%7A?Wvgm$U$}W8`PAax zOx4b8{gziIU!F`QR_eRnsqeb^=v@Ks(5gfKi!(VM`n8#>-3|?XclX`KA0A&WIrxL) zi(mTM*fJqwrfl20o^3xkKG&ZvIrz(xvUwg|nh)KGT#qbOANrnhXUF%xoT)yP@f>>B zPJle+@FV=w$2QJ$h&ic$#?|*#32Ts+$Tw5#|0U3Ojlc;OxBhCx#-o!(x=Kg^pG8SA zS=bHnF&|^XVu6Y<2|B5f|C$Ifp<;}IP6tUYqwUWK7?s@lAWeLiA~y=OTr|8?^5DC3 z2F%YaRXPG@s9nE3VyKwFSnLZdQkhQJ)`rop>oKW#>#n>*QYzOKV)VQCj3NFfKf2Ef zv6ZUyzI^8RH>@c^6ms3Av-udgl#2-~%+8Nh-l%_d25l8Dxd|Qt*yMHJ2J2I?c_0GE zuHRuE>ZOL?VIIU0iaKT2#d@i6O%IBVX^T_~+x^xx<%;!G%mKwAhRDCAFrWAm?m`tO(R7T$mL?-P=L9~Yh!3yP zXZx7{LW|de*^rL^PLTgy`-mRmjOm~R0;&|;s*%yOjEDG5RzJkN@(;;>^ANAs@ zoj%WkAgT}xGJY!2MgkN*42Q4N<${#rQSn-IR@QweBAoyD-a{P1i9ivebRJ7{>LbUN z`&tYg&0?PO#5T>xp@*{-2B?W`Yd_COkhC^cz7xPq<1@^Qk!WFk!cjAJe1(1Wjt+K+ zI5mmR(O37_>@e1wuxcMoq?yniG+8Voj$_j|97p3gHwj^*95W%8QnCRFwXA(qQFUp> z#)DHz7#g2ZPPH%%FR@v3Aw{DxF^UP+olQo}LjH4uv_KSArnP`>kxrSYBC{C29>>n* z7*=7l#5z+&JtB-wR49}9?ByC?jMDupyygZ55}vh@!oC{Sj`QGJEV-vwo|v76;dzig zMI@_kI6Ox2mgUK0!c#qzMW@qQ34CpVp0x+nbF{C&_tDcsqv!f09MWMHHPtf9JYM!0 z#HX=4i-M06#Z@<)O{2LbS6J|aT1T*^XXCR7CcZWrzB-0a%E2-|Iw41AW6bd){}g>- znlcl3=Z<{faNnrEUZ1q4o6pYIXB>lwmB%L^=*vDQzvr1dyDaS0pHHN> z4pPP=ddA7`nZ9rT*Y@u^SK1CNwH;V#>s@N=eX}Xk*8k?0?zNquvL|z8Z(XG)QE$zX z-g=r^JCkqi__DBB2h8rB?iHtR$?3~D+YxUs*j9wfC82WRiPW{bb#KShLglhBl+S)V z6}el&GW+!=uTYZ>dXu(vOCL4apKtQ?Ys#I&RLB_O@zh9q-&x8U)S+CXr$!x$J>A+v zP!1YUZY8L|mJeOtl<+6+TjtyLoAzY+AGz;r&A9f?9ev;Cg7fE*Z`snD+Lue)SG|p? zttt6mwINyoK#(@EN)!=(u*!9xEv{1tgjVj{Y>G1pLs81Le82A-2 zJM1VGKN>DDWvWZHjUp&>4AJoQKnWb#gnx3+F7{Os&2YR*75<)*M=4=A`4;X}Hyo|_ ze2P{@Tq^~#s*T`sseec-r(5e=G;&{_}t3uRQQlgK{)u&DMUD-U-0~|g=${- zq>JNAf60~nl5_o%bFlxoFZ(6uWH}YT8CZ-{Dx0= z)$$D=Kg^f$B_Fq$_`M&OZ=#yzc-+>=mwW_kzQZ$T`PgdaPx2qTOZlpgTReEVrGhW{ lv_s@;KkDZ>!9AZ?HrHZz(U)MZ54|)D!#8vjVI!Nj{|gN553>LO diff --git a/client/granthi_sync_client.py b/client/granthi_sync_client.py index f567271..d4f3328 100644 --- a/client/granthi_sync_client.py +++ b/client/granthi_sync_client.py @@ -29,6 +29,7 @@ import base64 import json import os import re +import shlex import signal import subprocess import sys @@ -101,9 +102,12 @@ def load_config(): def save_config(cfg): os.makedirs(CONFIG_DIR, mode=0o700, exist_ok=True) tmp = CONFIG_PATH + ".tmp" - with open(tmp, "w") as f: + # O_CREAT with mode 0600 -- the file is never observable with wider + # permissions (a write-then-chmod sequence leaves a umask-sized window + # in which the token is world-readable). + fd = os.open(tmp, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: json.dump(cfg, f, indent=2) - os.chmod(tmp, 0o600) os.replace(tmp, CONFIG_PATH) @@ -220,9 +224,17 @@ def cmd_git_credential(argv): return 0 +def credential_helper_value(): + """Shell command git runs for credentials. Both paths are shlex-quoted: + a Python or script path containing spaces (or shell metacharacters) + must neither break the helper nor inject into the shell.""" + return "!{} {} git-credential".format( + shlex.quote(sys.executable), + shlex.quote(os.path.abspath(__file__))) + + def install_credential_helper(folder): - helper = f"!{sys.executable} {os.path.abspath(__file__)} git-credential" - git(folder, "config", "credential.helper", helper) + git(folder, "config", "credential.helper", credential_helper_value()) # -------------------------------------------------------------------------- diff --git a/server/__pycache__/granthi_link.cpython-314.pyc b/server/__pycache__/granthi_link.cpython-314.pyc deleted file mode 100644 index eb10fe5ec8c3b9e6ccb8ebbaf514bce84115cbe7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18494 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 diff --git a/server/granthi_link.py b/server/granthi_link.py index f12a717..83f3157 100644 --- a/server/granthi_link.py +++ b/server/granthi_link.py @@ -5,7 +5,7 @@ Bridges shre-id (Zitadel) identity to a Granthi (Gitea) forge: POST /v1/link {zitadel_access_token, device_name} -> validates the token against Zitadel userinfo - -> ensures a Gitea user exists (admin API) + -> applies the identity-binding rules (see below) -> mints a scoped Gitea token for that user (admin basic auth + `Sudo:` header -- the only mechanism that works on Gitea 1.27; token-authenticated sudo returns 401) @@ -29,11 +29,24 @@ Design decisions (documented per spec): * Token minting NEEDS the admin password (basic auth + Sudo header). The admin API token alone cannot mint user tokens on 1.27. The config therefore carries admin_login/admin_password alongside admin_token; - config must be root-owned 0600. - * test_mode: when config "test_mode" is true, a /v1/link body may carry + config must be 0600/0400 and owned by the service user or startup is + REFUSED (fail closed). + * test_mode: when config "test_mode" is true AND the service environment + also sets GRANTHI_LINK_ALLOW_TEST_MODE=1, a /v1/link body may carry "test_userinfo" (dict) instead of a Zitadel round-trip. This exists so E2E can exercise the ensure-user+mint path without a human OAuth login. - NEVER enable in production config. + Config alone is NOT enough: without the env gate the flag is logged + loudly and ignored. NEVER enable in production. + * Identity binding: /v1/link persists a server-side map of Zitadel `sub` + -> Gitea login in state.json (0600, atomic writes). Rules: + (a) mapped sub -> always use the mapped login; if that login was + deleted it is re-created only when it was service-created, + otherwise the link is refused; + (b) unmapped sub + login free -> create user, record mapping; + (c) unmapped sub + login taken -> bind ONLY when the Gitea user's + primary email equals the Zitadel userinfo email AND + email_verified is true; otherwise 409. + A token is never minted before the binding rule passes. Stdlib only. Python 3.9+. """ @@ -53,10 +66,16 @@ import urllib.request from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -VERSION = "1.0.0" +VERSION = "1.1.0" LOG = logging.getLogger("granthi-link") DEFAULT_CONFIG = "/opt/granthi-link/config.json" +DEFAULT_STATE = "/opt/granthi-link/state.json" +MAX_BODY_BYTES = 64 * 1024 +TEST_MODE_ENV = "GRANTHI_LINK_ALLOW_TEST_MODE" + +# Sentinel: create_user hit a 409 (someone else created the login first). +USER_CREATE_CONFLICT = object() LOGIN_SAFE = re.compile(r"[^a-zA-Z0-9._-]+") @@ -95,6 +114,80 @@ def _basic(login, password): return f"Basic {tok}" +# -------------------------------------------------------------------------- +# Startup hardening +# -------------------------------------------------------------------------- + +def check_config_perms(path, euid=None): + """Fail-closed config check. Returns an error string or None. + + The config carries the Gitea admin password; it must be 0600/0400 and + owned by the user the service runs as, or startup is refused. + """ + st = os.stat(path) + mode = st.st_mode & 0o777 + if mode not in (0o600, 0o400): + return (f"config {path} has mode {oct(mode)}; refusing to start " + f"(must be 0600 or 0400)") + euid = os.geteuid() if euid is None else euid + if st.st_uid != euid: + return (f"config {path} is owned by uid {st.st_uid} but the service " + f"runs as uid {euid}; refusing to start") + return None + + +# -------------------------------------------------------------------------- +# Identity map: zitadel sub -> gitea login (JSON, 0600, atomic writes) +# -------------------------------------------------------------------------- + +class IdentityStore: + """Persistent map of Zitadel `sub` -> Gitea login binding records. + + Record shape: {"login": str, "created_by_service": bool, + "email": str, "linked_at": iso8601} + """ + + def __init__(self, path): + self.path = path + self.lock = threading.Lock() + + def _load(self): + try: + with open(self.path) as f: + data = json.load(f) + except FileNotFoundError: + return {"identities": {}} + except (ValueError, OSError) as e: + # Corrupt/unreadable state must NOT silently fall back to an + # empty map -- that would re-open the takeover window. + raise RuntimeError(f"identity state {self.path} unreadable: {e}") + if not isinstance(data, dict) or not isinstance( + data.get("identities"), dict): + raise RuntimeError(f"identity state {self.path} malformed") + return data + + def _write(self, data): + tmp = f"{self.path}.tmp.{os.getpid()}" + fd = os.open(tmp, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600) + try: + with os.fdopen(fd, "w") as f: + json.dump(data, f, indent=2, sort_keys=True) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, self.path) + finally: + if os.path.exists(tmp): + os.unlink(tmp) + + def get(self, sub): + return self._load()["identities"].get(str(sub)) + + def set(self, sub, record): + data = self._load() + data["identities"][str(sub)] = record + self._write(data) + + # -------------------------------------------------------------------------- # Core logic (class so tests can instantiate with a stub config) # -------------------------------------------------------------------------- @@ -108,6 +201,20 @@ class LinkService: self.public_gitea = config.get("public_gitea_base", self.gitea).rstrip("/") self.userinfo_url = config.get( "zitadel_userinfo", "https://id.shre.ai/oidc/v1/userinfo") + self.state = IdentityStore(config.get("state_path", DEFAULT_STATE)) + + def test_mode_enabled(self): + """Config test_mode is honored ONLY with the env gate also set.""" + if not self.cfg.get("test_mode"): + return False + if os.environ.get(TEST_MODE_ENV) != "1": + LOG.error( + "config sets test_mode=true but %s=1 is NOT set in the " + "service environment -- IGNORING test_mode (treating as " + "false). Remove test_mode from production config.", + TEST_MODE_ENV) + return False + return True # -- identity ---------------------------------------------------------- def validate_token(self, access_token): @@ -134,18 +241,30 @@ class LinkService: def _admin_hdr(self): return {"Authorization": f"token {self.cfg['admin_token']}"} - def user_exists(self, login): - status, _ = http_json( + def get_user(self, login): + """Admin-view of a Gitea user (includes primary email) or None.""" + status, resp = http_json( "GET", f"{self.gitea}/api/v1/users/{login}", headers=self._admin_hdr()) - return status == 200 + if status == 200 and isinstance(resp, dict): + return resp + return None + + def user_exists(self, login): + return self.get_user(login) is not None + + @staticmethod + def intended_email(login, userinfo): + return userinfo.get("email") or f"{login}@users.noreply.granthi.shre.ai" def create_user(self, login, userinfo): + """Create the Gitea user. Returns None on success, the sentinel + USER_CREATE_CONFLICT on HTTP 409 (concurrent create -- caller + re-fetches and continues idempotently), or an error string.""" pw_alphabet = string.ascii_letters + string.digits password = "".join(secrets.choice(pw_alphabet) for _ in range(30)) - email = userinfo.get("email") or f"{login}@users.noreply.granthi.shre.ai" body = { "username": login, - "email": email, + "email": self.intended_email(login, userinfo), "password": password, "must_change_password": False, "visibility": "private", @@ -155,6 +274,8 @@ class LinkService: status, resp = http_json( "POST", f"{self.gitea}/api/v1/admin/users", headers=self._admin_hdr(), body=body) + if status == 409: + return USER_CREATE_CONFLICT if status != 201: return f"gitea admin user create failed (HTTP {status}): {resp}" return None @@ -178,10 +299,85 @@ class LinkService: return None, None, f"token mint failed (HTTP {status}): {resp}" return resp.get("sha1"), token_name, None + # -- identity binding (finding 1: no minting before binding passes) ---- + def _record_binding(self, sub, login, userinfo, created_by_service): + self.state.set(sub, { + "login": login, + "created_by_service": bool(created_by_service), + "email": userinfo.get("email") or "", + "linked_at": datetime.now(timezone.utc).isoformat( + timespec="seconds"), + }) + + def _bind_identity(self, sub, userinfo): + """Apply the binding rules. Returns (status, error_resp, login). + + login is None unless binding passed. Caller holds self.state.lock. + """ + rec = self.state.get(sub) + if rec: # rule (a): mapping wins, regardless of current userinfo + login = rec["login"] + if not self.user_exists(login): + if not rec.get("created_by_service"): + return 409, {"error": + f"mapped login {login} no longer exists and " + "was not created by this service; refusing " + "to re-create"}, None + err = self.create_user(login, userinfo) + if err and err is not USER_CREATE_CONFLICT: + return 502, {"error": err}, None + LOG.info("re-created service-managed gitea user %s", login) + return 200, None, login + + login = self.derive_login(userinfo) + if not login: + return 422, {"error": "could not derive a login from userinfo"}, None + + if not self.user_exists(login): # rule (b): fresh login + err = self.create_user(login, userinfo) + if err is USER_CREATE_CONFLICT: + # finding 7: concurrent first-link race. Re-fetch and continue + # idempotently -- but only if the user that won the race + # carries the email WE would have set; anything else is a + # foreign identity and must be refused. + user = self.get_user(login) + if not user: + return 502, {"error": "user create conflicted but user " + "not fetchable"}, None + want = self.intended_email(login, userinfo).lower() + if (user.get("email") or "").lower() != want: + return 409, {"error": "login exists and is not linked " + "to this identity"}, None + LOG.info("user %s created concurrently; continuing", login) + elif err: + return 502, {"error": err}, None + else: + LOG.info("created gitea user %s", login) + self._record_binding(sub, login, userinfo, created_by_service=True) + return 200, None, login + + # rule (c): login taken by an unmapped Gitea user -- bind only on + # verified email match. + user = self.get_user(login) + if not user: + return 502, {"error": "gitea user lookup failed"}, None + zemail = (userinfo.get("email") or "").lower() + gemail = (user.get("email") or "").lower() + if zemail and userinfo.get("email_verified") is True and zemail == gemail: + self._record_binding(sub, login, userinfo, created_by_service=False) + LOG.info("bound existing gitea user %s to sub %s via verified " + "email match", login, sub) + return 200, None, login + LOG.warning("refused link: login %s exists, sub %s not mapped, " + "email match=%s verified=%s", login, sub, + zemail == gemail, userinfo.get("email_verified")) + return 409, {"error": "login exists and is not linked to this " + "identity"}, None + # -- endpoints --------------------------------------------------------- def link(self, body): device_name = body.get("device_name") or "device" - if self.cfg.get("test_mode") and isinstance(body.get("test_userinfo"), dict): + if self.test_mode_enabled() and isinstance(body.get("test_userinfo"), dict): LOG.warning("TEST-MODE link request (stubbed userinfo)") userinfo = body["test_userinfo"] else: @@ -191,18 +387,21 @@ class LinkService: userinfo, err = self.validate_token(token) if err: return 401, {"error": err} - login = self.derive_login(userinfo) - if not login: - return 422, {"error": "could not derive a login from userinfo"} - if not self.user_exists(login): - err = self.create_user(login, userinfo) - if err: - return 502, {"error": err} - LOG.info("created gitea user %s", login) + sub = str(userinfo.get("sub") or "").strip() + if not sub: + return 422, {"error": "userinfo has no sub"} + try: + with self.state.lock: + status, err_resp, login = self._bind_identity(sub, userinfo) + except RuntimeError as e: # identity state unreadable: fail closed + LOG.error("%s", e) + return 500, {"error": "identity state unavailable"} + if login is None: + return status, err_resp gitea_token, token_name, err = self.mint_token(login, device_name) if err: return 502, {"error": err} - LOG.info("minted token %s for %s", token_name, login) + LOG.info("minted token %s for %s (sub %s)", token_name, login, sub) return 200, {"gitea_base": self.public_gitea, "login": login, "token": gitea_token, "token_name": token_name} @@ -253,11 +452,29 @@ class Handler(BaseHTTPRequestHandler): self._send(404, {"error": "not found"}) def do_POST(self): + cl = self.headers.get("Content-Length") + if cl is None: + self.close_connection = True + return self._send(411, {"error": "Content-Length required"}) + try: + length = int(cl) + except (ValueError, TypeError): + self.close_connection = True + return self._send(400, {"error": "invalid Content-Length"}) + if length < 0: + self.close_connection = True + return self._send(400, {"error": "invalid Content-Length"}) + if length > MAX_BODY_BYTES: + # body is not read; close so the peer can't stream it anyway + self.close_connection = True + return self._send(413, {"error": f"request body too large " + f"(max {MAX_BODY_BYTES} bytes)"}) try: - length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(length) or b"{}") except (ValueError, TypeError): return self._send(400, {"error": "invalid JSON body"}) + if not isinstance(body, dict): + return self._send(400, {"error": "body must be a JSON object"}) if self.path == "/v1/link": status, resp = self.service.link(body) elif self.path == "/v1/repos": @@ -298,14 +515,15 @@ def main(): logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_CONFIG + perm_err = check_config_perms(path) + if perm_err: # fail CLOSED: a warning here would leak the admin password + LOG.error("%s", perm_err) + sys.exit(2) with open(path) as f: config = json.load(f) for key in ("gitea_base", "admin_token", "admin_login", "admin_password"): if key not in config: sys.exit(f"config missing required key: {key}") - st = os.stat(path) - if st.st_mode & 0o077: - LOG.warning("config %s is group/world readable -- chmod 600 it", path) serve(config) diff --git a/tests/__pycache__/test_client.cpython-314.pyc b/tests/__pycache__/test_client.cpython-314.pyc deleted file mode 100644 index bfe7bef9ad4d95880feae89a9a23a4f094507aa9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16251 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 diff --git a/tests/__pycache__/test_server.cpython-314.pyc b/tests/__pycache__/test_server.cpython-314.pyc deleted file mode 100644 index 6afe80981c661a927b60b9d6e16ef11b79e111e4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14805 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 U_TH&)ochz#ztdBO8ZyuS2B-gw8vp email (existing Gitea users) + state["hide_once"]: logins whose next GET 404s (simulates a concurrent + create racing between the existence check and the create call) + """ state = None # dict injected per-test def _json(self, status, obj): @@ -34,10 +43,14 @@ class StubUpstream(BaseHTTPRequestHandler): "Alice.Smith@org.example", "email": "alice@example.com", "name": "Alice"}) return self._json(401, {"error": "invalid token"}) - if self.path.startswith("/api/v1/users/"): + if self.path.startswith("/api/v1/users/") and not self.path.endswith("/tokens"): login = self.path.rsplit("/", 1)[1] + if login in st["hide_once"]: + st["hide_once"].discard(login) + return self._json(404, {"message": "not found"}) if login in st["users"]: - return self._json(200, {"login": login}) + return self._json(200, {"login": login, + "email": st["users"][login]}) return self._json(404, {"message": "not found"}) self._json(404, {}) @@ -46,7 +59,9 @@ class StubUpstream(BaseHTTPRequestHandler): length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(length) or b"{}") if self.path == "/api/v1/admin/users": - st["users"].add(body["username"]) + if body["username"] in st["users"]: + return self._json(409, {"message": "user already exists"}) + st["users"][body["username"]] = body["email"] st["created"].append(body) return self._json(201, {"login": body["username"]}) if self.path.startswith("/api/v1/users/") and self.path.endswith("/tokens"): @@ -72,20 +87,42 @@ class StubUpstream(BaseHTTPRequestHandler): class ServiceTestBase(unittest.TestCase): def setUp(self): - StubUpstream.state = {"users": set(), "created": [], "repos": set(), - "token_reqs": []} + StubUpstream.state = {"users": {}, "created": [], "repos": set(), + "token_reqs": [], "hide_once": set()} self.upstream = ThreadingHTTPServer(("127.0.0.1", 0), StubUpstream) threading.Thread(target=self.upstream.serve_forever, daemon=True).start() self.addCleanup(self.upstream.shutdown) base = f"http://127.0.0.1:{self.upstream.server_address[1]}" + self.state_dir = tempfile.mkdtemp(prefix="granthi-link-state-") + self.state_path = os.path.join(self.state_dir, "state.json") self.svc = granthi_link.LinkService({ "gitea_base": base, "public_gitea_base": "http://public.example:3041", "zitadel_userinfo": f"{base}/oidc/v1/userinfo", "admin_token": "ADMTOK", "admin_login": "root", "admin_password": "rootpw", "test_mode": False, + "state_path": self.state_path, }) + def enable_test_mode(self): + self.svc.cfg["test_mode"] = True + patcher = mock.patch.dict( + os.environ, {granthi_link.TEST_MODE_ENV: "1"}) + patcher.start() + self.addCleanup(patcher.stop) + + def stub_link(self, sub, username, email=None, verified=None, device="d"): + ui = {"sub": sub, "preferred_username": username} + if email is not None: + ui["email"] = email + if verified is not None: + ui["email_verified"] = verified + return self.svc.link({"test_userinfo": ui, "device_name": device}) + + def read_state(self): + with open(self.state_path) as f: + return json.load(f) + class TestDeriveLogin(unittest.TestCase): def test_strips_domain_and_sanitizes(self): @@ -123,12 +160,15 @@ class TestLink(ServiceTestBase): self.assertEqual(sorted(req["body"]["scopes"]), ["write:repository", "write:user"]) - def test_link_existing_user_skips_create(self): - StubUpstream.state["users"].add("alice.smith") - status, resp = self.svc.link({"zitadel_access_token": "good-token", - "device_name": "d"}) - self.assertEqual(status, 200) - self.assertEqual(StubUpstream.state["created"], []) + def test_link_records_identity_mapping(self): + self.svc.link({"zitadel_access_token": "good-token", + "device_name": "d"}) + state = self.read_state() + rec = state["identities"]["123"] + self.assertEqual(rec["login"], "alice.smith") + self.assertTrue(rec["created_by_service"]) + mode = os.stat(self.state_path).st_mode & 0o777 + self.assertEqual(mode, 0o600) def test_link_bad_token_401(self): status, resp = self.svc.link({"zitadel_access_token": "bad", @@ -139,17 +179,251 @@ class TestLink(ServiceTestBase): status, _ = self.svc.link({"device_name": "d"}) self.assertEqual(status, 400) - def test_test_mode_stub_only_when_enabled(self): - # disabled -> stub ignored, token required - status, _ = self.svc.link({"test_userinfo": {"sub": "1", - "preferred_username": "x"}}) - self.assertEqual(status, 400) + def test_link_missing_sub_422(self): + self.enable_test_mode() + status, _ = self.svc.link({"test_userinfo": + {"preferred_username": "nosub"}, + "device_name": "d"}) + self.assertEqual(status, 422) + + +class TestIdentityBinding(ServiceTestBase): + """Finding 1: account takeover by login collision.""" + + def setUp(self): + super().setUp() + self.enable_test_mode() + + def test_repeat_link_same_sub_reuses_mapping(self): + status, resp = self.stub_link("s1", "alice") + self.assertEqual(status, 200) + # same sub again -- even with a different preferred_username the + # mapping wins and no second user is created + status, resp = self.stub_link("s1", "totally-different") + self.assertEqual(status, 200) + self.assertEqual(resp["login"], "alice") + self.assertEqual(len(StubUpstream.state["created"]), 1) + + def test_colliding_username_different_sub_409_no_token(self): + status, _ = self.stub_link("s1", "alice") + self.assertEqual(status, 200) + minted_before = len(StubUpstream.state["token_reqs"]) + status, resp = self.stub_link("s2", "alice") # attacker + self.assertEqual(status, 409) + self.assertIn("not linked to this identity", resp["error"]) + # no token minted for the refused identity + self.assertEqual(len(StubUpstream.state["token_reqs"]), minted_before) + self.assertNotIn("s2", self.read_state()["identities"]) + + def test_existing_user_binds_on_verified_email_match(self): + StubUpstream.state["users"]["bob"] = "bob@example.com" + status, resp = self.stub_link("s9", "bob", email="bob@example.com", + verified=True) + self.assertEqual(status, 200) + self.assertEqual(resp["login"], "bob") + rec = self.read_state()["identities"]["s9"] + self.assertFalse(rec["created_by_service"]) + + def test_existing_user_unverified_email_409(self): + StubUpstream.state["users"]["bob"] = "bob@example.com" + status, _ = self.stub_link("s9", "bob", email="bob@example.com", + verified=False) + self.assertEqual(status, 409) + self.assertEqual(StubUpstream.state["token_reqs"], []) + + def test_existing_user_wrong_email_409(self): + StubUpstream.state["users"]["bob"] = "bob@example.com" + status, _ = self.stub_link("s9", "bob", email="evil@example.com", + verified=True) + self.assertEqual(status, 409) + self.assertEqual(StubUpstream.state["token_reqs"], []) + + def test_deleted_service_created_login_is_recreated(self): + self.stub_link("s1", "alice") + del StubUpstream.state["users"]["alice"] # user deleted in Gitea + status, resp = self.stub_link("s1", "alice") + self.assertEqual(status, 200) + self.assertEqual(resp["login"], "alice") + self.assertIn("alice", StubUpstream.state["users"]) + + def test_deleted_adopted_login_is_refused(self): + StubUpstream.state["users"]["bob"] = "bob@example.com" + status, _ = self.stub_link("s9", "bob", email="bob@example.com", + verified=True) + self.assertEqual(status, 200) + del StubUpstream.state["users"]["bob"] + status, resp = self.stub_link("s9", "bob", email="bob@example.com", + verified=True) + self.assertEqual(status, 409) + self.assertIn("not created by this service", resp["error"]) + + def test_corrupt_state_fails_closed(self): + with open(self.state_path, "w") as f: + f.write("{ not json") + status, resp = self.svc.link({"test_userinfo": + {"sub": "s1", + "preferred_username": "alice"}, + "device_name": "d"}) + self.assertEqual(status, 500) + self.assertEqual(StubUpstream.state["token_reqs"], []) + + +class TestConcurrentCreateRace(ServiceTestBase): + """Finding 7: Gitea 409 on user create is handled idempotently.""" + + def setUp(self): + super().setUp() + self.enable_test_mode() + + def test_409_on_create_refetches_and_continues(self): + # user exists (created by a concurrent request with OUR email) but + # the first existence check misses it + email = "alice@example.com" + StubUpstream.state["users"]["alice"] = email + StubUpstream.state["hide_once"].add("alice") + status, resp = self.stub_link("s1", "alice", email=email) + self.assertEqual(status, 200) + self.assertEqual(resp["login"], "alice") + self.assertEqual(self.read_state()["identities"]["s1"]["login"], + "alice") + + def test_409_on_create_with_foreign_email_refused(self): + StubUpstream.state["users"]["alice"] = "someoneelse@example.com" + StubUpstream.state["hide_once"].add("alice") + status, resp = self.stub_link("s1", "alice", + email="alice@example.com") + self.assertEqual(status, 409) + self.assertEqual(StubUpstream.state["token_reqs"], []) + + +class TestTestModeGate(ServiceTestBase): + """Finding 2: test_mode requires the env gate.""" + + def test_config_flag_alone_is_ignored(self): self.svc.cfg["test_mode"] = True - status, resp = self.svc.link({"test_userinfo": { - "sub": "1", "preferred_username": "evetest"}, "device_name": "d"}) + env = {k: v for k, v in os.environ.items() + if k != granthi_link.TEST_MODE_ENV} + with mock.patch.dict(os.environ, env, clear=True), \ + self.assertLogs("granthi-link", level="ERROR"): + status, _ = self.svc.link({"test_userinfo": { + "sub": "1", "preferred_username": "x"}}) + self.assertEqual(status, 400) # falls through to token-required + + def test_env_gate_wrong_value_is_ignored(self): + self.svc.cfg["test_mode"] = True + with mock.patch.dict(os.environ, + {granthi_link.TEST_MODE_ENV: "true"}): + status, _ = self.svc.link({"test_userinfo": { + "sub": "1", "preferred_username": "x"}}) + self.assertEqual(status, 400) + + def test_enabled_with_config_and_env(self): + self.enable_test_mode() + status, resp = self.stub_link("1", "evetest") self.assertEqual(status, 200) self.assertEqual(resp["login"], "evetest") + def test_env_alone_without_config_flag_disabled(self): + with mock.patch.dict(os.environ, + {granthi_link.TEST_MODE_ENV: "1"}): + status, _ = self.svc.link({"test_userinfo": { + "sub": "1", "preferred_username": "x"}}) + self.assertEqual(status, 400) + + +class TestConfigPerms(unittest.TestCase): + """Finding 3: refuse startup on permissive or foreign-owned config.""" + + def setUp(self): + self.tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".json") + self.tmp.write(b"{}") + self.tmp.close() + self.addCleanup(os.unlink, self.tmp.name) + + def test_0600_ok(self): + os.chmod(self.tmp.name, 0o600) + self.assertIsNone(granthi_link.check_config_perms(self.tmp.name)) + + def test_0400_ok(self): + os.chmod(self.tmp.name, 0o400) + self.assertIsNone(granthi_link.check_config_perms(self.tmp.name)) + + def test_0644_refused(self): + os.chmod(self.tmp.name, 0o644) + err = granthi_link.check_config_perms(self.tmp.name) + self.assertIn("refusing to start", err) + self.assertIn("0o644", err) + + def test_0640_refused(self): + os.chmod(self.tmp.name, 0o640) + self.assertIsNotNone(granthi_link.check_config_perms(self.tmp.name)) + + def test_foreign_owner_refused(self): + os.chmod(self.tmp.name, 0o600) + not_me = os.geteuid() + 1 + err = granthi_link.check_config_perms(self.tmp.name, euid=not_me) + self.assertIn("owned by uid", err) + + +class HandlerTestBase(ServiceTestBase): + def setUp(self): + super().setUp() + granthi_link.Handler.service = self.svc + self.srv = ThreadingHTTPServer(("127.0.0.1", 0), granthi_link.Handler) + threading.Thread(target=self.srv.serve_forever, daemon=True).start() + self.addCleanup(self.srv.shutdown) + self.port = self.srv.server_address[1] + + def raw_post(self, path, body_bytes=None, headers=None): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10) + self.addCleanup(conn.close) + conn.putrequest("POST", path) + for k, v in (headers or {}).items(): + conn.putheader(k, v) + conn.endheaders() + if body_bytes: + conn.send(body_bytes) + resp = conn.getresponse() + return resp.status, json.loads(resp.read() or b"{}") + + +class TestBodyLimits(HandlerTestBase): + """Finding 6: bounded reads, Content-Length required on POST.""" + + def test_oversized_content_length_413(self): + status, resp = self.raw_post( + "/v1/link", headers={"Content-Length": + str(granthi_link.MAX_BODY_BYTES + 1)}) + self.assertEqual(status, 413) + self.assertIn("too large", resp["error"]) + + def test_missing_content_length_411(self): + status, _ = self.raw_post("/v1/link") + self.assertEqual(status, 411) + + def test_invalid_content_length_400(self): + status, _ = self.raw_post("/v1/link", + headers={"Content-Length": "banana"}) + self.assertEqual(status, 400) + + def test_normal_post_still_works(self): + body = json.dumps({"device_name": "d"}).encode() + status, resp = self.raw_post( + "/v1/link", body_bytes=body, + headers={"Content-Length": str(len(body)), + "Content-Type": "application/json"}) + self.assertEqual(status, 400) # missing token, but parsed fine + self.assertIn("zitadel_access_token", resp["error"]) + + def test_at_limit_accepted(self): + pad = "x" * (granthi_link.MAX_BODY_BYTES - 30) + body = json.dumps({"pad": pad}).encode() + self.assertLessEqual(len(body), granthi_link.MAX_BODY_BYTES) + status, _ = self.raw_post( + "/v1/link", body_bytes=body, + headers={"Content-Length": str(len(body))}) + self.assertEqual(status, 400) # parsed; fails on missing token + class TestRepos(ServiceTestBase): def test_repo_create_returns_public_clone_url(self): @@ -169,14 +443,10 @@ class TestRepos(ServiceTestBase): self.assertEqual(status, 400) -class TestHealth(ServiceTestBase): +class TestHealth(HandlerTestBase): def test_health_endpoint(self): - granthi_link.Handler.service = self.svc - srv = ThreadingHTTPServer(("127.0.0.1", 0), granthi_link.Handler) - threading.Thread(target=srv.serve_forever, daemon=True).start() - self.addCleanup(srv.shutdown) with urllib.request.urlopen( - f"http://127.0.0.1:{srv.server_address[1]}/health") as r: + f"http://127.0.0.1:{self.port}/health") as r: body = json.loads(r.read()) self.assertEqual(body["status"], "ok") self.assertEqual(body["service"], "granthi-link")