Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.vscode
.DS_Store
.env
nul

/node_modules/
/ssl/*
Expand Down
69 changes: 40 additions & 29 deletions API/Backend/Draw/routes/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -532,18 +532,50 @@ const _templateConform = (req, from) => {
let numVal = response.newValue.replace(start, "").replace(end, "");
if (numVal != "#") {
numVal = parseInt(numVal);

// Check if this value collides with existing values
// Need to distinguish between editing an existing feature vs adding a new one
let hasCollision = false;

if (existingProperties[t.field] === response.newValue) {
// In case of a resave, make sure the id exists only once
// Value didn't change from what was sent by frontend
// Count occurrences excluding this feature's UUID
let count = 0;
for (let i = 0; i < layer.length; i++) {
if (layer[i] == null) continue;
let geojson = layer[i];
if (geojson?.properties?.[t.field] != null) {
let featuresVal = geojson?.properties?.[t.field];
let extractedVal = parseInt(featuresVal.replace(start, "").replace(end, ""));
// Count this occurrence only if it's a DIFFERENT feature (different UUID)
if (extractedVal === numVal &&
geojson?.properties?.uuid !== existingProperties.uuid) {
count++;
}
}
}
// If ANY other feature has this value, it's a collision
if (count > 0) {
hasCollision = true;
}
} else {
// Manual change to a different value - check if it already exists
if (usedValues.indexOf(numVal) !== -1) {
hasCollision = true;
}
}

if (hasCollision) {
// Auto-assign next available value instead of error
let bestVal = 0;
usedValues.sort(function (a, b) {
return a - b;
});
usedValues = [...new Set(usedValues)]; // makes it unique
usedValues.forEach((v) => {
if (numVal === v) count++;
if (bestVal === v) bestVal++;
});
if (count > 1)
response.error = `Incrementing field: '${t.field}' is not unique`;
} else {
// In case a manual change, make sure the id is unique
if (usedValues.indexOf(numVal) !== -1)
response.error = `Incrementing field: '${t.field}' is not unique`;
response.newValue = start + bestVal + end;
}
}
}
Expand All @@ -554,27 +586,6 @@ const _templateConform = (req, from) => {
response.error = `Incrementing field: '${t.field}' must follow syntax: '${start}{#}${end}'`;
}

// Check that incrementer is unique
let numMatches = 0;
for (let i = 0; i < layer.length; i++) {
if (layer[i] == null) continue;
let geojson = layer[i];
if (geojson?.properties?.[t.field] != null) {
let featuresVal = geojson?.properties?.[t.field];
if (
(value || "").indexOf("#") == -1 &&
response.newValue === featuresVal &&
geojson?.properties?.uuid != existingProperties.uuid
) {
numMatches++;
}
}
}
// If we're are editing and the value did not change, allow a single match
if (numMatches > 0) {
response.error = `Incrementing field: '${t.field}' is not unique`;
}

return response;
}
});
Expand Down
71 changes: 71 additions & 0 deletions API/Backend/Stac/routes/stac.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,75 @@
});
});

// Export collection with all items
router.get("/collections/:collection/export", async function (req, res, next) {
const { collection } = req.params;

const stacUrl = `http://${
process.env.IS_DOCKER === "true" ? "stac-fastapi" : "localhost"
}:${process.env.STAC_PORT || 8881}`;

try {
// Fetch collection metadata
const collectionResponse = await fetch(
`${stacUrl}/collections/${collection}`,
{
method: "GET",
headers: { "content-type": "application/json" },
}
);
Comment on lines +120 to +126

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

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

Copilot Autofix

AI 4 months ago

In general, to fix SSRF vulnerabilities when user input is used to determine part of a backend HTTP request URL, you must ensure that the user input cannot cause the server to access arbitrary resources. The best way to do this is to restrict the URL path segment (collection in this case) to a known-safe allow-list of permitted values, or to strictly validate that it is a safe value (for example, allowing only alphanumeric names, possibly including certain symbols like "-", "_", but never slashes or dots). The validation should happen immediately after extracting the user input from the request, before you use it in the fetch.

Specific steps:

  • Add a validation step after extracting collection from req.params.
    • You can either check collection is in a pre-defined allow-list (most secure), or enforce that it matches a safe pattern (e.g., using a regexp to allow only "^[\w-]+$" — letters, digits, underscore, dash).
  • If the value is unsafe, immediately reject the request with a 400 Bad Request (or similar) response.
  • No external libraries are strictly required—Node's RegExp is sufficient.
  • These code edits should be made at the beginning of the /collections/:collection/export handler, after retrieving collection.
Suggested changeset 1
API/Backend/Stac/routes/stac.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/Backend/Stac/routes/stac.js b/API/Backend/Stac/routes/stac.js
--- a/API/Backend/Stac/routes/stac.js
+++ b/API/Backend/Stac/routes/stac.js
@@ -111,6 +111,21 @@
 router.get("/collections/:collection/export", async function (req, res, next) {
   const { collection } = req.params;
 
+  // Validate collection to prevent SSRF: only allow alphanumerics, underscore, hyphen.
+  if (!/^[\w-]+$/.test(collection)) {
+    logger(
+      "error",
+      "Invalid collection name in export request",
+      req.originalUrl,
+      req,
+      { collection }
+    );
+    return res.status(400).send({
+      status: "failure",
+      message: "Invalid collection name.",
+    });
+  }
+
   const stacUrl = `http://${
     process.env.IS_DOCKER === "true" ? "stac-fastapi" : "localhost"
   }:${process.env.STAC_PORT || 8881}`;
EOF
@@ -111,6 +111,21 @@
router.get("/collections/:collection/export", async function (req, res, next) {
const { collection } = req.params;

// Validate collection to prevent SSRF: only allow alphanumerics, underscore, hyphen.
if (!/^[\w-]+$/.test(collection)) {
logger(
"error",
"Invalid collection name in export request",
req.originalUrl,
req,
{ collection }
);
return res.status(400).send({
status: "failure",
message: "Invalid collection name.",
});
}

const stacUrl = `http://${
process.env.IS_DOCKER === "true" ? "stac-fastapi" : "localhost"
}:${process.env.STAC_PORT || 8881}`;
Copilot is powered by AI and may make mistakes. Always verify output.

if (!collectionResponse.ok) {
throw new Error("Collection not found");
}

const collectionData = await collectionResponse.json();

// Fetch all items with pagination
let allItems = [];
let nextUrl = `${stacUrl}/collections/${collection}/items?limit=10000`;

while (nextUrl) {
const itemsResponse = await fetch(nextUrl, {
method: "GET",
headers: { "content-type": "application/json" },
});
Comment on lines +139 to +142

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

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

if (!itemsResponse.ok) {
throw new Error("Failed to fetch items");
}

const itemsData = await itemsResponse.json();
allItems = allItems.concat(itemsData.features || []);

// Check for next page link
const nextLink = itemsData.links?.find((link) => link.rel === "next");
nextUrl = nextLink ? nextLink.href : null;
}

// Combine into export format
const exportData = {
collection: collectionData,
items: allItems,
};

res.send({
status: "success",
body: exportData,
});
} catch (error) {
logger(
"error",
"Failed to export STAC Collection",
req.originalUrl,
req,
error
);
res.status(500).send({
status: "failure",
message: error.message || "Failed to export STAC Collection",
});
}
});

module.exports = router;
9 changes: 9 additions & 0 deletions configuration/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -629,5 +629,14 @@ module.exports = function (webpackEnv) {
// Turn off performance processing because we utilize
// our own hints via the FileSizeReporter
performance: false,
// Suppress warnings from node_modules dependencies
ignoreWarnings: [
// Suppress warning from @ffmpeg/ffmpeg worker.js. This is a known issue with the
// library's dynamic imports for worker files, but it works correctly at runtime.
{
module: /node_modules\/@ffmpeg\/ffmpeg/,
message: /Critical dependency: the request of a dependency is an expression/,
},
],
};
};
2 changes: 1 addition & 1 deletion configure/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "configure",
"version": "4.1.11-20251113",
"version": "4.1.18-20251205",
"homepage": "./configure/build",
"private": true,
"dependencies": {
Expand Down
2 changes: 2 additions & 0 deletions configure/src/core/ConfigureStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ export const ConfigureStore = createSlice({
newStacCollection: false,
stacCollectionItems: false,
stacCollectionJson: false,
editStacCollection: false,
layersUsedByStacCollection: false,
deleteStacCollection: false,
importStacItems: false,
uploadConfig: false,
cloneConfig: false,
deleteConfig: false,
Expand Down
6 changes: 3 additions & 3 deletions configure/src/core/Maker.js
Original file line number Diff line number Diff line change
Expand Up @@ -393,9 +393,9 @@ const getComponent = (

// Get the configurable disabled message or use a default
const disabledMessage = com.disabledMessage || "This feature is disabled.";

let inner;
let disabled = false;
let disabled = com.disabled || false;
if (com.disableSwitch) {
let switchVal = getIn(configuration, com.disableSwitch, null);
if (switchVal == null) {
Expand All @@ -407,7 +407,7 @@ const getComponent = (
switchVal = false;
}
}
disabled = !switchVal;
disabled = disabled || !switchVal;
}
const isRequired = isFieldRequired(com, layer, configuration);
const fieldValue = value != null ? value : getIn(directConf, com.field, "");
Expand Down
18 changes: 16 additions & 2 deletions configure/src/core/calls.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,18 @@ const c = {
type: "DELETE",
url: "stac/collections/:collection/items/:item",
},
stac_bulk_items: {
type: "POST",
url: "stac/collections/:collection/bulk_items",
},
stac_export_collection: {
type: "GET",
url: "api/stac/collections/:collection/export",
},
stac_update_collection: {
type: "PUT",
url: "stac/collections/:collection",
},
account_entries: {
type: "GET",
url: "api/accounts/entries",
Expand Down Expand Up @@ -197,8 +209,10 @@ function api(call, data, success, error) {
delete data.forceParams;
}

if (c[call].type === "POST") options.body = JSON.stringify(data);
else if (c[call].type === "GET") options.data = JSON.stringify(data);
if (c[call].type === "POST" || c[call].type === "PUT" || c[call].type === "PATCH")
options.body = JSON.stringify(data);
else if (c[call].type === "GET")
options.data = JSON.stringify(data);

fetch(
`${domain}${url}${
Expand Down
Loading
Loading