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,264 changes: 662 additions & 602 deletions docs/index.html

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions docs/openapi/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ source:
description: The content to store at the specified location.
type: string
format: binary
guid:
description: |
Optional client-supplied identifier for the document. When the target
location does not already have a stored identifier, this value becomes
the document identifier. It must be a UUID. A value that is not a UUID
is rejected with a 400 response. When the target already has a stored
identifier that differs from this value, the request is rejected with a
409 response. When omitted, the service generates a UUID.
type: string
format: uuid

copy:
source:
Expand Down
16 changes: 16 additions & 0 deletions docs/openapi/source-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ source:
Accepts either a `multipart/form-data` body with the content in the `data` field, or a
raw request body for text-based types (`text/html`, `application/json`).

The `multipart/form-data` body may also include an optional `guid` field. When the target
location does not already have a stored identifier, `guid` becomes the document identifier.
It must be a UUID. A `guid` that is not a UUID is rejected with a 400 response. When the
target already has a stored identifier that differs from `guid`, the request is rejected with
a 409 response. When omitted, the service generates a UUID.

**Important:** For files, the `path` parameter must include the file extension (e.g., `myfile.html`, `data.json`).
For folders, omit the extension (e.g., `myfolder`).
parameters:
Expand Down Expand Up @@ -64,6 +70,8 @@ source:
$ref: "./responses.yaml#/400"
'401':
$ref: "./responses.yaml#/401"
'409':
$ref: "./responses.yaml#/409"
'500':
$ref: "./responses.yaml#/500"
post:
Expand All @@ -77,6 +85,12 @@ source:
Accepts either a `multipart/form-data` body with the content in the `data` field, or a
raw request body for text-based types (`text/html`, `application/json`).

The `multipart/form-data` body may also include an optional `guid` field. When the target
location does not already have a stored identifier, `guid` becomes the document identifier.
It must be a UUID. A `guid` that is not a UUID is rejected with a 400 response. When the
target already has a stored identifier that differs from `guid`, the request is rejected with
a 409 response. When omitted, the service generates a UUID.

**Important:** For files, the `path` parameter must include the file extension (e.g., `myfile.html`, `data.json`).
For folders, omit the extension (e.g., `myfolder`).
Examples:
Expand Down Expand Up @@ -109,6 +123,8 @@ source:
$ref: "./responses.yaml#/400"
'401':
$ref: "./responses.yaml#/401"
'409':
$ref: "./responses.yaml#/409"
'500':
$ref: "./responses.yaml#/500"
delete:
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"eslint": "9.4.0",
"esmock": "2.7.6",
"husky": "9.1.7",
"js-yaml": "4.1.1",
"lint-staged": "17.0.7",
"mocha": "11.7.6",
"s3rver": "3.7.1",
Expand Down
39 changes: 39 additions & 0 deletions src/storage/version/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,45 @@
* governing permissions and limitations under the License.
*/

const ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/**
* True when the value is a plain UUID that is safe to use as a file id.
* A file id becomes a path segment inside the reserved .da-versions key space,
* so it must not contain a slash or any other key steering characters. Requiring
* the UUID shape also matches the ids this service generates with
* crypto.randomUUID.
* @param {string} id
* @returns {boolean}
*/
export function isValidId(id) {
return typeof id === 'string' && ID_PATTERN.test(id);
}

/**
* True when the value is safe to use as a path segment inside the reserved
* .da-versions key space. A stored file id read from object metadata is
* untrusted input, so it must not steer keys. The Cloudflare Worker AWS
* transport turns a key into a WHATWG URL, which collapses "." and ".."
* segments, treats a backslash as a separator, decodes "%xx" (so "%2e" becomes
* "."), and strips whitespace such as tab or newline. Any of those let a
* poisoned id escape its per-document prefix, so reject a slash, a backslash, a
* percent sign, any whitespace, a "." or ".." segment, and a bare .da-versions
* segment before key construction. A plain single-segment id, including a UUID
* or a benign legacy id, is still allowed. It is weaker than isValidId on
* purpose so existing non-UUID ids keep working while unsafe values are refused.
* @param {string} id
* @returns {boolean}
*/
export function isSafeId(id) {
return typeof id === 'string'
&& id.length > 0
&& !/[\s%/\\]/.test(id)
&& id !== '.'
&& id !== '..'
&& id !== '.da-versions';
}

/**
* Path for a version object under repo.
* @param {string} repo
Expand Down
15 changes: 14 additions & 1 deletion src/storage/version/put.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
} from '../utils/version.js';
import getObject from '../object/get.js';
import { writeAuditEntry } from './audit.js';
import { versionKey } from './paths.js';
import { versionKey, isValidId, isSafeId } from './paths.js';

export function getContentLength(body) {
if (body === undefined) {
Expand Down Expand Up @@ -120,7 +120,20 @@ export async function putObjectWithVersion(

let ID = current.metadata?.id;
if (!ID) {
// A client-supplied guid becomes this document's file id and is written into the
// reserved .da-versions key space. Require a plain UUID so a crafted guid cannot
// place keys outside that space, for example a value with a slash or a
// .da-versions segment.
if (guid && !isValidId(guid)) {
return { status: 400, metadata: {} };
}
ID = guid || crypto.randomUUID();
} else if (!isSafeId(ID)) {
// A stored file id is untrusted input. An id persisted before this validation
// existed could contain key steering characters. Refuse to build reserved keys
// from an unsafe stored id. A benign legacy id (a single segment that is not
// .da-versions) is still allowed.
return { status: 400, metadata: {} };
} else if (guid && ID !== guid) {
return { status: 409, metadata: { id: ID } };
}
Expand Down
53 changes: 53 additions & 0 deletions test/docs/openapi.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import assert from 'node:assert';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';

function loadYaml(relPath) {
const url = new URL(relPath, import.meta.url);
return yaml.load(readFileSync(fileURLToPath(url), 'utf8'));
}

describe('OpenAPI source contract', () => {
it('documents the optional guid field as a UUID on the source schema', () => {
const schemas = loadYaml('../../docs/openapi/schemas.yaml');
const guid = schemas?.source?.properties?.guid;
assert.ok(guid, 'source schema must document a guid property');
assert.strictEqual(guid.type, 'string');
assert.strictEqual(guid.format, 'uuid', 'guid must be documented as a UUID');
assert.match(guid.description, /UUID/, 'guid description must state the UUID constraint');
assert.match(guid.description, /400/, 'guid description must state the 400 rejection');
assert.match(guid.description, /409/, 'guid description must state the 409 mismatch response');
});

it('describes the guid contract on the PUT and POST source operations', () => {
const api = loadYaml('../../docs/openapi/source-api.yaml');
for (const method of ['put', 'post']) {
const { description } = api.source[method];
assert.ok(description, `source ${method} must have a description`);
assert.match(description, /guid/, `source ${method} description must mention guid`);
assert.match(description, /UUID/, `source ${method} description must state the UUID constraint`);
assert.match(description, /400/, `source ${method} description must state the 400 rejection`);
assert.match(description, /409/, `source ${method} description must state the 409 mismatch response`);
}
});

it('keeps 400 and 409 responses on the PUT and POST source operations', () => {
const api = loadYaml('../../docs/openapi/source-api.yaml');
for (const method of ['put', 'post']) {
assert.ok(api.source[method].responses['400'], `source ${method} must document a 400 response`);
assert.ok(api.source[method].responses['409'], `source ${method} must document a 409 response`);
}
});
});
57 changes: 57 additions & 0 deletions test/it/it-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,63 @@ export default (ctx) => describe('Integration Tests: it tests', function () {
assert.strictEqual(getResp.status, 404, `Expected 404 from the router guard on read, got ${getResp.status} - user: ${superUser.email}`);
});

it('[super user] source PUT rejects a guid that contains a .da-versions segment', async () => {
// The guid becomes the document file id and keys the reserved
// .da-versions/{id}/... space. A guid with path separators or a .da-versions
// segment must not be accepted, or it would steer the write outside that space.
const {
serverUrl, org, repo, superUser,
} = ctx;
const url = `${serverUrl}/source/${org}/${repo}/guid-craft-seg.html`;
const formData = new FormData();
const blob = new Blob(['<html><body>craft</body></html>'], { type: 'text/html' });
formData.append('data', new File([blob], 'guid-craft-seg.html', { type: 'text/html' }));
formData.append('guid', 'x/.da-versions/forged');
const resp = await fetch(url, {
method: 'PUT',
body: formData,
headers: { Authorization: `Bearer ${superUser.accessToken}` },
});
assert.strictEqual(resp.status, 400, `Expected 400 for a crafted guid, got ${resp.status} - user: ${superUser.email}`);
});

it('[super user] source PUT rejects a non-UUID guid', async () => {
const {
serverUrl, org, repo, superUser,
} = ctx;
const url = `${serverUrl}/source/${org}/${repo}/guid-craft-plain.html`;
const formData = new FormData();
const blob = new Blob(['<html><body>craft</body></html>'], { type: 'text/html' });
formData.append('data', new File([blob], 'guid-craft-plain.html', { type: 'text/html' }));
formData.append('guid', 'not-a-uuid');
const resp = await fetch(url, {
method: 'PUT',
body: formData,
headers: { Authorization: `Bearer ${superUser.accessToken}` },
});
assert.strictEqual(resp.status, 400, `Expected 400 for a non-UUID guid, got ${resp.status} - user: ${superUser.email}`);
});

it('[super user] source PUT accepts a valid UUID guid and stores it as the file id', async () => {
// A well formed guid is the supported contract and must still work.
const {
serverUrl, org, repo, superUser,
} = ctx;
const validGuid = 'a1b2c3d4-e5f6-4a1b-8c2d-3e4f5a6b7c8d';
const url = `${serverUrl}/source/${org}/${repo}/guid-valid.html`;
const formData = new FormData();
const blob = new Blob(['<html><body>valid</body></html>'], { type: 'text/html' });
formData.append('data', new File([blob], 'guid-valid.html', { type: 'text/html' }));
formData.append('guid', validGuid);
const resp = await fetch(url, {
method: 'PUT',
body: formData,
headers: { Authorization: `Bearer ${superUser.accessToken}` },
});
assert.ok([200, 201].includes(resp.status), `Expected 200 or 201 for a valid guid, got ${resp.status} - user: ${superUser.email}`);
assert.strictEqual(resp.headers.get('x-da-id'), validGuid, `Expected the stored file id to match the guid - user: ${superUser.email}`);
});

it('[limited user] cannot read page1', async () => {
const {
serverUrl, org, repo, limitedUser,
Expand Down
74 changes: 74 additions & 0 deletions test/storage/version/paths.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
auditKey,
auditArchiveKey,
auditDirPrefix,
isValidId,
isSafeId,
} from '../../../src/storage/version/paths.js';

describe('Version Paths', () => {
Expand Down Expand Up @@ -62,4 +64,76 @@ describe('Version Paths', () => {
assert.strictEqual(prefix, 'myrepo/.da-versions/file-id-xyz/audit');
});
});

describe('isValidId', () => {
it('accepts a plain UUID', () => {
assert.strictEqual(isValidId('9b2e6c1a-4f3d-4a2b-8c1e-1d2f3a4b5c6d'), true);
});

it('rejects a non-UUID string', () => {
assert.strictEqual(isValidId('not-a-uuid'), false);
});

it('rejects a UUID with a trailing path', () => {
assert.strictEqual(isValidId('9b2e6c1a-4f3d-4a2b-8c1e-1d2f3a4b5c6d/x'), false);
});

it('rejects a non-string', () => {
assert.strictEqual(isValidId(undefined), false);
});
});

describe('isSafeId', () => {
it('accepts a plain UUID', () => {
assert.strictEqual(isSafeId('9b2e6c1a-4f3d-4a2b-8c1e-1d2f3a4b5c6d'), true);
});

it('accepts a benign legacy single-segment id', () => {
assert.strictEqual(isSafeId('legacy-id-123'), true);
});

it('rejects an id with a slash', () => {
assert.strictEqual(isSafeId('foo/bar'), false);
});

it('rejects an id with a .da-versions segment', () => {
assert.strictEqual(isSafeId('x/.da-versions/y'), false);
});

it('rejects a bare .da-versions id', () => {
assert.strictEqual(isSafeId('.da-versions'), false);
});

it('rejects a single dot segment', () => {
assert.strictEqual(isSafeId('.'), false);
});

it('rejects a double dot segment', () => {
assert.strictEqual(isSafeId('..'), false);
});

it('rejects an id with a backslash separator', () => {
assert.strictEqual(isSafeId('..\\..'), false);
});

it('rejects a percent-encoded dot segment', () => {
assert.strictEqual(isSafeId('%2e%2e'), false);
});

it('rejects an id with whitespace', () => {
assert.strictEqual(isSafeId('a\tb'), false);
});

it('accepts a legacy id that contains a dot', () => {
assert.strictEqual(isSafeId('v1.2'), true);
});

it('rejects an empty string', () => {
assert.strictEqual(isSafeId(''), false);
});

it('rejects a non-string', () => {
assert.strictEqual(isSafeId(null), false);
});
});
});
Loading
Loading