Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Install docker-compose
uses: KengoTODA/actions-setup-docker-compose@v1
with:
version: '2.14.2'
- name: Run Tests
run: sudo CONFIG_FILE=./env/local.js docker compose -f docker-compose-with-keycloak.yml up --abort-on-container-exit
run: sudo CONFIG_FILE=./env/docker-tests.js docker compose -f docker-compose-run-tests.yml up --abort-on-container-exit
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "1.0.0",
"private": true,
"description": "idptools client project",
"author": "Robert C. Broeckelmann Jr. <robert@iyasec.io>",
"author": "Robert C. Broeckelmann Jr. <info@iyasec.io>",
"scripts": {
"start": "node server.js"
},
Expand Down
233 changes: 215 additions & 18 deletions api/server.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
// File: server.js
// Author: Robert C. Broeckelmann Jr.
// Date: 05/31/2020
// Notes:
//
'use strict';

var appconfig = require(process.env.CONFIG_FILE);
Expand All @@ -20,17 +15,31 @@
const LOG_LEVEL = appconfig.logLevel || 'debug';
const uiUrl = appconfig.uiUrl || 'http://localhost:3000';

const STATUS_200 = 200;
const STATUS_400 = 400;
const STATUS_401 = 401;
const STATUS_403 = 403;
const STATUS_404 = 404;
const STATUS_500 = 500;

var log = bunyan.createLogger({ name: 'server',
level: LOG_LEVEL });
log.info("Log initialized. logLevel=" + log.level());

var claimDescriptions = "";
var cachedClaimDescriptions = false;

const app = express();
const expressSwagger = require('express-swagger-generator')(app);

app.use(bodyParser.json());
var corsOptions = {
origin: '*',

Check warning

Code scanning / CodeQL

Permissive CORS configuration Medium

CORS Origin allows broad access due to
permissive or user controlled value
.
optionsSuccessStatus: 204
};
// app.use(expressLogging(logger));
app.options('*', cors());
app.use(cors());
app.options("*", cors(corsOptions));
app.use(cors(corsOptions));

/**
* @typedef HealthcheckResponse
Expand All @@ -45,28 +54,70 @@
* @returns {Error.model} 500 - Unexpected error
*/
app.get('/healthcheck', function (req, res) {
res.json({ message: 'Success' });
res
.status(STATUS_200)
.json({ message: 'Success' });
});

/**
* System healthcheck
* Retrieve Claims Description.
* @route GET /claimdescription
* @group Metadata - Support operations
* @returns {HealthcheckResponse.model} 200 - Claim Description Response
* @returns {Error.model} 400 - Syntax error
* @returns {Error.model} 500 - Unexpected error
*/
app.get('/claimdescription', function(req, res) {
fetch("https://www.iana.org/assignments/jwt/jwt.xml")
.then((response) => {
response.text()
.then( (text) => {
log.debug("Retrieved: " + text);
console.log("Entering GET /claimdescription.");
try {
if(cachedClaimDescriptions) {
console.debug("Using cached claim descriptions.");
res
.append('Content-Type', 'application/xml')
.send(text)
.status(STATUS_200)
.send(claimDescriptions);
} else {
log.debug("Pulling claim descriptions");
fetch("https://www.iana.org/assignments/jwt/jwt.xml")
.then((response) => {
response
.text()
.then( (text) => {
log.debug("Retrieved: " + text);
res
.append('Content-Type', 'application/xml')
.send(text);
cachedClaimDescriptions = true;
claimDescriptions = text;
});
})
.catch(function (error) {
log.error('Error from claimsdescription endpoint: ' + error.stack);
if(!!error.response) {
if(!!error.response.status) {
log.error("Error Status: " + error.response.status);
}
if(!!error.response.data) {
log.error("Error Response body: " + JSON.stringify(error.response.data));
}
if(!!error.response.headers) {
log.error("Error Response headers: " + error.response.headers);
}
if (!!error.response) {
res.status(error.response.status);
res.json(error.response.data);
} else {
res.status(STATUS_500);
res.json(error.message);
}
}
});
});
}
} catch(e) {
log.error("An error occurred while retrieving the claim description XML: " + e.stack);
res.status(STATUS_500)
.render('error', { error: e });

Check warning

Code scanning / CodeQL

Information exposure through a stack trace Medium

This information exposed to the user depends on
stack trace information
.

Copilot Autofix

AI 1 day ago

To fix the information exposure via stack trace, we should ensure that information sent to the user contains only generic error details, while any detailed error logs (including stack traces) are retained only in server logs. Specifically:

  • In file api/server.js, at line 119, replace res.render('error', { error: e }); with a more generic response.
  • Log the complete error (including stack trace) server-side using log.error, as is already done.
  • For the user response, send only a general message, such as "An unexpected error occurred." or a structured error object containing a generic message and sanitized code, but never expose stack traces or internal exception details.
  • If standardized error codes or models are used (such as Swagger), ensure the structure matches expectations, but content remains generic.

No change to imports is required; use only standard logging.


Suggested changeset 1
api/server.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/api/server.js b/api/server.js
--- a/api/server.js
+++ b/api/server.js
@@ -116,7 +116,11 @@
   } catch(e) {
     log.error("An error occurred while retrieving the claim description XML: " + e.stack);
     res.status(STATUS_500)
-       .render('error', { error: e });
+       .json({
+         status: false,
+         code: 'UNEXPECTED_ERROR',
+         message: 'An unexpected error occurred.'
+       });
   }
 });
 
EOF
@@ -116,7 +116,11 @@
} catch(e) {
log.error("An error occurred while retrieving the claim description XML: " + e.stack);
res.status(STATUS_500)
.render('error', { error: e });
.json({
status: false,
code: 'UNEXPECTED_ERROR',
message: 'An unexpected error occurred.'
});
}
});

Copilot is powered by AI and may make mistakes. Always verify output.
}
});

/**
Expand Down Expand Up @@ -176,11 +227,158 @@
});
} catch (e) {
log.error('An error occurred: ' + e);
res.status(500);
res.status(STATUS_500);
res.json({ "error": e });
}
});

/**
* @typedef IntrospectionRequest
* @property {string} grant_type.required - The OAuth2 / OIDC Grant / Flow Type
* @property {string} client_id.required - The OAuth2 client identifier
*/

/**
* @typedef IntrospectionResponse
* @property {string} access_token.required - The OAuth2 Access Token
* @property {string} id_token - The OpenID Connect ID Token
*/

/**
* Wrapper around OAuth2 Introspection Endpoint
* @route POST /introspection
* @group Debugger - Operations for OAuth2/OIDC Debugger
* @param {IntrospectionRequest.model} req.body.required - Token Endpoint Request
* @returns {IntrospectionResponse.model} 200 - Token Endpoint Response
* @returns {Error.model} 400 - Syntax error
* @returns {Error.model} 500 - Unexpected error
*/
app.post('/introspection', (req, res) => {
try {
log.info('Entering app.post for /introspection.');
const body = req.body;
log.debug('body: ' + JSON.stringify(body));
var headers = {
"Authorization": req.headers.authorization,
"Content-Type": "application/x-www-form-urlencoded"
};
var introspectionRequestMessage = {
token: body.token,
token_type_hint: body.token_type_hint
}
const parameterString = JSON.stringify(introspectionRequestMessage);
log.debug("Method: POST");
log.debug("URL: " + body.introspectionEndpoint);
log.debug("headers: " + JSON.stringify(headers));
log.debug("body: " + parameterString);
axios({
method: 'post',
url: body.introspectionEndpoint,
headers: headers,
data: introspectionRequestMessage,
httpsAgent: new (require('https').Agent)({ rejectUnauthorized: true })
})
.then(function (response) {
log.debug('Response from OAuth2 Introspection Endpoint: ' + JSON.stringify(response.data));
log.debug('Headers: ' + response.headers);
res.status(response.status);
res.json(response.data);
})
.catch(function (error) {
log.error('Error from OAuth2 Introspection Endpoint: ' + error);
if(!!error.response) {
if(!!error.response.status) {
log.error("Error Status: " + error.response.status);
}
if(!!error.response.data) {
log.error("Error Response body: " + JSON.stringify(error.response.data));
}
if(!!error.response.headers) {
log.error("Error Response headers: " + error.response.headers);
}
if (!!error.response) {
res.status(error.response.status);
res.json(error.response.data);
} else {
res.status(STATUS_500);
res.json(error.message);
}
}
});
} catch(e) {
log.error("Error from OAuth2 Introspection Endpoint: " + error);
}
});

app.post('/userinfo', (req, res) => {
log.info('Entering app.post for /userinfo.');
userinfo_common(req, res);
log.debug("Leaving app.post for /userinfo.");
});

/**
* Wrapper around OIDC UserInfo Endpoint
* @route POST /userinfo
* @group Debugger - Operations for OAuth2/OIDC Debugger
* @param {UserInfoRequest.model} req.body.required - UserInfo Endpoint Request
* @returns {UserInfoResponse.model} 200 - UserInfo Endpoint Response
* @returns {Error.model} 400 - Syntax error
* @returns {Error.model} 500 - Unexpected error
*/
app.get('/userinfo', (req, res) => {
log.info("Entering app.get for /userinfo.");
userinfo_common(req, res);
log.debug("Leaving app.get for /userinfo.");
});

function userinfo_common(req, res) {
try {
log.info('Entering app.get for /userinfo.');
var headers = {
"Authorization": req.headers.authorization,
};
// All types of requests are converted to GET.
log.debug("Method: GET");
log.debug("URL: " + Buffer.from(req.query.userinfo_endpoint, 'base64').toString('utf-8'));
log.debug("headers: " + JSON.stringify(headers));
axios({
method: 'get',
url: Buffer.from(req.query.userinfo_endpoint, 'base64').toString('utf-8'),
headers: headers,
httpsAgent: new (require('https').Agent)({ rejectUnauthorized: true })
})
Comment on lines +344 to +349

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.

Copilot Autofix

AI 1 day ago

To fix the SSRF issue, we need to ensure the outgoing request’s URL cannot be arbitrarily set by the user. The most robust standard fix is to restrict the target host/domain with an allow-list, so that only pre-approved endpoints can be used. For example, instead of using any URL provided by the user, the code should map a user-supplied identifier (such as an alias or region code) to a specific endpoint from a list of known safe URLs. If the architecture requires the full URL, then strict validation (e.g., regex, hostname checking, path disallowing local/internal services) must be implemented.

In this case, we should:

  • Define an allow-list of permitted endpoints (e.g., in an object mapping of endpoint aliases to URLs).
  • Accept from the client only either: (a) an alias key that selects the real endpoint, or (b) a base64 value and then check the decoded URL against the allow-list.
  • If the value does not match any allowed endpoint, reject the request with an error status and message.

Steps (in file api/server.js):

  1. Add a mapping (object) of allowed endpoints (e.g., OIDC issuers’ userinfo URLs).
  2. In userinfo_common, fetch either an alias or a URL, decode it if needed, and check that it matches the allowed endpoints.
  3. If valid, proceed with the request. If not, respond with 400 (Bad Request) and error message.
  4. Optionally, log invalid attempts for audit.
  5. No additional imports are necessary.

Suggested changeset 1
api/server.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/api/server.js b/api/server.js
--- a/api/server.js
+++ b/api/server.js
@@ -331,51 +331,66 @@
   log.debug("Leaving app.get for /userinfo.");
 });
 
+// Allowed OIDC UserInfo endpoints (replace with your valid endpoints)
+const ALLOWED_USERINFO_ENDPOINTS = [
+  "https://accounts.google.com/o/oauth2/v3/userinfo",
+  "https://login.microsoftonline.com/common/openid/userinfo",
+  // Add more allowed endpoints here
+];
+
 function userinfo_common(req, res) {
-try {
-  log.info('Entering app.get for /userinfo.');
-  var headers = {
-    "Authorization": req.headers.authorization,
-  };
-  // All types of requests are converted to GET.
-  log.debug("Method: GET");
-  log.debug("URL: " + Buffer.from(req.query.userinfo_endpoint, 'base64').toString('utf-8'));
-  log.debug("headers: " + JSON.stringify(headers));
-  axios({
-      method: 'get',
-      url: Buffer.from(req.query.userinfo_endpoint, 'base64').toString('utf-8'),
-      headers: headers,
-      httpsAgent: new (require('https').Agent)({ rejectUnauthorized: true })
-    })
-    .then(function (response) {
-      log.debug('Response from OIDC UserInfo Endpoint: ' + JSON.stringify(response.data));
-      log.debug('Headers: ' + response.headers);
-      res.status(response.status);
-      res.json(response.data);
-    })
-    .catch(function (error) {
-      log.error('Error from OIDC UserInfo Endpoint: ' + error);
-      if(!!error.response) {
-        if(!!error.response.status) {
-          log.error("Error Status: " + error.response.status);
+  try {
+    log.info('Entering app.get for /userinfo.');
+    var headers = {
+      "Authorization": req.headers.authorization,
+    };
+    // All types of requests are converted to GET.
+    log.debug("Method: GET");
+    let decodedUrl = Buffer.from(req.query.userinfo_endpoint, 'base64').toString('utf-8');
+    log.debug("Decoded userinfo_endpoint: " + decodedUrl);
+    log.debug("headers: " + JSON.stringify(headers));
+
+    if (!ALLOWED_USERINFO_ENDPOINTS.includes(decodedUrl)) {
+      log.error("Denied userinfo_endpoint: " + decodedUrl);
+      res.status(400).json({ error: "Requested endpoint is not allowed." });
+      return;
+    }
+
+    axios({
+        method: 'get',
+        url: decodedUrl,
+        headers: headers,
+        httpsAgent: new (require('https').Agent)({ rejectUnauthorized: true })
+      })
+      .then(function (response) {
+        log.debug('Response from OIDC UserInfo Endpoint: ' + JSON.stringify(response.data));
+        log.debug('Headers: ' + response.headers);
+        res.status(response.status);
+        res.json(response.data);
+      })
+      .catch(function (error) {
+        log.error('Error from OIDC UserInfo Endpoint: ' + error);
+        if(!!error.response) {
+          if(!!error.response.status) {
+            log.error("Error Status: " + error.response.status);
+          }
+          if(!!error.response.data) {
+            log.error("Error Response body: " + JSON.stringify(error.response.data));
+          }
+          if(!!error.response.headers) {
+            log.error("Error Response headers: " + error.response.headers);
+          }
+          if (!!error.response) {
+            res.status(error.response.status);
+            res.json(error.response.data);
+          } else {
+            res.status(STATUS_500);
+            res.json(error.message);
+          }
         }
-        if(!!error.response.data) {
-          log.error("Error Response body: " + JSON.stringify(error.response.data));
-        }
-        if(!!error.response.headers) {
-          log.error("Error Response headers: " + error.response.headers);
-        }
-        if (!!error.response) {
-          res.status(error.response.status);
-          res.json(error.response.data);
-        } else {
-          res.status(STATUS_500);
-          res.json(error.message);
-        }
-      }
-    });
+      });
   } catch(e) {
-    log.error("Error from OIDC UserInfo Endpoint: " + error);
+    log.error("Error from OIDC UserInfo Endpoint: " + e);
   }
 }
 
EOF
Copilot is powered by AI and may make mistakes. Always verify output.
.then(function (response) {
log.debug('Response from OIDC UserInfo Endpoint: ' + JSON.stringify(response.data));
log.debug('Headers: ' + response.headers);
res.status(response.status);
res.json(response.data);
})
.catch(function (error) {
log.error('Error from OIDC UserInfo Endpoint: ' + error);
if(!!error.response) {
if(!!error.response.status) {
log.error("Error Status: " + error.response.status);
}
if(!!error.response.data) {
log.error("Error Response body: " + JSON.stringify(error.response.data));
}
if(!!error.response.headers) {
log.error("Error Response headers: " + error.response.headers);
}
if (!!error.response) {
res.status(error.response.status);
res.json(error.response.data);
} else {
res.status(STATUS_500);
res.json(error.message);
}
}
});
} catch(e) {
log.error("Error from OIDC UserInfo Endpoint: " + error);
}
}

let options = {
swaggerDefinition: {
info: {
Expand All @@ -200,7 +398,6 @@
basedir: __dirname, //app absolute path
files: ['server.js'] //Path to the API handle folder
};

expressSwagger(options)
app.listen(PORT, HOST);
log.info(`Running on http://${HOST}:${PORT}`);
Loading
Loading