-
Notifications
You must be signed in to change notification settings - Fork 0
feat(java): add resource proxies with signed references #349
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
+363
−0
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
1eed005
feat(java): resource proxies (signed references)
pratyush618 ecbd4c0
test(java): cover resource proxies
pratyush618 a2b4f57
fix(java): resolve real paths for proxy allowlist check
pratyush618 5722b6d
fix(java): reject null reference map in file proxy
pratyush618 9605781
fix(java): reject null or duplicate proxy handler id
pratyush618 60f77aa
fix(java): reject null value in proxy deconstruct
pratyush618 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
18 changes: 18 additions & 0 deletions
18
sdks/java/src/main/java/org/byteveda/taskito/errors/ProxyException.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,18 @@ | ||
| package org.byteveda.taskito.errors; | ||
|
|
||
| import org.byteveda.taskito.TaskitoException; | ||
|
|
||
| /** | ||
| * A non-serializable value could not be turned into a {@code ProxyRef}, or a ref | ||
| * could not be reconstructed — no handler, a signature mismatch, or a value | ||
| * outside an allowlist. | ||
| */ | ||
| public class ProxyException extends TaskitoException { | ||
| public ProxyException(String message) { | ||
| super(message); | ||
| } | ||
|
|
||
| public ProxyException(String message, Throwable cause) { | ||
| super(message, cause); | ||
| } | ||
| } |
100 changes: 100 additions & 0 deletions
100
sdks/java/src/main/java/org/byteveda/taskito/proxies/FileProxyHandler.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,100 @@ | ||
| package org.byteveda.taskito.proxies; | ||
|
|
||
| import java.io.File; | ||
| import java.io.IOException; | ||
| import java.nio.file.Files; | ||
| import java.nio.file.Path; | ||
| import java.nio.file.Paths; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import org.byteveda.taskito.errors.ProxyException; | ||
|
|
||
| /** | ||
| * Proxies a {@link File} by its absolute path. An optional allowlist of root | ||
| * directories is enforced on reconstruct, so a tampered or hostile ref cannot | ||
| * resolve to a path outside them (an empty allowlist permits any path). | ||
| */ | ||
| public final class FileProxyHandler implements ProxyHandler<File> { | ||
| private final List<Path> allowedRoots; | ||
|
|
||
| public FileProxyHandler() { | ||
| this(List.of()); | ||
| } | ||
|
|
||
| public FileProxyHandler(List<Path> allowedRoots) { | ||
| List<Path> roots = new ArrayList<>(allowedRoots.size()); | ||
| for (Path root : allowedRoots) { | ||
| // Resolve each root to its real path so containment is checked against | ||
| // the true filesystem location, not a symlinked alias. | ||
| roots.add(realPath(root.toAbsolutePath().normalize())); | ||
| } | ||
| this.allowedRoots = List.copyOf(roots); | ||
| } | ||
|
|
||
| @Override | ||
| public String id() { | ||
| return "file"; | ||
| } | ||
|
|
||
| @Override | ||
| public boolean handles(Object value) { | ||
| return value instanceof File; | ||
| } | ||
|
|
||
| @Override | ||
| public Map<String, Object> deconstruct(File value) { | ||
| return Map.of("path", value.getAbsolutePath()); | ||
| } | ||
|
|
||
| @Override | ||
| public File reconstruct(Map<String, Object> reference) { | ||
| if (reference == null) { | ||
| throw new ProxyException("file proxy ref has no reference"); | ||
| } | ||
| Object path = reference.get("path"); | ||
| if (!(path instanceof String)) { | ||
| throw new ProxyException("file proxy ref missing 'path'"); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| Path resolved = Paths.get((String) path).toAbsolutePath().normalize(); | ||
| if (!allowed(resolved)) { | ||
| throw new ProxyException("file path not in allowlist: " + resolved); | ||
| } | ||
| return resolved.toFile(); | ||
| } | ||
|
|
||
| private boolean allowed(Path path) { | ||
| if (allowedRoots.isEmpty()) { | ||
| return true; | ||
| } | ||
| Path real = realPath(path); | ||
| for (Path root : allowedRoots) { | ||
| if (real.startsWith(root)) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * The real (symlink-resolved) path. Since the target may not exist yet, resolve | ||
| * the nearest existing ancestor to its real path — collapsing any symlinked | ||
| * ancestor — then re-append the remaining segments. A path whose ancestors do | ||
| * not exist yet has no symlink to hide behind, so its normalized form stands. | ||
| */ | ||
| private static Path realPath(Path path) { | ||
| Path existing = path; | ||
| while (existing != null && !Files.exists(existing)) { | ||
| existing = existing.getParent(); | ||
| } | ||
| if (existing == null) { | ||
| return path; | ||
| } | ||
| try { | ||
| Path realExisting = existing.toRealPath(); | ||
| return realExisting.resolve(existing.relativize(path)).normalize(); | ||
| } catch (IOException e) { | ||
| throw new ProxyException("failed to resolve real path for " + path, e); | ||
| } | ||
| } | ||
| } | ||
95 changes: 95 additions & 0 deletions
95
sdks/java/src/main/java/org/byteveda/taskito/proxies/Proxies.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,95 @@ | ||
| package org.byteveda.taskito.proxies; | ||
|
|
||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.fasterxml.jackson.databind.SerializationFeature; | ||
| import java.nio.charset.StandardCharsets; | ||
| import java.security.MessageDigest; | ||
| import java.util.Base64; | ||
| import java.util.LinkedHashMap; | ||
| import java.util.Map; | ||
| import javax.crypto.Mac; | ||
| import javax.crypto.spec.SecretKeySpec; | ||
| import org.byteveda.taskito.errors.ProxyException; | ||
|
|
||
| /** | ||
| * Registry that deconstructs resources into signed {@link ProxyRef}s and | ||
| * reconstructs them. Construct with an HMAC key (shared by producer and worker), | ||
| * register a {@link ProxyHandler} per resource type, then | ||
| * {@link #deconstruct(Object)} on the producer and {@link #reconstruct(ProxyRef)} | ||
| * (or {@link #resolve(ProxyRef)}) on the worker. | ||
| */ | ||
| public final class Proxies { | ||
| private static final String ALGORITHM = "HmacSHA256"; | ||
|
|
||
| private final Map<String, ProxyHandler<?>> handlers = new LinkedHashMap<>(); | ||
| private final byte[] key; | ||
| private final ObjectMapper canonical = | ||
| new ObjectMapper().configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); | ||
|
|
||
| public Proxies(byte[] hmacKey) { | ||
| this.key = hmacKey.clone(); | ||
| } | ||
|
|
||
| /** Register a handler under its non-null, unique id; returns {@code this}. */ | ||
| public Proxies register(ProxyHandler<?> handler) { | ||
| String id = handler.id(); | ||
| if (id == null) { | ||
| throw new ProxyException("proxy handler id must not be null"); | ||
| } | ||
| // Fail fast on a duplicate: silently overwriting would let a producer and | ||
| // worker disagree on what a given ProxyRef's handler id means. | ||
| if (handlers.putIfAbsent(id, handler) != null) { | ||
| throw new ProxyException("proxy handler '" + id + "' is already registered"); | ||
| } | ||
| return this; | ||
| } | ||
|
|
||
| /** Deconstruct {@code value} into a signed ref; throws if no handler accepts it. */ | ||
| @SuppressWarnings("unchecked") | ||
| public ProxyRef deconstruct(Object value) { | ||
| if (value == null) { | ||
| throw new ProxyException("cannot deconstruct null"); | ||
| } | ||
| for (ProxyHandler<?> handler : handlers.values()) { | ||
| if (handler.handles(value)) { | ||
| Map<String, Object> reference = ((ProxyHandler<Object>) handler).deconstruct(value); | ||
| return new ProxyRef(handler.id(), reference, sign(handler.id(), reference)); | ||
| } | ||
| } | ||
| throw new ProxyException("no proxy handler for " + value.getClass().getName()); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** Verify a ref's signature and reconstruct the resource. */ | ||
| @SuppressWarnings("unchecked") | ||
| public Object reconstruct(ProxyRef ref) { | ||
| ProxyHandler<Object> handler = (ProxyHandler<Object>) handlers.get(ref.handler()); | ||
| if (handler == null) { | ||
| throw new ProxyException("unknown proxy handler '" + ref.handler() + "'"); | ||
| } | ||
| byte[] expected = sign(ref.handler(), ref.reference()).getBytes(StandardCharsets.UTF_8); | ||
| byte[] actual = (ref.signature() == null ? "" : ref.signature()).getBytes(StandardCharsets.UTF_8); | ||
| if (!MessageDigest.isEqual(expected, actual)) { | ||
| throw new ProxyException("proxy signature mismatch for handler '" + ref.handler() + "'"); | ||
| } | ||
| return handler.reconstruct(ref.reference()); | ||
| } | ||
|
|
||
| /** {@link #reconstruct(ProxyRef)} cast to the caller's type. */ | ||
| @SuppressWarnings("unchecked") | ||
| public <T> T resolve(ProxyRef ref) { | ||
| return (T) reconstruct(ref); | ||
| } | ||
|
|
||
| private String sign(String handlerId, Map<String, Object> reference) { | ||
| try { | ||
| Mac mac = Mac.getInstance(ALGORITHM); | ||
| mac.init(new SecretKeySpec(key, ALGORITHM)); | ||
| mac.update(handlerId.getBytes(StandardCharsets.UTF_8)); | ||
| mac.update((byte) '\n'); | ||
| mac.update(canonical.writeValueAsBytes(reference)); | ||
| return Base64.getEncoder().encodeToString(mac.doFinal()); | ||
| } catch (Exception e) { | ||
| throw new ProxyException("failed to sign proxy ref", e); | ||
| } | ||
| } | ||
| } | ||
24 changes: 24 additions & 0 deletions
24
sdks/java/src/main/java/org/byteveda/taskito/proxies/ProxyHandler.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,24 @@ | ||
| package org.byteveda.taskito.proxies; | ||
|
|
||
| import java.util.Map; | ||
|
|
||
| /** | ||
| * Deconstructs a non-serializable resource of type {@code T} into a serializable | ||
| * reference, and reconstructs it on the worker. Register handlers with a | ||
| * {@link Proxies} registry. | ||
| * | ||
| * @param <T> the resource type this handler proxies | ||
| */ | ||
| public interface ProxyHandler<T> { | ||
| /** Stable id stored in the {@link ProxyRef} and used to find this handler on the worker. */ | ||
| String id(); | ||
|
|
||
| /** Whether this handler can proxy {@code value}. */ | ||
| boolean handles(Object value); | ||
|
|
||
| /** Reduce {@code value} to a serializable reference (e.g. a file path, a config map). */ | ||
| Map<String, Object> deconstruct(T value); | ||
|
|
||
| /** Rebuild the resource from a reference produced by {@link #deconstruct}. */ | ||
| T reconstruct(Map<String, Object> reference); | ||
| } |
16 changes: 16 additions & 0 deletions
16
sdks/java/src/main/java/org/byteveda/taskito/proxies/ProxyRef.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,16 @@ | ||
| package org.byteveda.taskito.proxies; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonIgnoreProperties; | ||
| import java.util.Map; | ||
|
|
||
| /** | ||
| * A serializable, signed reference to a non-serializable resource. Carry it in a | ||
| * job payload; the worker reconstructs the resource with | ||
| * {@link Proxies#reconstruct}. | ||
| * | ||
| * @param handler the {@link ProxyHandler#id()} that produced (and reconstructs) it | ||
| * @param reference the handler's serializable reference data | ||
| * @param signature an HMAC over {@code handler + reference}, verified on reconstruct | ||
| */ | ||
| @JsonIgnoreProperties(ignoreUnknown = true) | ||
| public record ProxyRef(String handler, Map<String, Object> reference, String signature) {} |
14 changes: 14 additions & 0 deletions
14
sdks/java/src/main/java/org/byteveda/taskito/proxies/package-info.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,14 @@ | ||
| /** | ||
| * Pass non-serializable resources through a job payload by reference. | ||
| * | ||
| * <p>Java's typed, JSON-serialized payloads don't lend themselves to Python's | ||
| * implicit argument proxying, so this is explicit: deconstruct a value into a | ||
| * signed, serializable {@link org.byteveda.taskito.proxies.ProxyRef} on the | ||
| * producer ({@link org.byteveda.taskito.proxies.Proxies#deconstruct}), carry it | ||
| * in the payload, and reconstruct it in the handler | ||
| * ({@link org.byteveda.taskito.proxies.Proxies#reconstruct}). Refs are | ||
| * HMAC-signed; a {@link org.byteveda.taskito.proxies.ProxyHandler} per resource | ||
| * type does the (de)construction, with an allowlist where it matters | ||
| * (e.g. {@link org.byteveda.taskito.proxies.FileProxyHandler}). | ||
| */ | ||
| package org.byteveda.taskito.proxies; |
96 changes: 96 additions & 0 deletions
96
sdks/java/src/test/java/org/byteveda/taskito/ProxyTest.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,96 @@ | ||
| package org.byteveda.taskito; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| import static org.junit.jupiter.api.Assertions.assertThrows; | ||
|
|
||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import java.io.File; | ||
| import java.nio.file.Files; | ||
| import java.nio.file.Path; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import org.byteveda.taskito.errors.ProxyException; | ||
| import org.byteveda.taskito.proxies.FileProxyHandler; | ||
| import org.byteveda.taskito.proxies.Proxies; | ||
| import org.byteveda.taskito.proxies.ProxyRef; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.api.io.TempDir; | ||
|
|
||
| class ProxyTest { | ||
|
|
||
| private static final byte[] KEY = "proxy-secret-key".getBytes(); | ||
| private final ObjectMapper json = new ObjectMapper(); | ||
|
|
||
| @Test | ||
| void roundTripsFileAcrossTheWire(@TempDir Path dir) throws Exception { | ||
| Proxies proxies = new Proxies(KEY).register(new FileProxyHandler()); | ||
| File file = dir.resolve("data.txt").toFile(); | ||
|
|
||
| ProxyRef ref = proxies.deconstruct(file); | ||
| ProxyRef onWire = json.readValue(json.writeValueAsBytes(ref), ProxyRef.class); // simulate serialization | ||
| File reconstructed = proxies.resolve(onWire); | ||
|
|
||
| assertEquals(file.getAbsolutePath(), reconstructed.getAbsolutePath()); | ||
| } | ||
|
|
||
| @Test | ||
| void rejectsTamperedRef(@TempDir Path dir) { | ||
| Proxies proxies = new Proxies(KEY).register(new FileProxyHandler()); | ||
| ProxyRef ref = proxies.deconstruct(dir.resolve("a").toFile()); | ||
| ProxyRef tampered = new ProxyRef(ref.handler(), Map.of("path", "/etc/passwd"), ref.signature()); | ||
|
|
||
| assertThrows(ProxyException.class, () -> proxies.reconstruct(tampered)); | ||
| } | ||
|
|
||
| @Test | ||
| void enforcesAllowlist(@TempDir Path dir) { | ||
| Proxies proxies = new Proxies(KEY).register(new FileProxyHandler(List.of(dir))); | ||
|
|
||
| File inside = dir.resolve("ok.txt").toFile(); | ||
| File back = proxies.resolve(proxies.deconstruct(inside)); | ||
| assertEquals(inside.getAbsolutePath(), back.getAbsolutePath()); | ||
|
|
||
| File outside = dir.getParent().resolve("outside.txt").toFile(); | ||
| ProxyRef ref = proxies.deconstruct(outside); | ||
| assertThrows(ProxyException.class, () -> proxies.reconstruct(ref)); | ||
| } | ||
|
|
||
| @Test | ||
| void allowlistRejectsSymlinkedAncestorEscape(@TempDir Path dir) throws Exception { | ||
| Path allowed = Files.createDirectory(dir.resolve("allowed")); | ||
| Path secret = Files.createDirectory(dir.resolve("secret")); | ||
| Files.writeString(secret.resolve("data.txt"), "top secret"); | ||
| // A symlink inside the allowed root pointing at the secret dir: lexically | ||
| // under the allowlist, but its real target is not. | ||
| Path link = allowed.resolve("link"); | ||
| Files.createSymbolicLink(link, secret); | ||
|
|
||
| Proxies proxies = new Proxies(KEY).register(new FileProxyHandler(List.of(allowed))); | ||
| ProxyRef ref = proxies.deconstruct(link.resolve("data.txt").toFile()); | ||
| assertThrows(ProxyException.class, () -> proxies.reconstruct(ref)); | ||
| } | ||
|
|
||
| @Test | ||
| void rejectsValueWithNoHandler() { | ||
| Proxies proxies = new Proxies(KEY); | ||
| assertThrows(ProxyException.class, () -> proxies.deconstruct("not proxyable")); | ||
| } | ||
|
|
||
| @Test | ||
| void rejectsUnknownHandlerOnReconstruct() { | ||
| Proxies proxies = new Proxies(KEY).register(new FileProxyHandler()); | ||
| assertThrows(ProxyException.class, () -> proxies.reconstruct(new ProxyRef("nope", Map.of(), "sig"))); | ||
| } | ||
|
|
||
| @Test | ||
| void rejectsNullValueOnDeconstruct() { | ||
| Proxies proxies = new Proxies(KEY).register(new FileProxyHandler()); | ||
| assertThrows(ProxyException.class, () -> proxies.deconstruct(null)); | ||
| } | ||
|
|
||
| @Test | ||
| void rejectsDuplicateHandlerId() { | ||
| Proxies proxies = new Proxies(KEY).register(new FileProxyHandler()); | ||
| assertThrows(ProxyException.class, () -> proxies.register(new FileProxyHandler())); | ||
| } | ||
| } |
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.