Skip to content

Commit 8956cca

Browse files
authored
Merge pull request #168 from scitokens/copilot/fix-4bfa53bc-d5c1-400f-bf9b-b0f30f715e86
Fix security vulnerability in JWT issuer error message handling
2 parents 42d201b + f8d0fb3 commit 8956cca

File tree

2 files changed

+130
-2
lines changed

2 files changed

+130
-2
lines changed

src/scitokens_internal.h

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -487,9 +487,10 @@ class Validator {
487487
}
488488
}
489489
if (!permitted) {
490+
std::string safe_issuer = format_issuer_for_error(jwt);
490491
throw JWTVerificationException(
491-
"Token issuer '" + issuer +
492-
"' is not in list of allowed issuers.");
492+
"Token issuer " + safe_issuer +
493+
" is not in list of allowed issuers.");
493494
}
494495
}
495496

@@ -774,6 +775,33 @@ class Validator {
774775
const picojson::value &keys,
775776
int64_t next_update, int64_t expires);
776777

778+
/**
779+
* Safely format an issuer for error messages.
780+
* Serializes the issuer claim back to JSON format and limits the size
781+
* to prevent malicious issuers from causing problems in error output.
782+
*/
783+
static std::string format_issuer_for_error(
784+
const jwt::decoded_jwt<jwt::traits::kazuho_picojson> &jwt) {
785+
try {
786+
if (!jwt.has_payload_claim("iss")) {
787+
return "<missing issuer>";
788+
}
789+
// Get the raw claim and serialize it back to JSON
790+
const auto &claim = jwt.get_payload_claim("iss");
791+
std::string serialized = claim.to_json().serialize();
792+
// Limit the size to prevent abuse
793+
const size_t max_issuer_length = 256;
794+
if (serialized.length() > max_issuer_length) {
795+
serialized =
796+
serialized.substr(0, max_issuer_length - 3) + "...";
797+
}
798+
return serialized;
799+
} catch (...) {
800+
// If anything goes wrong, return a safe fallback
801+
return "<invalid issuer>";
802+
}
803+
}
804+
777805
bool m_validate_all_claims{true};
778806
SciToken::Profile m_profile{SciToken::Profile::COMPAT};
779807
SciToken::Profile m_validate_profile{SciToken::Profile::COMPAT};

test/main.cpp

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,106 @@ TEST_F(KeycacheTest, RefreshExpiredTest) {
806806
EXPECT_EQ(jwks_str, "{\"keys\": []}");
807807
}
808808

809+
class IssuerSecurityTest : public ::testing::Test {
810+
protected:
811+
void SetUp() override {
812+
char *err_msg = nullptr;
813+
m_key = KeyPtr(
814+
scitoken_key_create("1", "ES256", ec_public, ec_private, &err_msg),
815+
scitoken_key_destroy);
816+
ASSERT_TRUE(m_key.get() != nullptr) << err_msg;
817+
818+
m_token = TokenPtr(scitoken_create(m_key.get()), scitoken_destroy);
819+
ASSERT_TRUE(m_token.get() != nullptr);
820+
821+
// Store public key for verification
822+
auto rv = scitoken_store_public_ec_key(
823+
"https://demo.scitokens.org/gtest", "1", ec_public, &err_msg);
824+
ASSERT_TRUE(rv == 0) << err_msg;
825+
826+
scitoken_set_lifetime(m_token.get(), 60);
827+
828+
m_read_token.reset(scitoken_create(nullptr));
829+
ASSERT_TRUE(m_read_token.get() != nullptr);
830+
}
831+
832+
using KeyPtr = std::unique_ptr<void, decltype(&scitoken_key_destroy)>;
833+
KeyPtr m_key{nullptr, scitoken_key_destroy};
834+
835+
using TokenPtr = std::unique_ptr<void, decltype(&scitoken_destroy)>;
836+
TokenPtr m_token{nullptr, scitoken_destroy};
837+
838+
TokenPtr m_read_token{nullptr, scitoken_destroy};
839+
};
840+
841+
TEST_F(IssuerSecurityTest, LongIssuerTruncation) {
842+
char *err_msg = nullptr;
843+
844+
// Create a very long issuer (1000 characters)
845+
std::string very_long_issuer(1000, 'A');
846+
auto rv = scitoken_set_claim_string(m_token.get(), "iss",
847+
very_long_issuer.c_str(), &err_msg);
848+
ASSERT_TRUE(rv == 0) << err_msg;
849+
850+
char *token_value = nullptr;
851+
rv = scitoken_serialize(m_token.get(), &token_value, &err_msg);
852+
ASSERT_TRUE(rv == 0) << err_msg;
853+
std::unique_ptr<char, decltype(&free)> token_value_ptr(token_value, free);
854+
855+
// Try to verify with a restricted issuer list to trigger error
856+
const char *allowed_issuers[] = {"https://good.issuer.com", nullptr};
857+
rv = scitoken_deserialize_v2(token_value, m_read_token.get(),
858+
allowed_issuers, &err_msg);
859+
860+
// Should fail
861+
ASSERT_FALSE(rv == 0);
862+
ASSERT_TRUE(err_msg != nullptr);
863+
std::string error_message(err_msg);
864+
std::unique_ptr<char, decltype(&free)> err_msg_ptr(err_msg, free);
865+
// Error message should be reasonable length (< 400 chars)
866+
EXPECT_LT(error_message.length(), 400);
867+
// Should contain expected error text
868+
EXPECT_NE(error_message.find("is not in list of allowed issuers"),
869+
std::string::npos);
870+
871+
// Should contain truncated issuer with ellipsis
872+
EXPECT_NE(error_message.find("..."), std::string::npos);
873+
}
874+
875+
TEST_F(IssuerSecurityTest, SpecialCharacterIssuer) {
876+
char *err_msg = nullptr;
877+
878+
// Create an issuer with special characters and control chars
879+
std::string special_issuer = "https://bad.com/\"\n\t\r\x01\x1f";
880+
auto rv = scitoken_set_claim_string(m_token.get(), "iss",
881+
special_issuer.c_str(), &err_msg);
882+
ASSERT_TRUE(rv == 0) << err_msg;
883+
884+
char *token_value = nullptr;
885+
rv = scitoken_serialize(m_token.get(), &token_value, &err_msg);
886+
ASSERT_TRUE(rv == 0) << err_msg;
887+
std::unique_ptr<char, decltype(&free)> token_value_ptr(token_value, free);
888+
889+
// Try to verify with a restricted issuer list to trigger error
890+
const char *allowed_issuers[] = {"https://good.issuer.com", nullptr};
891+
rv = scitoken_deserialize_v2(token_value, m_read_token.get(),
892+
allowed_issuers, &err_msg);
893+
894+
// Should fail
895+
ASSERT_FALSE(rv == 0);
896+
ASSERT_TRUE(err_msg != nullptr);
897+
std::string error_message(err_msg);
898+
std::unique_ptr<char, decltype(&free)> err_msg_ptr(err_msg, free);
899+
// Error message should be reasonable length
900+
EXPECT_LT(error_message.length(), 300);
901+
// Should contain expected error text
902+
EXPECT_NE(error_message.find("is not in list of allowed issuers"),
903+
std::string::npos);
904+
905+
// Should contain properly escaped JSON (with quotes)
906+
EXPECT_NE(error_message.find("\""), std::string::npos);
907+
}
908+
809909
int main(int argc, char **argv) {
810910
::testing::InitGoogleTest(&argc, argv);
811911
return RUN_ALL_TESTS();

0 commit comments

Comments
 (0)