Conversation
…to eliminate gatekeeper usage for rate limiting and security checks.
in src/core/pseudorandom.ts a helper function of nextint is defined but never used. instead, this.rng is used. those are replaced. also, shuffleArray originally would self-shuffle at i=0 for the conditional being i>=0, fixed to 0. - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced regression is found: jack_45183
Player names are hidden, except small sized fonts, when zoomed in too much(near max), improving user experience by allowing users to see structures clearly without the obstruction of text - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file(no need) - [x] I have added relevant tests to the test directory(n/a) - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced [video](https://streamable.com/e/mlrfqo?) regression is found: _federalagent
Added tests/NationNameLength.test.ts to enforce nation name length ≤27. Updated manifest.json files in resources/maps with shorter names to pass the test. This fix ensures that names will no longer be truncated Examples of country names that are too long and have their endings cut off <img width="231" height="331" alt="スクリーンショット 2025-10-01 10 51 40" src="https://github.com/user-attachments/assets/8c4611ab-f97b-4606-9834-7816dbd1ee8d" /> - Replaced overly long country names with shorter common forms: - "The Democratic Republic of the Congo" -> "DR Congo" - "Democratic Republic of the Congo" -> "DR Congo" - "Lao People's Democratic Republic" -> "Laos" - "Federated States of Micronesia" -> "Micronesia" - "People's Democratic Republic of Algeria" -> "Algeria" - "People's Republic of Algeria'" -> "Algeria" - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced regression is found: aotumuri
…Storage dependency
Fix null socket crashing the log - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced regression is found: Mr.Box
…57 (#2365) Before, the condition was "if it's not singleplayer", but replays are counted as singleplayer game for some reason (will need to fix the underlying issue) so it wasn't robust enough Now the condition is based on are we in replay or not,. Related to issue openfrontio/OpenFrontIO#2357 I cannot get to replay games locally for some reason (client just throws an error that it cannot load the lobby), so I made sure that the singleplayer text did not change (at least no regression) cf screenshot: <img width="1920" height="963" alt="Screenshot 2025-11-02 at 17 46 57" src="https://github.com/user-attachments/assets/27e055a8-3813-46bd-a8ae-0c463a94d1a8" /> - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced regression is found: sorikairo
…y cluster calculation logic
…r in a single scan
… alpha channel to fully opaque
Previously, the zod schemas for troop and gold donation allowed for negative values which could open the game up to vulnerabilities through undefined behavior in the future. We mitigate these vulnerabilities but adding `.nonnegative` to the `DonateGoldIntentSchema` and `DonateTroopIntentShcema` respectively. Today, code exists to prevent this deeper in the codebase, but we should also prevent this earlier if possible during intent validation. - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced regression is found: haticus
- Implemented a new server for pathfinding playground with API routes for maps and pathfinding. - Added utility functions for benchmarking pathfinding performance. - Created a comprehensive test map manifest and associated binary files for testing. - Enhanced setup utility to support binary map formats and legacy PNG formats. - Introduced performance tests for A* pathfinding with various scenarios.
- Updated HTML structure to replace references from "Gateways" to "Nodes" and "Used Gateways" to "Used Nodes". - Changed button IDs and labels to reflect the new pathfinding algorithm (HPA*). - Removed unused PF.Mini pathfinding API endpoint and adjusted the main pathfinding endpoint to accept comparison adapters. - Enhanced CSS styles for comparison rows in the UI. - Updated pathfinding utility functions to support new pathfinding algorithms and configurations. - Deleted obsolete performance test for A* pathfinding.
This PR introduces final change to the pathfinding - path refinement. It optimizes Line of Sight refinement by searching with for the best tile with a binary search instead of linearly. And then spends the recovered budget on better refinement of the first and last 50 tiles of the journey - the place where user is most likely to look at. Additionally this PR re-introduces magnitude check and makes the ships prefer sailing close to the coast, but not too close. - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced | Before | After | | :--- | :--- | | <img width="1097" height="1117" alt="image" src="https://github.com/user-attachments/assets/4a0b300d-10ef-4151-b6dc-33acfb49f992" /> | <img width="1093" height="1119" alt="image" src="https://github.com/user-attachments/assets/cf81c515-c145-40f4-91e5-a4353986907b" /> | | <img width="1096" height="1129" alt="image" src="https://github.com/user-attachments/assets/21b46bce-f961-4259-88f6-fe4a66180270" /> | <img width="1098" height="1126" alt="image" src="https://github.com/user-attachments/assets/d92587d1-e6b6-4353-b4a4-1efe71bca43d" /> | There is actually a severe performance impact of these changes. The path initial path takes almost 2x as long to generate - this is because pre processing can only do so much if the initial path is ugly. Luckily in real gameplay we only need to do this calculation once per edge, so the actual observed performance impact should be much smaller. Cache FTW. | | No Cache | Cache | | :--- | :--- | :--- | | Before | 277.04ms | 208.58ms | | After | 498.34ms | 264.27ms | Small utility, it allows any code to be easily instrumented for performance. The idea is the same as with [OTEL Spans](https://opentelemetry.io/docs/concepts/signals/traces/). Produce a span, create sub-spans, measure whatever you need. Works only when `globalThis.__DEBUG_SPAN_ENABLED__ === true`, otherwise no-op. Cool stuff, try it out: ```ts // Convenient wrapper, small performance impact return DebugSpan.wrap('add', () => a + b) // Synchronous API, basically free DebugSpan.start('work') work() DebugSpan.end() // Create sub spans DebugSpan.wrap('complex', () => { const aPlusB = DebugSpan.wrap('add', () => a + b) DebugSpan.set('additionResult', () => aPlusB) // Store data return aPlusB * c }) // Access spans, data and timing const span = DebugSpan.getLast() const compelxSpan = DebugSpan.getLast('complex') console.log(complexSpan.duration, complexSpan.data['additionResult']) ``` These are virtually free and can be enabled on-demand **in production** and available in the devtools. Under the hood devtools integration is just a wrapper around [Performance API](https://developer.mozilla.org/en-US/docs/Web/API/Performance_API). For clarity data keys not prefixed by `$` are omitted from the integration. Every key prefixed with `$` must be fully JSON serializable. <img width="977" height="799" alt="image" src="https://github.com/user-attachments/assets/b4d43506-1639-4f78-a611-30e61de12a07" />
https://pf-pt-4.openfront.dev/ Hello again! Pathfinding. It's fast, but inaccurate. This PR makes it more accurate and actually faster. Sadly it is _faster_ because of a blunder in previous PR (using BucketQueue where MinHeap would be better), not because of a new tech. More importantly, it is more accurate. And that's what people apparently want. Most of the functional changes relate to `SpatialQuery` module. This is the thingy that answers "we know the target, which tile of my territory is the best to launch an invasion". To make it compute a path from South America to the deep inland China river, it has to work on a coerced map, one with a very small resolution, so small in fact, that every 4096 map tiles gets compressed to just one pixel. I hope you see where this is going. Previously we selected a random coastal tile within this big pixel (honestly it wasn't random at all, but could very well be for the illustrative purposes). Now, we try to be a bit more deliberate. Since we already know the rough location of the probably best tile, we can exclude all other tiles from the computation. Imagine a player's territory spans both Americas on global map - that's a lot of shores. But since we already know the best tile is somewhere close to Miami, the problem space was greatly reduced, no need to consider all other shores. But pathing to the target in China from Miami is still crazy expensive. This is where second trick comes to play - instead of pathing all the way to China, we select a _waypoint_ in the rough direction of China, about 100 to 200 tiles away. This way we fairly cheaply select best tile to launch an invasion towards this abstract point. And chances are, this point is far enough, the newly computed path is very close to being optimal. When you throw a dart from far away, the difference between scoring 10 and missing is very small. This is why aiming in the general direction of the board - as opposed to the ceiling - is usually good enough. opposed bank of a river?! Well, pathing from America to China is cool, but most players wouldn't notice the difference on such long paths, what about the short ones? We now try more accurate pathing first and defer to hierarchy only if it fails. This produces much better paths for short invasions. While the fix described above ensures the accuracy is improved also on medium-to-long routes. Yes. https://github.com/user-attachments/assets/9cf9586f-c99a-416d-b856-8cf0a21c35ed Grab a 🥕. Remember `tests/pathfinding/playground` is mostly generated code and go easy on it. It's enough for it to work and do it's job of visualizing the paths. No need for throughout review of these files. - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced regression is found: moleole
…oercingTransformer (#2937) **Describe the PR.** This PR improves how pathfinding finds a starting water tile when launching a transport ship from a shore. Previously, the code simply picked the first water neighbor it found. This caused issues where, if a boat were traveling east, it might launch out of a northern tile from a shore. <img width="896" height="353" alt="image" src="https://github.com/user-attachments/assets/69d83012-3397-43b3-8ab0-9ebde6ffea97" /> <img width="342" height="219" alt="image" src="https://github.com/user-attachments/assets/a191f5cf-97da-4e34-a191-55ce14c794f0" /> The new logic checks all water neighbors and picks the "best" one by counting how many water tiles surround it. This ensures transport ships launch into the main body of water instead of suboptimal positions. If two tiles have water neighbors with the same score, they are tie-broken through a euclidean distance check. - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced regression is found: Scisyph --------- Co-authored-by: WilliamT-byte <williamt2023@tamu.edu> Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com>
As reported on Discord, warship could get stuck. This PR fixes the issue. - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced regression is found: moleole
Math was not mathing, increased the bounds to 260x260, it is a bit slower but should work better. The short path was breaking when player owned a lot of shores. This is because the bounding box of tiles with less than 120 distance + 10 padding could be as big as 260x260 and the optimized array was set to 140x140. I made mistake of calculating it as `2 * (60 + 10)` instead of `2 * (120 + 10)`. Previously, we ran 2 passes of LoS smoothing on the path. However, since we are effectively tracing the same path, the line of sight is essentially the same. This PR makes second line of sight stop on water tiles with magnitude `n + 1` compared to first path. Practically, this means it'll attempt LoS exactly 1 tile after previous corner. See screenshot. <img width="1299" height="1151" alt="image" src="https://github.com/user-attachments/assets/726be236-1ff8-406c-896a-02902a762ab0" /> The flow of sending transport ships is currently strange. This PR makes the flow more sane. **Old flow** ``` - Player clicks TARGET tile, it can be deep inland - Client asks Worker for the best START tile to TARGET tile - Worker answers `false`, since the tile is inland - Client sends BoatAttackIntent with START=false and TARGET tiles set - Worker accepts BoatAttackIntent, computes DESTINATION as closest shore to TARGET - Worker re-computes best START to DESTINATION - Worker sends boat from START to DESTINATION ``` **New flow** ``` - Player clicks TARGET tile, it can be deep inland - Client sends BoatAttackIntent with TARGET - Worker accepts BoatAttackIntent, computes DESTINATION as closest shore to TARGET - Worker computes START as the best tile to DESTINATION - Worker sends boat from START to DESTINATION ``` - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced regression is found: moleole
Optimize border-tile maintenance in `GameImpl` to reduce per-conquest overhead. Border tiles are updated whenever ownership changes; this PR trims allocations and avoids unnecessary iteration in the hot path. - `src/core/game/GameImpl.ts` - `updateBorders(tile)` no longer allocates an array of tiles; it updates the changed tile and its 4-neighbors directly via `forEachNeighbor`. - `calcIsBorder(tile)` no longer calls `neighbors(tile)` / loops an array; it checks the four cardinal neighbors via `x/y` bounds and `ownerID`. - `GameImpl.updateBorders(tile: TileRef)` - `GameImpl.calcIsBorder(tile: TileRef): boolean` - Call sites impacted by behavior/perf: - `GameImpl.conquer(owner, tile)` - `GameImpl.relinquish(tile)` - [ ] I have added screenshots for all UI updates - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] I have added relevant tests to the test directory - [ ] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced regression is found: DISCORD_USERNAME
… and improve readability
…rove performance feat(UnitGrid): enhance nearby unit search with fast path for single-type queries feat(DefaultConfig): add unit info caching to improve unit info retrieval efficiency
| const response = await fetch( | ||
| `http://localhost:${config.workerPort(gameID)}/api/kick_player/${gameID}/${clientID}`, | ||
| { | ||
| method: "POST", | ||
| headers: { | ||
| [config.adminHeader()]: config.adminToken(), | ||
| }, | ||
| ); | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error(`Failed to kick player: ${response.statusText}`); | ||
| } | ||
| }, | ||
| ); |
Check failure
Code scanning / CodeQL
Server-side request forgery Critical
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 2 days ago
General fix: Avoid constructing the internal request URL directly from user input. Instead, use the user input only as a key into trusted server-side data (for instance, a map of active games to worker ports and worker endpoints). Use the result of that lookup to build the URL (or even just post to a known admin endpoint on the worker), and keep the path and host under full server control.
Best concrete fix here: publicLobbiesData already exists and presumably tracks current games. We can use gameID and clientID to look up a corresponding public lobby entry and derive trusted data (like worker port and the canonical game/client IDs) from that object. We then construct the URL using those trusted values. This way, the request URL is no longer based directly on the untrusted params but on server-side state that must already be valid to exist. This addresses CodeQL’s taint-tracking while preserving functionality (we still kick the same player in the same game, but only if that game/client pair exists in our tracked lobbies).
Concretely in src/server/Master.ts around the /api/kick_player/:gameID/:clientID handler:
- After validating
gameIDandclientID, look up the corresponding lobby inpublicLobbiesData(or whatever structure is holding active games). If no entry matches, return404(or400). - Derive a trusted
workerPortfrom the lobby’s storedgameID(or storedportif present). - Construct the URL using the lobby’s stored
gameIDandclientIDinstead of the rawreq.params, so the taint stops at the lookup. - Keep the rest of the logic (admin header check, fetch, response handling) unchanged.
This requires only edits inside the shown handler body and uses existing imports and variables; no new external dependencies are needed.
| @@ -224,9 +224,22 @@ | ||
| return; | ||
| } | ||
|
|
||
| // Look up the lobby using the validated IDs so that the internal request | ||
| // is based on trusted server-side state rather than raw user input. | ||
| const lobby = publicLobbiesData.find( | ||
| (lobby) => lobby.gameID === gameID && lobby.clientID === clientID, | ||
| ); | ||
|
|
||
| if (!lobby) { | ||
| res.sendStatus(404); | ||
| return; | ||
| } | ||
|
|
||
| const workerPort = config.workerPort(lobby.gameID); | ||
|
|
||
| try { | ||
| const response = await fetch( | ||
| `http://localhost:${config.workerPort(gameID)}/api/kick_player/${gameID}/${clientID}`, | ||
| `http://localhost:${workerPort}/api/kick_player/${lobby.gameID}/${lobby.clientID}`, | ||
| { | ||
| method: "POST", | ||
| headers: { |
| const metadata = await getMapMetadata(name); | ||
| res.json(metadata); | ||
| } catch (error) { | ||
| console.error(`Error loading map ${req.params.name}:`, error); |
Check failure
Code scanning / CodeQL
Use of externally-controlled format string High test
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 2 days ago
In general, to fix externally controlled format string issues with Node’s console (or util.format), ensure that untrusted data is never part of the format string argument. Instead, use a constant format string containing %s (or appropriate specifier) and pass the untrusted value as a separate argument, or avoid formatting features by concatenating into a single string and passing only one argument.
For this specific case in tests/pathfinding/playground/server.ts, we should change the console.error call in the /api/maps/:name route so that the first argument is a constant string containing %s, and pass req.params.name and error as subsequent arguments. Functionality remains the same—the logged message content is equivalent—but we no longer allow user input to influence the format string. No new imports or helper methods are needed; we only adjust the single logging statement at line 53.
Concretely:
- In
tests/pathfinding/playground/server.ts, locate theconsole.errorline inside thecatchblock of the/api/maps/:nameroute. - Replace:
console.error(`Error loading map ${req.params.name}:`, error);
- With:
console.error("Error loading map %s:", req.params.name, error);
No other lines or files need to change.
| @@ -50,7 +50,7 @@ | ||
| const metadata = await getMapMetadata(name); | ||
| res.json(metadata); | ||
| } catch (error) { | ||
| console.error(`Error loading map ${req.params.name}:`, error); | ||
| console.error("Error loading map %s:", req.params.name, error); | ||
|
|
||
| if (error instanceof Error && error.message.includes("ENOENT")) { | ||
| res.status(404).json({ |
| app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => { | ||
| try { | ||
| const { name } = req.params; | ||
| const thumbnailPath = join( | ||
| dirname(fileURLToPath(import.meta.url)), | ||
| "../../../resources/maps", | ||
| name, | ||
| "thumbnail.webp", | ||
| ); | ||
| res.sendFile(thumbnailPath); | ||
| } catch (error) { | ||
| console.error(`Error loading thumbnail for ${req.params.name}:`, error); | ||
| res.status(404).json({ | ||
| error: "Thumbnail not found", | ||
| message: error instanceof Error ? error.message : String(error), | ||
| }); | ||
| } | ||
| }); |
Check failure
Code scanning / CodeQL
Missing rate limiting High test
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 2 days ago
In general, the fix is to introduce a rate-limiting middleware that caps how many requests a client can make to handlers that perform filesystem access, in this case the /api/maps/:name/thumbnail route. The usual way in Express is to use a library such as express-rate-limit to define a limiter (specifying a time window and a max number of requests per IP) and then apply that limiter either globally or to specific routes.
The minimal, non-breaking change here is:
- Import
express-rate-limit. - Create a dedicated limiter for thumbnail requests (e.g., allow a reasonable number per minute).
- Apply that limiter only to the
/api/maps/:name/thumbnailroute so existing behavior of other endpoints is unchanged.
Concretely in tests/pathfinding/playground/server.ts:
- Add an import for
express-rate-limitnear the top. - After creating the Express app and before defining routes, define a
thumbnailRateLimiterusingrateLimit({ windowMs: ..., max: ... }). - Modify the
app.get("/api/maps/:name/thumbnail", ...)registration to include the limiter as middleware:app.get("/api/maps/:name/thumbnail", thumbnailRateLimiter, (req, res) => { ... }).
No other logic inside the handler needs to change.
| @@ -2,6 +2,7 @@ | ||
| import express, { Request, Response } from "express"; | ||
| import { dirname, join } from "path"; | ||
| import { fileURLToPath } from "url"; | ||
| import rateLimit from "express-rate-limit"; | ||
| import { | ||
| clearCache as clearMapCache, | ||
| getMapMetadata, | ||
| @@ -13,6 +14,12 @@ | ||
| const app = express(); | ||
| const PORT = process.env.PORT ?? 5555; | ||
|
|
||
| // Rate limiting | ||
| const thumbnailRateLimiter = rateLimit({ | ||
| windowMs: 60 * 1000, // 1 minute | ||
| max: 60, // limit each IP to 60 thumbnail requests per minute | ||
| }); | ||
|
|
||
| // Middleware | ||
| app.use(compression()); // gzip compression for large responses | ||
| app.use(express.json({ limit: "50mb" })); // JSON body parser with larger limit | ||
| @@ -70,24 +77,28 @@ | ||
| * GET /api/maps/:name/thumbnail | ||
| * Get map thumbnail image | ||
| */ | ||
| app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => { | ||
| try { | ||
| const { name } = req.params; | ||
| const thumbnailPath = join( | ||
| dirname(fileURLToPath(import.meta.url)), | ||
| "../../../resources/maps", | ||
| name, | ||
| "thumbnail.webp", | ||
| ); | ||
| res.sendFile(thumbnailPath); | ||
| } catch (error) { | ||
| console.error(`Error loading thumbnail for ${req.params.name}:`, error); | ||
| res.status(404).json({ | ||
| error: "Thumbnail not found", | ||
| message: error instanceof Error ? error.message : String(error), | ||
| }); | ||
| } | ||
| }); | ||
| app.get( | ||
| "/api/maps/:name/thumbnail", | ||
| thumbnailRateLimiter, | ||
| (req: Request, res: Response) => { | ||
| try { | ||
| const { name } = req.params; | ||
| const thumbnailPath = join( | ||
| dirname(fileURLToPath(import.meta.url)), | ||
| "../../../resources/maps", | ||
| name, | ||
| "thumbnail.webp", | ||
| ); | ||
| res.sendFile(thumbnailPath); | ||
| } catch (error) { | ||
| console.error(`Error loading thumbnail for ${req.params.name}:`, error); | ||
| res.status(404).json({ | ||
| error: "Thumbnail not found", | ||
| message: error instanceof Error ? error.message : String(error), | ||
| }); | ||
| } | ||
| }, | ||
| ); | ||
|
|
||
| /** | ||
| * POST /api/pathfind |
| name, | ||
| "thumbnail.webp", | ||
| ); | ||
| res.sendFile(thumbnailPath); |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High test
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 2 days ago
In general, to fix uncontrolled path usage, normalize the computed path and enforce that it remains within a designated root directory, or restrict the user input to a simple, safe filename pattern (e.g., alphanumerics and dashes only).
For this specific code, the least intrusive fix is to treat ../../../resources/maps as a root directory, resolve any requested thumbnail path relative to that root using path.resolve, and then verify that the final path still starts with the root directory. If it does not, return a 400/403 error instead of calling res.sendFile. This preserves existing behavior for valid map names while blocking traversal attempts. We can reuse the existing dirname and join imports; we need to also import resolve from "path" to build safe absolute paths.
Concretely, in tests/pathfinding/playground/server.ts:
- Update the import from
"path"to also importresolve. - Inside the
/api/maps/:name/thumbnailroute:- Compute a
mapsRootdirectory once (usingjoin(dirname(fileURLToPath(import.meta.url)), "../../../resources/maps")). - Compute
requestedPathusingresolve(mapsRoot, name, "thumbnail.webp"), which normalizes any..segments. - Check that
requestedPathstarts withmapsRoot(after normalizing both to absolute paths); if not, respond with400or403. - Call
res.sendFile(requestedPath)instead of the oldthumbnailPath.
- Compute a
This keeps functionality the same for legitimate names but blocks malicious ones.
| @@ -1,6 +1,6 @@ | ||
| import compression from "compression"; | ||
| import express, { Request, Response } from "express"; | ||
| import { dirname, join } from "path"; | ||
| import { dirname, join, resolve } from "path"; | ||
| import { fileURLToPath } from "url"; | ||
| import { | ||
| clearCache as clearMapCache, | ||
| @@ -73,12 +73,21 @@ | ||
| app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => { | ||
| try { | ||
| const { name } = req.params; | ||
| const thumbnailPath = join( | ||
| const mapsRoot = join( | ||
| dirname(fileURLToPath(import.meta.url)), | ||
| "../../../resources/maps", | ||
| name, | ||
| "thumbnail.webp", | ||
| ); | ||
| const thumbnailPath = resolve(mapsRoot, name, "thumbnail.webp"); | ||
|
|
||
| // Ensure the resolved path is within the mapsRoot directory to prevent path traversal | ||
| if (!thumbnailPath.startsWith(mapsRoot)) { | ||
| res.status(403).json({ | ||
| error: "Forbidden", | ||
| message: "Invalid map name", | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| res.sendFile(thumbnailPath); | ||
| } catch (error) { | ||
| console.error(`Error loading thumbnail for ${req.params.name}:`, error); |
| ); | ||
| res.sendFile(thumbnailPath); | ||
| } catch (error) { | ||
| console.error(`Error loading thumbnail for ${req.params.name}:`, error); |
Check failure
Code scanning / CodeQL
Use of externally-controlled format string High test
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 2 days ago
In general, to fix externally-controlled format string issues with console.* or util.format, avoid embedding untrusted input directly into the format string. Instead, use literal format strings (e.g. "Error loading thumbnail for %s:") and pass untrusted data as subsequent arguments, or concatenate using +/template literals without relying on formatting semantics when multiple arguments are used.
Here, the safest and smallest change is to keep the log message semantics but move req.params.name out of the template literal and into a separate argument. For example, change:
console.error(`Error loading thumbnail for ${req.params.name}:`, error);to:
console.error("Error loading thumbnail for %s:", req.params.name, error);This ensures the format string is a constant, and the user-controlled name is only substituted into the %s placeholder. No change in behavior is introduced: logs will still show the map name followed by the error. This change is localized to tests/pathfinding/playground/server.ts line 84, and no new imports or helper methods are required.
| @@ -81,7 +81,7 @@ | ||
| ); | ||
| res.sendFile(thumbnailPath); | ||
| } catch (error) { | ||
| console.error(`Error loading thumbnail for ${req.params.name}:`, error); | ||
| console.error("Error loading thumbnail for %s:", req.params.name, error); | ||
| res.status(404).json({ | ||
| error: "Thumbnail not found", | ||
| message: error instanceof Error ? error.message : String(error), |
| const miniMapBinPath = path.join(mapDirectory, mapName, "map4x.bin"); | ||
|
|
||
| // Check if files exist | ||
| if (!fs.existsSync(mapBinPath)) { |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High test
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 2 days ago
To fix uncontrolled path usage, we should constrain user-provided map identifiers (mapName / name / map) so they cannot escape the intended maps directory. The safest and least intrusive approach here is:
- Treat
mapNameas a simple identifier (no path separators or..), and reject anything that doesn’t match a strict pattern. - Additionally, resolve the final paths under a known maps root (
mapDirectory/ maps dir for thumbnails) and verify that the resolved path still lies within that root before accessing it.
This preserves existing behavior for legitimate map names but prevents directory traversal.
Concretely:
-
In
tests/pathfinding/utils.ts:- Add a small helper to validate
mapName(e.g., only alphanumerics,_,-,.) and throw if invalid. - In
setupFromPath, before constructing paths, validatemapName. - Build the base directory as
const baseDir = path.resolve(mapDirectory);. - Build
mapBinPathandminiMapBinPathaspath.resolve(baseDir, mapName, "map.bin")/"map4x.bin". - After resolving, check that each path starts with
baseDir + path.sep(or equalsbaseDir) to enforce containment. If not, throw an error.
- Add a small helper to validate
-
In
tests/pathfinding/playground/server.ts:- For
/api/maps/:nameand/api/maps/:name/thumbnail, validatenamewith the same pattern; if invalid, respond with400. - For
/api/pathfindand/api/spatial-query, validatemapsimilarly before passing it tocomputePath/computeSpatialQuery(which eventually reachsetupFromPath), returning400on invalid input. - For the thumbnail endpoint, compute a safe root maps directory (the same one
getMapsDirectory()uses; since we don’t see that code, we approximate with the existingjoin(dirname(fileURLToPath(import.meta.url)), "../../../resources/maps")resolved withpath.resolve), then:- Build the thumbnail path with
path.resolve(mapsRoot, name, "thumbnail.webp"). - Ensure the resolved path starts with
mapsRoot + path.sep; if not, return400or404. - Only then call
res.sendFile.
- Build the thumbnail path with
- For
We will implement a single central validator function (e.g., isValidMapName) in server.ts and a similar or identical copy in utils.ts (because they are in different modules and we must not assume extra wiring). These changes only add validation and containment checks and do not otherwise alter the logic of loading and using maps.
| @@ -26,6 +26,12 @@ | ||
| import { GameConfig, PeaceTimerDuration } from "../../src/core/Schemas"; | ||
| import { TestConfig } from "../util/TestConfig"; | ||
|
|
||
| function isValidMapName(mapName: string): boolean { | ||
| // Allow simple identifiers: letters, numbers, underscore, hyphen, and dot | ||
| // Disallow any path separators or traversal sequences. | ||
| return /^[a-zA-Z0-9._-]+$/.test(mapName); | ||
| } | ||
|
|
||
| export type BenchmarkRoute = { | ||
| name: string; | ||
| from: TileRef; | ||
| @@ -223,10 +229,26 @@ | ||
| // Suppress console.debug for tests | ||
| console.debug = () => {}; | ||
|
|
||
| // Load map files from specified directory | ||
| const mapBinPath = path.join(mapDirectory, mapName, "map.bin"); | ||
| const miniMapBinPath = path.join(mapDirectory, mapName, "map4x.bin"); | ||
| if (!isValidMapName(mapName)) { | ||
| throw new Error(`Invalid map name: ${mapName}`); | ||
| } | ||
|
|
||
| const baseDir = path.resolve(mapDirectory); | ||
|
|
||
| // Load map files from specified directory, ensuring paths stay within baseDir | ||
| const mapBinPath = path.resolve(baseDir, mapName, "map.bin"); | ||
| const miniMapBinPath = path.resolve(baseDir, mapName, "map4x.bin"); | ||
|
|
||
| if (!mapBinPath.startsWith(baseDir + path.sep)) { | ||
| throw new Error(`Resolved map path escapes base directory: ${mapBinPath}`); | ||
| } | ||
|
|
||
| if (!miniMapBinPath.startsWith(baseDir + path.sep)) { | ||
| throw new Error( | ||
| `Resolved mini map path escapes base directory: ${miniMapBinPath}`, | ||
| ); | ||
| } | ||
|
|
||
| // Check if files exist | ||
| if (!fs.existsSync(mapBinPath)) { | ||
| throw new Error(`Map not found: ${mapBinPath}`); |
| throw new Error(`Map not found: ${mapBinPath}`); | ||
| } | ||
|
|
||
| if (!fs.existsSync(miniMapBinPath)) { |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High test
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 2 days ago
In general, the fix is to ensure that any user-controlled path component is constrained to a safe root directory or strictly sanitized. Here we should (1) compute a canonical path under a known root (the maps directory), (2) normalize it via path.resolve, and (3) verify that the resulting path still resides under that root. If it does not, we should reject the request. This keeps behavior the same for valid map names but prevents directory traversal via .. or absolute paths. We can implement this once in setupFromPath, since both getMapMetadata and the /api/pathfind and /api/spatial-query endpoints funnel map loading through that function. Additionally, we should harden the thumbnail route to ensure the name parameter can only select files inside the maps directory.
Concrete changes:
-
In
tests/pathfinding/utils.ts:- Introduce a small helper (or inline logic) in
setupFromPaththat:- Resolves
mapDirectoryto an absolute path. - Resolves a candidate map directory as
path.resolve(rootDir, mapName). - Verifies that this resolved path starts with the root directory path plus a path separator (to avoid false positives on prefixes like
/maps/foovs/maps/foobar). - If the check fails, throws an error (in tests, throwing is fine; callers already handle errors).
- Resolves
- Then build
mapBinPathandminiMapBinPathusing that safe resolved directory (e.g.,const mapDir = ...; const mapBinPath = path.join(mapDir, "map.bin");), instead of joining rawmapDirectoryandmapName. - This preserves existing functionality for valid map names while preventing a user from escaping the maps directory.
- Introduce a small helper (or inline logic) in
-
In
tests/pathfinding/playground/server.ts:- For the thumbnail route
/api/maps/:name/thumbnail, make surenameis validated or constrained. Since we already have a safe way of resolving map directories viagetMapsDirectoryandsetupFromPath, the safest and most consistent approach is:- Import
getMapsDirectory(if not already). - Resolve the maps root directory inside the route (or reuse a module-level constant).
- Use the same root-directory enforcement strategy as in
setupFromPath: resolvenameagainst the maps root, ensure the resulting path is still under the root, and then build the thumbnail path asjoin(safeMapDir, "thumbnail.webp").
- Import
- This prevents directory traversal via the
nameparameter in the thumbnail route as well.
- For the thumbnail route
These changes require no new imports beyond path, which is already imported in both files. No extra npm dependencies are needed.
| @@ -223,10 +223,25 @@ | ||
| // Suppress console.debug for tests | ||
| console.debug = () => {}; | ||
|
|
||
| // Load map files from specified directory | ||
| const mapBinPath = path.join(mapDirectory, mapName, "map.bin"); | ||
| const miniMapBinPath = path.join(mapDirectory, mapName, "map4x.bin"); | ||
| // Resolve and validate map directory to prevent directory traversal | ||
| const rootDir = path.resolve(mapDirectory); | ||
| const resolvedMapDir = path.resolve(rootDir, mapName); | ||
|
|
||
| // Ensure the resolved directory is within the root directory | ||
| const rootDirWithSep = rootDir.endsWith(path.sep) | ||
| ? rootDir | ||
| : rootDir + path.sep; | ||
| if ( | ||
| resolvedMapDir !== rootDir && | ||
| !resolvedMapDir.startsWith(rootDirWithSep) | ||
| ) { | ||
| throw new Error(`Invalid map name: ${mapName}`); | ||
| } | ||
|
|
||
| // Load map files from validated directory | ||
| const mapBinPath = path.join(resolvedMapDir, "map.bin"); | ||
| const miniMapBinPath = path.join(resolvedMapDir, "map4x.bin"); | ||
|
|
||
| // Check if files exist | ||
| if (!fs.existsSync(mapBinPath)) { | ||
| throw new Error(`Map not found: ${mapBinPath}`); |
| @@ -1,6 +1,6 @@ | ||
| import compression from "compression"; | ||
| import express, { Request, Response } from "express"; | ||
| import { dirname, join } from "path"; | ||
| import { dirname, join, resolve, sep } from "path"; | ||
| import { fileURLToPath } from "url"; | ||
| import { | ||
| clearCache as clearMapCache, | ||
| @@ -21,6 +21,12 @@ | ||
| const publicDir = join(dirname(fileURLToPath(import.meta.url)), "public"); | ||
| app.use(express.static(publicDir)); | ||
|
|
||
| // Base directory for map resources (thumbnails, binaries, etc.) | ||
| const mapsRootDir = resolve( | ||
| dirname(fileURLToPath(import.meta.url)), | ||
| "../../../resources/maps", | ||
| ); | ||
|
|
||
| // API Routes | ||
|
|
||
| /** | ||
| @@ -73,12 +79,23 @@ | ||
| app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => { | ||
| try { | ||
| const { name } = req.params; | ||
| const thumbnailPath = join( | ||
| dirname(fileURLToPath(import.meta.url)), | ||
| "../../../resources/maps", | ||
| name, | ||
| "thumbnail.webp", | ||
| ); | ||
|
|
||
| // Resolve and validate map directory to prevent directory traversal | ||
| const resolvedMapDir = resolve(mapsRootDir, name); | ||
| const rootWithSep = mapsRootDir.endsWith(sep) | ||
| ? mapsRootDir | ||
| : mapsRootDir + sep; | ||
| if ( | ||
| resolvedMapDir !== mapsRootDir && | ||
| !resolvedMapDir.startsWith(rootWithSep) | ||
| ) { | ||
| return res.status(400).json({ | ||
| error: "Invalid map name", | ||
| message: `Map name "${name}" is not allowed`, | ||
| }); | ||
| } | ||
|
|
||
| const thumbnailPath = join(resolvedMapDir, "thumbnail.webp"); | ||
| res.sendFile(thumbnailPath); | ||
| } catch (error) { | ||
| console.error(`Error loading thumbnail for ${req.params.name}:`, error); |
| throw new Error(`Mini map not found: ${miniMapBinPath}`); | ||
| } | ||
|
|
||
| const mapBinBuffer = fs.readFileSync(mapBinPath); |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High test
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 2 days ago
In general, to fix uncontrolled path usage, either (a) normalize the constructed path and ensure it stays within a known root directory, or (b) restrict user input to a simple, validated name (or allow‑listed set), never allowing path separators or traversal sequences.
Here, the simplest, least invasive and most robust fix is:
-
In
tests/pathfinding/utils.ts’ssetupFromPath, treatmapDirectoryas the trusted root and validatemapName:- Resolve
safeBase = path.resolve(mapDirectory). - Resolve
mapPath = path.resolve(safeBase, mapName). - Check that
mapPathstarts withsafeBase + path.sep(or equalssafeBaseif you want to allow that); if not, throw an error. - Build
mapBinPathandminiMapBinPathusing this validatedmapPath.
This ensuresmapNamecannot escape the intendedmapDirectory.
- Resolve
-
In
tests/pathfinding/playground/server.ts, validatenameused for thumbnails:- Add a small helper function like
isValidMapName(name: string): booleanthat enforces a conservative pattern (e.g.^[A-Za-z0-9_-]+$), rejecting any path separators or traversal characters. - Before building
thumbnailPath, checkisValidMapName(name)and return400if invalid. - Optionally reuse this helper for other routes that accept
:nameto keep behavior consistent.
- Add a small helper function like
These changes avoid altering existing functionality for valid map names (they still resolve to the same directories/files), while preventing directory traversal via malicious values. No new external dependencies are strictly required; Node’s built‑in path is sufficient.
| @@ -223,9 +223,20 @@ | ||
| // Suppress console.debug for tests | ||
| console.debug = () => {}; | ||
|
|
||
| // Normalize and validate map path to prevent directory traversal | ||
| const safeBaseDir = path.resolve(mapDirectory); | ||
| const resolvedMapDir = path.resolve(safeBaseDir, mapName); | ||
|
|
||
| if ( | ||
| resolvedMapDir !== safeBaseDir && | ||
| !resolvedMapDir.startsWith(safeBaseDir + path.sep) | ||
| ) { | ||
| throw new Error(`Invalid map name: ${mapName}`); | ||
| } | ||
|
|
||
| // Load map files from specified directory | ||
| const mapBinPath = path.join(mapDirectory, mapName, "map.bin"); | ||
| const miniMapBinPath = path.join(mapDirectory, mapName, "map4x.bin"); | ||
| const mapBinPath = path.join(resolvedMapDir, "map.bin"); | ||
| const miniMapBinPath = path.join(resolvedMapDir, "map4x.bin"); | ||
|
|
||
| // Check if files exist | ||
| if (!fs.existsSync(mapBinPath)) { |
| @@ -13,6 +13,12 @@ | ||
| const app = express(); | ||
| const PORT = process.env.PORT ?? 5555; | ||
|
|
||
| // Restrictive validation for map names used in filesystem paths | ||
| function isValidMapName(name: string): boolean { | ||
| // Allow only simple names without path separators or traversal characters | ||
| return /^[A-Za-z0-9_-]+$/.test(name); | ||
| } | ||
|
|
||
| // Middleware | ||
| app.use(compression()); // gzip compression for large responses | ||
| app.use(express.json({ limit: "50mb" })); // JSON body parser with larger limit | ||
| @@ -73,6 +79,12 @@ | ||
| app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => { | ||
| try { | ||
| const { name } = req.params; | ||
| if (!isValidMapName(name)) { | ||
| return res.status(400).json({ | ||
| error: "Invalid map name", | ||
| message: "Map name contains invalid characters", | ||
| }); | ||
| } | ||
| const thumbnailPath = join( | ||
| dirname(fileURLToPath(import.meta.url)), | ||
| "../../../resources/maps", |
| } | ||
|
|
||
| const mapBinBuffer = fs.readFileSync(mapBinPath); | ||
| const miniMapBinBuffer = fs.readFileSync(miniMapBinPath); |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High test
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 2 days ago
In general, to fix uncontrolled use of user input in path expressions, we need to (1) define a trusted root directory, (2) normalize any path derived from user input relative to that root, and (3) verify after normalization that the resulting path stays within the root. For simple “map name” semantics, we can also validate that mapName is a simple filename-style identifier (for example, only letters, numbers, underscores, and dashes) and reject anything containing path separators or ...
In this codebase, the single best fix without changing existing functionality is to sanitize and validate mapName inside setupFromPath in tests/pathfinding/utils.ts, because all the tainted flows ultimately converge there. We will introduce a small helper sanitizeMapName that enforces a strict allowlist of characters and rejects names with path separators or traversal patterns. This keeps the semantics “one directory per logical map” intact while preventing ../../ or similar tricks. Then we’ll use the sanitized name to build mapBinPath and miniMapBinPath. Since mapDirectory comes from getMapsDirectory() and is not user-controlled in the shown snippets, validating mapName is sufficient to break the taint flow and prevent traversal.
Concretely:
- In
tests/pathfinding/utils.ts, add asanitizeMapNamefunction near the top of the file. - In
setupFromPath, before building paths, deriveconst safeMapName = sanitizeMapName(mapName);and usesafeMapNamein thepath.joincalls. - The helper will:
- Accept only names that match
/^[A-Za-z0-9_\-]+$/. - Reject any name containing
/,\, or... - Throw a descriptive error if validation fails.
This adds a narrow, explicit validation layer and leaves all other behavior unchanged.
- Accept only names that match
| @@ -26,6 +26,25 @@ | ||
| import { GameConfig, PeaceTimerDuration } from "../../src/core/Schemas"; | ||
| import { TestConfig } from "../util/TestConfig"; | ||
|
|
||
| /** | ||
| * Sanitize a map name to prevent path traversal and invalid characters. | ||
| * Allows only simple identifiers (letters, numbers, underscore, dash). | ||
| */ | ||
| export function sanitizeMapName(mapName: string): string { | ||
| // Disallow obvious traversal or path separator characters | ||
| if (mapName.includes("..") || mapName.includes("/") || mapName.includes("\\")) { | ||
| throw new Error(`Invalid map name: ${mapName}`); | ||
| } | ||
|
|
||
| // Allow only a safe subset of characters for directory names | ||
| const safePattern = /^[A-Za-z0-9_-]+$/; | ||
| if (!safePattern.test(mapName)) { | ||
| throw new Error(`Invalid map name: ${mapName}`); | ||
| } | ||
|
|
||
| return mapName; | ||
| } | ||
|
|
||
| export type BenchmarkRoute = { | ||
| name: string; | ||
| from: TileRef; | ||
| @@ -223,9 +242,12 @@ | ||
| // Suppress console.debug for tests | ||
| console.debug = () => {}; | ||
|
|
||
| // Sanitize map name to prevent path traversal | ||
| const safeMapName = sanitizeMapName(mapName); | ||
|
|
||
| // Load map files from specified directory | ||
| const mapBinPath = path.join(mapDirectory, mapName, "map.bin"); | ||
| const miniMapBinPath = path.join(mapDirectory, mapName, "map4x.bin"); | ||
| const mapBinPath = path.join(mapDirectory, safeMapName, "map.bin"); | ||
| const miniMapBinPath = path.join(mapDirectory, safeMapName, "map4x.bin"); | ||
|
|
||
| // Check if files exist | ||
| if (!fs.existsSync(mapBinPath)) { |
|
Hey, nice work on this PR — the pathfinding rewrite and perf optimizations are solid. Found a few things worth flagging: 🔴 1. Build-Breaking TypeScript Error in
|
|
Related to #215 |
Multiple bug fixes.
This pull request introduces several improvements and refactors across the codebase, focusing on UI/UX enhancements, code maintainability, and backend integration. The most notable changes include the addition of a robust lobby update system with WebSocket and HTTP fallback, improvements to game configuration update handling, and a series of map and naming consistency updates.
Lobby system and event handling improvements:
PublicLobbySocketclass insrc/client/LobbySocket.tsto manage real-time lobby updates via WebSocket, with automatic fallback to HTTP polling if the WebSocket connection fails. This ensures users always see up-to-date lobby information."update-game-config"inHostLobbyModal(src/client/HostLobbyModal.ts) and wired it into the main client event system (src/client/Main.ts). This decouples the UI from direct HTTP calls and allows for more flexible game configuration updates. [1] [2] [3] [4] [5]Gameplay and UI enhancements:
ClientGameRunner(src/client/ClientGameRunner.ts), including a newcanAutoBoatmethod for smarter auto-boat attack decisions and refactored related logic for clarity and maintainability. [1] [2] [3]Map and naming consistency updates:
map-generator/assets/test_maps/world/info.jsonlisting nations and their coordinates, likely for development or testing purposes.Build and project structure:
gatekeepersubmodule from.gitmodules, indicating a change in how dependencies are managed.