Repository navigation
Harden REST API authentication, thread safety and lifecycle #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
5f7cff2
Harden REST endpoints and lifecycle (fixes #1)
SrBedrock cd12ec8
Use runner JDK for Gradle builds
SrBedrock 952ae17
Provide Bukkit and PAPI dependencies to regression tests
SrBedrock 0725ebb
Build with Java 25, Paper 1.21.11 and Gradle 9.8.0
SrBedrock 9fab6f6
Add JUnit Platform launcher for Gradle 9 tests
SrBedrock 22ae2c2
Update Mockito for Java 25 tests
SrBedrock 4517a7c
Address PR review: async reload and nonblocking HTTP shutdown
SrBedrock File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| name: Build | ||
|
|
||
| on: | ||
| pull_request: | ||
| push: | ||
| branches: [master, main] | ||
|
|
||
| jobs: | ||
| build: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/setup-java@v4 | ||
| with: | ||
| distribution: temurin | ||
| java-version: '25' | ||
| - uses: gradle/actions/setup-gradle@v4 | ||
| - run: bash gradlew test shadowJar --no-daemon |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,34 +1,55 @@ | ||
| ## What Is RestPlaceholderAPI? | ||
| RestPlaceholderAPI (RestPAPI) is a small lightweight plugin, that allows you to easily parse placeholders from an external application, like a Discord bot, or Forums. | ||
| # RestPlaceholderAPI | ||
|
|
||
| > :warning: **Warning:** RestPAPI Does nothing by itself, it just allows for external applications to parse placeholders via a simple Rest (http) API | ||
| RestPAPI exposes PlaceholderAPI values from each Bukkit/Paper backend over HTTP. Install PlaceholderAPI and this plugin on **each** backend you want to query. Velocity forwards Minecraft traffic, not these HTTP requests. | ||
|
|
||
| ### Spigot Page / Downloads | ||
| https://www.spigotmc.org/resources/rest-placeholderapi.90266/ | ||
| ## Configuration | ||
|
|
||
| ### How Does it Work? | ||
| RestPAPI parses a specific placeholder, as a specific player when you make a Get request to your server, in the format: | ||
| http://backend.ip: port/<player-uuid>/<placeholder-without-%>, so an example would be http://example.com:8080/da8a8993-adfa-4d29-99b1-9d0f62fbb78d/player_name (returns json containing "Fredthedoggy") | ||
| On first start, `plugins/RestPAPI/config.yml` is created with two random UUID tokens. Keep them private. Example: | ||
|
|
||
| ### Security: | ||
| RestPAPI has a List of "Tokens" in the config (It starts with 2 randomly generated Java UUIDs, but can be changed). You must send the header "Token" with the value of one of the tokens in the config, or you will get a 401 (unauthorized) message. | ||
| ```yaml | ||
| port: 11001 | ||
| bind: 0.0.0.0 | ||
| tokens: | ||
| - "replace-with-a-long-random-secret" | ||
| timeout-ms: 3000 | ||
| max-concurrent: 16 | ||
| rate-limit: | ||
| requests: 60 | ||
| window-seconds: 60 | ||
| allowed-ips: [] | ||
| ``` | ||
|
|
||
| ### Plugin Support: | ||
| While it supports placeholderAPI, allowing it to support most PlaceholderAPI supported plugins, some placeholders will return an empty string, due to the fact that they cannot parse as an offline player, but will work when the player is online | ||
| Tokens must contain at least 16 characters, cannot be blank or padded with spaces, and must be unique. A missing or invalid token configuration prevents startup. To rotate tokens, temporarily include old and new tokens, run `/restpapi reload`, update clients, then remove the old token and reload again. Never print the tokens or put them in browser JavaScript. | ||
|
|
||
| Example Responses: | ||
| `bind` selects the interface **inside the container** (default `0.0.0.0`). `allowed-ips` is an exact match list of socket peer IPs; an empty list permits any peer with a valid token. Behind Nginx it will normally see the proxy address, not the original client. It deliberately ignores `X-Forwarded-For`. The rate limit is per socket peer and uses a fixed window; no more than 4096 distinct peers are tracked. The concurrency limit returns HTTP 503 instead of queuing unbounded lookups. Placeholder evaluation runs on the Minecraft main thread; clients receive HTTP 504 if it takes longer than `timeout-ms`. A lookup already running on the main thread cannot be interrupted. | ||
|
|
||
| ```json | ||
| {"status":"401","message":"Unauthorized"} | ||
| ``` | ||
| Without Authorization Header | ||
| The command `/restpapi reload` reads the file again, validates it before stopping the old listener, and attempts to restore the old listener if binding the new one fails. A failed rollback disables the plugin. Listener shutdown and startup take place off the Minecraft main thread; the command reports the result after they finish. In-flight lookups receive 503 during shutdown. A successful reload updates both the port and token set. | ||
|
|
||
| ## Requests | ||
|
|
||
| ```json | ||
| {"status":"404","message":"Invalid URI"} | ||
| Both routes require the `Token` header and return JSON with string fields `status` and `message`: | ||
|
|
||
| ```bash | ||
| curl -H "Token: YOUR_SECRET" "http://127.0.0.1:11001/da8a8993-adfa-4d29-99b1-9d0f62fbb78d/player_name" | ||
| curl -H "Token: YOUR_SECRET" "http://127.0.0.1:11001/server/server_online" | ||
| ``` | ||
| With The Wrong URL Scheme | ||
|
|
||
| ```json | ||
| {"status": "200", "message":"Fredthedoggy"} | ||
| Use the placeholder name without percent signs. Unknown placeholders return 406, missing player data 400, bad UUID 400, unauthorized requests 401, blocked peers 403, unknown routes 404, excess requests 429, overload or shutdown 503, and timed out lookups 504. Empty resolved values are valid. Offline results depend on each PlaceholderAPI expansion's support for offline players. | ||
|
|
||
| ## Docker, Pterodactyl and external bots | ||
|
|
||
| Allocate a dedicated HTTP port to **each backend** in Pterodactyl, separate from its Minecraft port. Set the plugin's `port` to that allocation's container port. For example, Minecraft may use `172.18.0.1:10001` while the same backend's REST service uses `172.18.0.1:11001`. The `172.18.0.1` gateway belongs to a Docker node and is not reachable from an external bot. The bot needs a routed private connection (for example VPN) or an HTTPS reverse proxy on the node. Do not publish the token over plain public HTTP. | ||
|
|
||
| For a proxy on the same node, assign REST ports to a private/interface allocation reachable from the proxy. Confirm the effective Docker/host firewall rules restrict direct access. Example Nginx location within a TLS server: | ||
|
|
||
| ```nginx | ||
| location /survival/ { | ||
| proxy_pass http://172.18.0.1:11001/; | ||
| } | ||
| ``` | ||
| Valid Placeholder (%player_name%) | ||
|
|
||
| Then query `https://api.example.com/survival/UUID/player_name` with the `Token` header. Use separate allocations and tokens for other backends. The trailing slashes strip `/survival/` before forwarding. Restrict the proxy by the bot's IP and/or additional authentication and keep the per-backend token. Configure `allowed-ips` with the proxy's **socket peer** address if needed; when Docker publishes a port, verify which source IP reaches the container. | ||
|
|
||
| ## Build | ||
|
|
||
| Use JDK 25 and `bash gradlew test shadowJar` (Gradle 9.8.0). The plugin targets Paper API 1.21.11; the shaded JAR is written under `build/libs`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| org.gradle.java.home=C:/Program Files/Java/jdk-17 | ||
| # Use the JDK selected by JAVA_HOME (or the CI setup-java action). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| distributionBase=GRADLE_USER_HOME | ||
| distributionPath=wrapper/dists | ||
| distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip | ||
| distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip | ||
| zipStoreBase=GRADLE_USER_HOME | ||
| zipStorePath=wrapper/dists |
40 changes: 40 additions & 0 deletions
40
src/main/java/me/fredthedoggy/restpapi/RequestLimiter.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| package me.fredthedoggy.restpapi; | ||
|
|
||
| import java.util.HashMap; | ||
| import java.util.Iterator; | ||
| import java.util.Map; | ||
| import java.util.concurrent.TimeUnit; | ||
|
|
||
| final class RequestLimiter { | ||
| private final int limit; | ||
| private final long windowNanos; | ||
| private final Map<String, Window> windows = new HashMap<>(); | ||
|
|
||
| RequestLimiter(int limit, int windowSeconds) { | ||
| this.limit = limit; | ||
| windowNanos = TimeUnit.SECONDS.toNanos(windowSeconds); | ||
| } | ||
|
|
||
| synchronized boolean allow(String ip) { | ||
| long now = System.nanoTime(); | ||
| Window window = windows.get(ip); | ||
| if (window == null || now - window.start >= windowNanos) { | ||
| if (windows.size() >= 4096) { | ||
| Iterator<Window> iterator = windows.values().iterator(); | ||
| while (iterator.hasNext()) { | ||
| if (now - iterator.next().start >= windowNanos) iterator.remove(); | ||
| } | ||
| if (windows.size() >= 4096) return false; | ||
| } | ||
| windows.put(ip, new Window(now)); | ||
| return true; | ||
| } | ||
| return ++window.count <= limit; | ||
| } | ||
|
|
||
| private static final class Window { | ||
| private final long start; | ||
| private int count = 1; | ||
| private Window(long start) { this.start = start; } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| package me.fredthedoggy.restpapi; | ||
|
|
||
| import org.bukkit.configuration.file.FileConfiguration; | ||
|
|
||
| import java.util.HashSet; | ||
| import java.util.ArrayList; | ||
| import java.util.Collections; | ||
| import java.util.List; | ||
| import java.util.Set; | ||
|
|
||
| final class RestConfig { | ||
| private final int port, timeoutMillis, rateLimit, rateWindowSeconds, maxConcurrent; | ||
| private final String bind; | ||
| private final List<String> tokens; | ||
| private final Set<String> allowedIps; | ||
|
|
||
| RestConfig(FileConfiguration yaml) { | ||
| port = yaml.getInt("port", 8080); | ||
| bind = yaml.getString("bind", "0.0.0.0"); | ||
| timeoutMillis = yaml.getInt("timeout-ms", 3000); | ||
| rateLimit = yaml.getInt("rate-limit.requests", 60); | ||
| rateWindowSeconds = yaml.getInt("rate-limit.window-seconds", 60); | ||
| maxConcurrent = yaml.getInt("max-concurrent", 16); | ||
| tokens = Collections.unmodifiableList(new ArrayList<>(yaml.getStringList("tokens"))); | ||
| allowedIps = Collections.unmodifiableSet(new HashSet<>(yaml.getStringList("allowed-ips"))); | ||
| if (port < 1 || port > 65535 || bind == null || bind.trim().isEmpty() | ||
| || timeoutMillis < 100 || timeoutMillis > 30000 | ||
| || rateLimit < 1 || rateLimit > 10000 || rateWindowSeconds < 1 | ||
| || rateWindowSeconds > 3600 || maxConcurrent < 1 || maxConcurrent > 24 | ||
| || tokens.isEmpty() || tokens.stream().anyMatch(t -> t == null || t.length() < 16 || !t.equals(t.trim())) | ||
| || new HashSet<>(tokens).size() != tokens.size() | ||
| || allowedIps.stream().anyMatch(ip -> ip == null || ip.trim().isEmpty() || !ip.equals(ip.trim()))) { | ||
| throw new IllegalArgumentException("Invalid REST configuration (port, bind, limits, tokens or allowed-ips)"); | ||
| } | ||
| } | ||
|
|
||
| int port() { return port; } | ||
| String bind() { return bind; } | ||
| int timeoutMillis() { return timeoutMillis; } | ||
| int rateLimit() { return rateLimit; } | ||
| int rateWindowSeconds() { return rateWindowSeconds; } | ||
| int maxConcurrent() { return maxConcurrent; } | ||
| List<String> tokens() { return tokens; } | ||
| Set<String> allowedIps() { return allowedIps; } | ||
| } |
49 changes: 25 additions & 24 deletions
49
src/main/java/me/fredthedoggy/restpapi/RestPapiCommand.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,41 +1,42 @@ | ||
| package me.fredthedoggy.restpapi; | ||
|
|
||
| import org.bukkit.Bukkit; | ||
| import org.bukkit.ChatColor; | ||
| import org.bukkit.Bukkit; | ||
| import org.bukkit.command.Command; | ||
| import org.bukkit.command.CommandExecutor; | ||
| import org.bukkit.command.CommandSender; | ||
| import org.bukkit.entity.Player; | ||
|
|
||
| import java.util.logging.Level; | ||
| import java.util.concurrent.CompletableFuture; | ||
|
|
||
| public class RestPapiCommand implements CommandExecutor { | ||
| public final class RestPapiCommand implements CommandExecutor { | ||
| private final Restpapi plugin; | ||
|
|
||
| private final Restpapi restpapi; | ||
|
|
||
| public RestPapiCommand(Restpapi restpapi) { | ||
| this.restpapi = restpapi; | ||
| } | ||
| RestPapiCommand(Restpapi plugin) { this.plugin = plugin; } | ||
|
|
||
| // This method is called, when somebody uses our command | ||
| @Override | ||
| public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { | ||
| if (args.length >= 1 && args[0].equals("reload")) { | ||
| restpapi.webServer.destroy(); | ||
| this.restpapi.getLoader().loadWebServer(); | ||
| if (sender instanceof Player) { | ||
| Player player = (Player) sender; | ||
| player.sendMessage(ChatColor.GREEN + "RestPAPI Config is being reloaded."); | ||
| } | ||
| Bukkit.getLogger().log(Level.INFO, "[RestPAPI] Reloading Config"); | ||
| } else { | ||
| if (sender instanceof Player) { | ||
| Player player = (Player) sender; | ||
| player.sendMessage(ChatColor.GREEN + "Run /restpapi reload to Reload RestPAPI"); | ||
| if (args.length == 1 && args[0].equalsIgnoreCase("reload")) { | ||
| CompletableFuture<Boolean> result = plugin.getLoader().reload(); | ||
| if (result.isDone()) { | ||
| sendResult(sender, result.getNow(false)); | ||
| } else { | ||
| Bukkit.getLogger().log(Level.INFO, "[RestPAPI] Run /restpapi reload to Reload RestPAPI"); | ||
| sender.sendMessage(ChatColor.YELLOW + "RestPAPI reload in progress."); | ||
| result.whenComplete((success, error) -> { | ||
| if (!plugin.isEnabled()) return; | ||
| try { | ||
| Bukkit.getScheduler().runTask(plugin, () -> sendResult(sender, error == null && success)); | ||
| } catch (RuntimeException exception) { | ||
| plugin.getLogger().warning("Could not deliver REST reload result: " + exception.getMessage()); | ||
| } | ||
| }); | ||
| } | ||
| } else { | ||
| sender.sendMessage(ChatColor.GREEN + "Usage: /" + label + " reload"); | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| private void sendResult(CommandSender sender, boolean success) { | ||
| sender.sendMessage((success ? ChatColor.GREEN : ChatColor.RED) | ||
| + (success ? "RestPAPI configuration reloaded." : "RestPAPI reload failed; check console.")); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.