लॉन्च से पहले हमने अपने ही auth server का ऑडिट किया। bugs की एक शक्ल थी।
इससे पहले कि हम किसी से कहते कि अपने logins के लिए हम पर भरोसा कीजिए, हमने अपने ही auth server को उसी तरह खंगाला जैसे कोई हमलावर खंगालता: उसे चलाकर नहीं, बल्कि पढ़कर। जो निकला वह पूरे surface की एक लिखित समीक्षा थी: सोलह क्रमांकित खंड, 261 पंक्तियाँ, SAML से लेकर OIDC federation, SCIM, admin API, MFA और backup tooling तक, और हर finding के साथ एक severity और source में उसकी जगह। आठ पर "पहले यही ठीक करो" का निशान लगा। उन आठ को हमने एक ही शाम में ठीक किया और बाक़ी सूची आधे घंटे बाद। उसके ढाई हफ़्ते बाद हमने वही कसरत multi-tenant control plane पर की और पाँच और मिले, जो production पर जाने से एक शाम पहले बंद कर दिए गए।
यह रहा वह आँकड़ा जो चिपक गया। test suite ने उस शाम से पहले 367 में से 367 pass बताए, और उसके बाद भी 367 में से 367। कुछ भी लाल नहीं हुआ, क्योंकि suite में कुछ भी इनमें से किसी पर तना हुआ नहीं था। tests वही साबित करते हैं जिसे जाँचने का ख़याल आपको आया, और किसी को यह जाँचने का ख़याल नहीं आया था कि signature जिस XML attribute को ढकता ही नहीं, उसे हटा देने पर एक दोबारा भेजा गया assertion कहीं ज़्यादा मेहरबान शाखा से तो नहीं गुज़र जाएगा।
आठ अलग-अलग bugs की तरह पढ़ी जाए तो ऐसी सूची किसी और के काम की नहीं। शक्लों की तरह पढ़ी जाए तो यह तीन आदतें हैं, जो अलग-अलग subsystems में दोहराई गईं, और वह भी उन लोगों के लिखे code में जिन्होंने specs पढ़ रखी थीं।
client_id, forwarded header, InResponseTo और email claim
पहली आदत है caller जो value लिखता है उसे caller के बारे में एक तथ्य मान लेना। समीक्षा ने जिन दो को Critical बताया, वे दोनों एक-एक input थे: एक query parameter, और एक email claim।
MFA नीति login form के returnUrl से निकाले गए client_id से तय होती थी, और /connect/authorize ने कभी दोबारा जाँच नहीं की। login को ऐसे client की तरफ़ मोड़िए जिस पर MFA बंद है, और session बिना दूसरे factor के बाहर आता था, और फिर उसी client पर बख़ूबी चल जाता था जो दूसरा factor माँगता है। उस पर अपना अलग लेख है।
प्रति-IP सीमाएँ remote address पर टिकी थीं, और forwarded headers पर 0.0.0.0/0 से भरोसा किया जाता था। हर request में एक X-Forwarded-For value ने हर प्रति-IP नियंत्रण को महज़ एक सलाह बना दिया, और मुफ़्त में audit log का IP कॉलम भी दोबारा लिख दिया। ब्यौरा यहाँ है।
SAML assertion consumer, SP-initiated रास्ते (request id को खपाओ, एक ही बार के लिए) और IdP-initiated रास्ते (assertion-id cache जाँचो) के बीच का चुनाव सिर्फ़ इस आधार पर करता था कि response में InResponseTo attribute है या नहीं। वह attribute <Response> element पर बैठता है, जो तब ढका नहीं होता जब सिर्फ़ assertion sign किया गया हो, और आम तौर पर यही होता है। उसे हटा दीजिए और पकड़ा हुआ response उस शाखा पर चला जाता था जिसकी replay cache को असली login ने कभी भरा ही नहीं था, और हर signature फिर भी सही निकलता था। signature कभी समस्या था ही नहीं, यह क्यों।
और लौटने वाले federated उपयोगकर्ता assertion में मौजूद email से पहचाने जाते थे, इसलिए कोई भी connection किसी और का पता बता सकता था और उसी के account पर पहुँच जाता था। वह भी लिखा जा चुका है।
admin API अपने ही credential की फ़ैक्टरी थी
admin authorization ठीक एक चीज़ पर टिका था: ऐसा token जिसके scope claim में admin scope मौजूद हो। दो admin endpoints उस scope को एक नए token में डाल देते थे।
POST /api/v1/token, यानी वह impersonation endpoint जिसे support tooling इस्तेमाल करती है, अपने scopes query parameter को तोड़ता था और नतीजे को ज्यों का त्यों एक access token और एक refresh token में उतार देता था, बिना client के दर्ज AllowedScopes से मिलान किए। इसलिए एक admin credential, चाहे कितनी भी कम उम्र का हो, किसी भी उपयोगकर्ता और किसी भी client के लिए लंबी उम्र वाला admin refresh token गढ़ देता था: यानी ऐसा विशेषाधिकार जो उस credential को बदल देने के बाद भी ज़िंदा रहता है जिसकी आपको फ़िक्र थी। उधर client बनाने वाला endpoint request body से सीधे एक कच्चा client object bind कर लेता था, ClientSecretHashes समेत, इसलिए कोई admin ऐसा client_credentials client दर्ज कर सकता था जिसके पास admin scope हो और वह secret hash हो जो उसे पहले से पता है। list और get हर संग्रहित hash को वापस दोहरा देते थे।
अब impersonation endpoint admin scope को मना कर देता है और बाक़ी को client के अपने AllowedScopes के भीतर बाँध देता है, create और update ऐसे DTO से bind करते हैं जिसमें secret-hash वाला field है ही नहीं, और कोई response कोई hash नहीं ले जाता। जो अनुमति ख़ुद को जारी कर सकती है, वह अनुमति नहीं है।
एक हाइफ़न, एक बेमालिक group, और पहचान का काम करता हुआ एक email
दूसरी आदत है किसी ऐसी चीज़ को पहचान की जगह खड़ा कर देना जिसमें बस एक नाम भरा हो।
federated sign-in के लिए email ही join key था: वैश्विक, कई providers पर मौजूद, और वही जो assert करने वाला connection कह दे। हल यह था कि पहचान provider और subject की जोड़ी से की जाए, जिसे कोई दूसरा connection गढ़ नहीं सकता।
control plane में वह नाम tenant slug था। जो router किसी slug को storage table prefix में बदलता है, वह हाइफ़न हटा देता था, इसलिए acme-corp और acmecorp एक ही prefix बनाते थे, यानी वही table set और वही Vault signing key। दो अलग-अलग दर्ज हो सकने वाले tenants, एक ही table set और एक ही signing key पर। दूसरा दर्ज कीजिए और आप पहले वाले के उपयोगकर्ताओं को देख रहे हैं। हल कोई ज़्यादा चतुर prefix function नहीं था। हल यह था कि slugs में हाइफ़न पर रोक लगा दी जाए ताकि mapping एकैकी रहे, और यह लंबे संस्करण के लायक़ है।
SCIM वाला मामला चुभा, क्योंकि उसका आधा हिस्सा सही था। हर user read और write दोबारा जाँचता था कि वह resource बुलाने वाले client ने ही provision किया है। बग़ल की फ़ाइल में पड़े groups का कोई मालिक था ही नहीं: create कभी कोई मालिक दर्ज नहीं करता था, list owner filter के तौर पर null भेजता था और environment का हर group लौटा देता था, और get, replace, patch तथा delete किसी भी id को स्वीकार कर लेते थे। group की id जान लेना ही पूरा authorization था, और जहाँ कई provisioning clients एक ही environment साझा करते हैं वहाँ यह group membership का cross-client पढ़ना और लिखना है, और roles इसी तरह दिए जाते हैं। अब groups अपने साथ बनाने वाले client को रखते हैं, और हर read और write उसी के दायरे में है।
webhook के जवाब देने से पहले ही cookie sign हो चुकी थी
तीसरी आदत है जाँच चलाने से पहले वह काम कर देना जिसे पलटा नहीं जा सकता।
सबसे साफ़ मिसाल एक ऐसी सुविधा है जिसे ग्राहक ख़रीदते हैं: एक tenant ऐसा webhook दर्ज करता है जिससे authentication के वक़्त पूछा जाता है "क्या इस login की इजाज़त है?", और वह चाहे तो जवाब को लागू भी करवा सकता है। यह hook हर session बनाने वाले रास्ते पर चलता था, ठीक से। बस वह SignInAsync के बाद चलता था। तो मना करने पर exception उठता, request 500 लौटती, और browser के पास तब तक sign की हुई session cookie पहुँच चुकी होती। जो भी client उस त्रुटि को अनदेखा करता, वह login हो जाता, रोका गया हो या नहीं। login रोकने वाला webhook रोकता ही नहीं था।
हल है एक पंक्ति को छह बार खिसकाना: hook को password, OIDC, SAML, MFA verify और MFA setup पर SignInAsync से पहले चलाओ, और token endpoint पर tokens गढ़ने से पहले। library वाला बदलाव और control-plane वाला बदलाव दो मिनट के फ़ासले पर उतरे। अब मना करने पर साफ़-सुथरा 403 मिलता है, बिना किसी Set-Cookie के, और एक end-to-end test पक्का करता है कि ठुकराए गए login पर कोई .AspNetCore.Cookies जारी नहीं होती।
यही क्रम तीन और शांत जगहों पर निकला। Authorization codes पहले पढ़े जाते और फिर मिटाए जाते, यानी दो अलग operations, इसलिए एक ही code को एक साथ भुनाने की दो कोशिशें दोनों read पास कर सकती थीं और दोनों को tokens मिल सकते थे; अब grant store TryConsumeAsync देता है, यानी एक शर्तिया delete जो सिर्फ़ उसी caller को true लौटाता है जिसने असल में वह row हटाई। Restore किसी भी चीज़ की पुष्टि से पहले entities upsert कर देता था: FileHashes field के बारे में लिखा हुआ था कि restore के दौरान उसकी पुष्टि होती है, जबकि वह न कभी भरा जाता था न जाँचा जाता था, और इससे छेड़ा हुआ backup production के ख़िलाफ़ एक write primitive बन जाता है। और Stripe webhook events पर यह दर्ज करने से पहले ही अमल कर देता था कि उसने उन्हें देख लिया है, इसलिए कम से कम एक बार वाली दोबारा डिलीवरी किसी tenant के plan और status को उलट-पुलट सकती थी या dunning दोबारा भेज सकती थी। अब तीनों पहले जाँचते हैं।
सहेजते वक़्त URL की जाँच, डायल करते वक़्त कुछ साबित नहीं करती
ग्राहक हमें URLs देते हैं: auth webhooks, backup targets, retention callbacks। सहेजने के क्षण जो URL किसी निजी पते पर हल होता हो उसे ठुकरा देना ज़ाहिर-सा बचाव है, और अकेले में वह क़रीब-क़रीब बेकार है, क्योंकि DNS कोई वादा नहीं है। जो नाम जाँचते वक़्त सार्वजनिक पते पर हल होता है, वही जुड़ते वक़्त 169.254.169.254 पर हल हो सकता है।
इसलिए भार उठाने वाली जाँच settings handler में नहीं रहती। वह ख़ुद HTTP handler पर लगा एक connect callback है। ग्राहक के दिए URL पर जाने वाली हर call पहले host को हल करती है, फिर loopback, RFC1918, unique-local और पूरी 169.254.0.0/16 link-local रेंज को मना करती है, जिसमें cloud metadata service रहती है, और उसके बाद hostname के बजाय ठीक उसी जाँचे हुए पते पर socket खोलती है, ताकि दोबारा rebind करने के लिए कोई दूसरा resolution बचे ही नहीं। अपने आप होने वाले redirects बंद हैं, क्योंकि एक 302 किसी आंतरिक निशाने का नाम लेने का एक और मौक़ा है। सहेजने के वक़्त वाली जाँच ग्राहक के लिए एक त्रुटि संदेश के रूप में बनी हुई है, और वह कभी सिर्फ़ इसी लायक़ थी।
दो endpoints और एक backup फ़ाइल, जिनकी हिफ़ाज़त सिर्फ़ नेटवर्क कर रहा था
/_internal/cluster/gossip anonymous map था, और उसकी shared-secret वाली जाँच code के बजाय एक टिप्पणी के रूप में मौजूद थी। /_internal/backchannel-logout सार्वजनिक listener पर antiforgery बंद करके anonymous map था, {"SubjectId": "..."} जैसा body लेता था, और उस subject के पास मौजूद हर grant रद्द कर देता था। दोनों में से किसी पर प्रक्रिया के भीतर कोई पहरा नहीं था। उनके आगे बस ingress का configuration था, और ऊपर बताई गई forwarded-header वाली खोज को देखते हुए IP पर टिका कोई भी बचाव वैसे भी spoof किया जा सकता था। अब दोनों या तो एक shared secret माँगते हैं जिसकी तुलना नियत समय में होती है, या फिर, जब कोई secret तय न हो, एक आंतरिक source address।
Backups का चरित्र भी यही था। जो hosts स्थानीय key source इस्तेमाल करते हैं, वहाँ signing-key वाली table में EC private scalar रहता है, और backup हर कॉलम को ज्यों का त्यों सादे gzipped JSONL में serialize कर देता था। जो कोई भी backup फ़ाइल पढ़ सकता था, वह उस issuer के लिए tokens गढ़ सकता था जिससे वह फ़ाइल आई थी। अब वह table डिफ़ॉल्ट रूप से बाहर रखी जाती है और encrypted targets के लिए साफ़-साफ़ opt-in करना पड़ता है, और हर data फ़ाइल का SHA-256 hash manifest में जाता है और restore के एक भी row लिखने से पहले उसकी पुष्टि होती है।
जाँच कहाँ चलती है, यही तय करता है कि जाँच की क़ीमत कितनी है
इनमें से कोई भी इस मायने में ग़ायब जाँच नहीं थी कि किसी ने उसके बारे में सोचा ही न हो। MFA लागू भी था और जाँचा भी गया था। webhook वाला hook हर रास्ते पर चलता था। SSRF की जाँच मौजूद थी। SCIM users हर verb पर सही दायरे में थे, और वह भी उस फ़ाइल के बग़ल वाली फ़ाइल में जहाँ groups नहीं थे। लगभग हर मामले में logic लिखा हुआ था, सही था, और ऐसी जगह लगाया गया था जहाँ वह अपना काम कर ही नहीं सकता था: caller की दी हुई किसी value पर, ऐसे क़दम पर जिसे caller छोड़ सकता था, उस स्थिति के बाद जिसे उसे रोकना था, या फिर बग़ल वाले resource पर।
तो हर endpoint पर काम का सवाल यह नहीं है कि "क्या हम इसकी जाँच करते हैं"। सवाल तीन और हैं। यह request असल में साबित क्या करती है, बनिस्बत इसके कि दावा क्या करती है? क्या यह पहचानकर्ता ऐसी चीज़ है जिसका नाम कोई भी caller ले सकता है, या ऐसी चीज़ जो सिर्फ़ सही caller के पास हो सकती है? और क्या जवाब आने से पहले ही कुछ ऐसा हो जाता है जिसे पलटा नहीं जा सकता? खंड दर खंड की गई, हर finding पर severity लगाकर लिखी गई समीक्षा दरअसल इन तीनों सवालों को उस code तक ज़बरदस्ती पहुँचाने का ज़रिया है जिसके बारे में आप निश्चिंत हैं। हम निश्चिंत थे। सोलह खंडों जितने निश्चिंत, और साथ में आठ ऐसी चीज़ें जो किसी न किसी की घटना बन जातीं।
इनमें से कुछ भी किसी ग्राहक की पहुँच में नहीं था, क्योंकि तब तक कोई ग्राहक था ही नहीं। यही वह दलील है कि यह काम पहली रिपोर्ट आने के बाद नहीं, बल्कि लॉन्च से पहले किया जाए: वही सूची छह महीने बाद लिखी जाए तो वह एक शाम का काम नहीं, बल्कि एक disclosure timeline बन जाती है। अगर आप इन जवाबों का मौजूदा संस्करण चाहते हैं, तो हमारा security पन्ना बताता है कि tenants को कैसे अलग रखा जाता है, भंडारण में क्या encrypt रहता है, और आपके डेटा के backup में असल में क्या होता है।