Unverified Commit 14f07359 authored by Hong Minhee's avatar Hong Minhee
Browse files

Fix HTTP sig verify with created/expires fields

Fixed a bug where verifyRequest() threw a TypeError when verifying
HTTP signatures containing `created` or `expires` fields as defined
in draft-cavage-http-signatures-12. These fields should be unquoted
integers according to the specification, but the parser was only
handling quoted string values.

This issue was causing 500 Internal Server Error responses in inbox
handlers when receiving activities with such signatures. The fix now
correctly parses both quoted strings and unquoted integers in the
Signature header, and properly validates the created/expires timestamps.

Added test case with a real-world signature example from oeee.cafe
that includes both created and expires fields.
parent c635b07a
Loading
Loading
Loading
Loading
+7 −0
Original line number Diff line number Diff line
@@ -8,6 +8,13 @@ Version 1.0.28

To be released.

 -  Fixed a bug where `verifyRequest()` function threw a `TypeError` when
    verifying HTTP Signatures with `created` or `expires` fields in
    the `Signature` header as defined in draft-cavage-http-signatures-12,
    causing `500 Internal Server Error` responses in inbox handlers.
    Now it correctly handles these fields as unquoted integers according
    to the specification.


Version 1.0.27
--------------
+20 −1
Original line number Diff line number Diff line
import { assertEquals } from "@std/assert";
import { assert, assertEquals } from "@std/assert";
import { mockDocumentLoader } from "../testing/docloader.ts";
import {
  rsaPrivateKey2,
@@ -212,4 +212,23 @@ test("verifyRequest()", async () => {
    ),
    rsaPublicKey1,
  );

  const request2 = new Request("https://c27a97f98d5f.ngrok.app/i/inbox", {
    method: "POST",
    body:
      '{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1"],"actor":"https://oeee.cafe/ap/users/3609fd4e-d51d-4db8-9f04-4189815864dd","object":{"actor":"https://c27a97f98d5f.ngrok.app/i","object":"https://oeee.cafe/ap/users/3609fd4e-d51d-4db8-9f04-4189815864dd","type":"Follow","id":"https://c27a97f98d5f.ngrok.app/i#follows/https://oeee.cafe/ap/users/3609fd4e-d51d-4db8-9f04-4189815864dd"},"type":"Accept","id":"https://oeee.cafe/objects/0fc2608f-5660-4b91-b8c7-63c0c2ac2e20"}',
    headers: {
      Host: "c27a97f98d5f.ngrok.app",
      "Content-Type": "application/activity+json",
      Date: "Mon, 25 Aug 2025 12:58:14 GMT",
      Digest: "SHA-256=YZyjeVQW5GwliJowASkteBJhFBTq3eQk/AMqRETc//A=",
      Signature:
        'keyId="https://oeee.cafe/ap/users/3609fd4e-d51d-4db8-9f04-4189815864dd#main-key",algorithm="hs2019",created="1756126694",expires="1756130294",headers="(request-target) (created) (expires) content-type date digest host",signature="XFb0jl2uMhE7RhbneE9sK9Zls2qZec8iy6+9O8UgDQeBGJThORFLjXKlps4QO1WAf1YSVB/i5aV6yF+h73Lm3ZiuAJDx1h+00iLsxoYuIw1CZvF0V2jELoo3sQ2/ZzqeoO6H5TbK7tKnU+ulFAPTuJgjIvPwYl11OMRouVS34NiaHP9Yx9pU813TLv37thG/hUKanyq8kk0IJWtDWteY/zxDvzoe7VOkBXVBHslMyrNAI/5JGulVQAQp/E61dJAhTHHIyGxkc/7iutWFZuqFXIiPJ9KR2OuKDj/B32hEzlsf5xH/CjqOJPIg1qMK8FzDiALCq6zjiKIBEnW8HQc/hQ=="',
    },
  });
  const options2: VerifyRequestOptions = {
    ...options,
    currentTime: Temporal.Instant.from("2025-08-25T12:58:14Z"),
  };
  assert(await verifyRequest(request2, options2) != null);
});
+63 −4
Original line number Diff line number Diff line
@@ -235,8 +235,10 @@ export async function verifyRequest(
  }
  const sigValues = Object.fromEntries(
    sigHeader.split(",").map((pair) =>
      pair.match(/^\s*([A-Za-z]+)="([^"]*)"\s*$/)
    ).filter((m) => m != null).map((m) => m!.slice(1, 3) as [string, string]),
      pair.match(/^\s*([A-Za-z]+)=(?:"([^"]*)"|(\d+))\s*$/)
    ).filter((m) => m != null).map((m) =>
      [m![1], m![2] ?? m![3]] as [string, string]
    ),
  );
  if (!("keyId" in sigValues)) {
    logger.debug(
@@ -257,6 +259,59 @@ export async function verifyRequest(
    );
    return null;
  }
  if ("expires" in sigValues) {
    const expiresSeconds = parseInt(sigValues.expires);
    if (!Number.isInteger(expiresSeconds)) {
      logger.debug(
        "Failed to verify; invalid expires field in the Signature header: {expires}.",
        { expires: sigValues.expires, signature: sigHeader },
      );
      return null;
    }
    const expires = Temporal.Instant.fromEpochMilliseconds(
      expiresSeconds * 1000,
    );
    if (Temporal.Instant.compare(now, expires) > 0) {
      logger.debug(
        "Failed to verify; signature expired at {expires} (now: {now}).",
        {
          expires: expires.toString(),
          now: now.toString(),
          signature: sigHeader,
        },
      );
      return null;
    }
  }
  if ("created" in sigValues) {
    const createdSeconds = parseInt(sigValues.created);
    if (!Number.isInteger(createdSeconds)) {
      logger.debug(
        "Failed to verify; invalid created field in the Signature header: {created}.",
        { created: sigValues.created, signature: sigHeader },
      );
      return null;
    }
    if (timeWindow !== false) {
      const created = Temporal.Instant.fromEpochMilliseconds(
        createdSeconds * 1000,
      );
      const tw: Temporal.DurationLike = timeWindow ?? { minutes: 1 };
      if (Temporal.Instant.compare(created, now.add(tw)) > 0) {
        logger.debug(
          "Failed to verify; created is too far in the future.",
          { created: created.toString(), now: now.toString() },
        );
        return null;
      } else if (Temporal.Instant.compare(created, now.subtract(tw)) < 0) {
        logger.debug(
          "Failed to verify; created is too far in the past.",
          { created: created.toString(), now: now.toString() },
        );
        return null;
      }
    }
  }
  const { keyId, headers, signature } = sigValues;
  const keyResult = await fetchKey(new URL(keyId), CryptographicKey, {
    documentLoader,
@@ -286,9 +341,13 @@ export async function verifyRequest(
  }
  const message = headerNames.map((name) =>
    `${name}: ` +
    (name == "(request-target)"
    (name === "(request-target)"
      ? `${request.method.toLowerCase()} ${new URL(request.url).pathname}`
      : name == "host"
      : name === "(created)"
      ? (sigValues.created ?? "")
      : name === "(expires)"
      ? (sigValues.expires ?? "")
      : name === "host"
      ? request.headers.get("host") ?? new URL(request.url).host
      : request.headers.get(name))
  ).join("\n");
+24 −0
Original line number Diff line number Diff line
{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    "https://w3id.org/security/v1"
  ],
  "id": "https://oeee.cafe/ap/users/3609fd4e-d51d-4db8-9f04-4189815864dd",
  "type": "Person",
  "preferredUsername": "hongminhee",
  "name": "洪兔",
  "inbox": "https://oeee.cafe/ap/users/3609fd4e-d51d-4db8-9f04-4189815864dd/inbox",
  "outbox": "https://oeee.cafe/ap/users/3609fd4e-d51d-4db8-9f04-4189815864dd/outbox",
  "publicKey": {
    "id": "https://oeee.cafe/ap/users/3609fd4e-d51d-4db8-9f04-4189815864dd#main-key",
    "owner": "https://oeee.cafe/ap/users/3609fd4e-d51d-4db8-9f04-4189815864dd",
    "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAowJfOzpA/nAYyL0bVDTm\niCAOlhFCIBnqwk1jvGrbkDhMzxlsgyoDqUSlmcJdKaPwu24YdFajDtJIgto27Ju7\nIC3hB7OFchnZ4JZrdYFo7CJABOzK58o12sdmmkCdY5hXWf1604E+mzyIdBAJ1FFJ\nL8vP07VEUsZ7yo9x0iVNg7HpCOK+y6BqI2GHS2dq9qkqQEIhC2TKHXn/RQVXwYB6\nG+YQmVUtcsbCVKdcWyTKhItLRGnepu3BqBSbieLxV27B1O9NFSoPu8xiBUnYwMoe\nsUQCE5tGcqxc75HzcVCbq7PqVqHZ1NW9RYssaSUqi4FYcjXxQrR08DrAl8rR4eXT\n4QIDAQAB\n-----END PUBLIC KEY-----\n"
  },
  "endpoints": {
    "type": "as:Endpoints",
    "sharedInbox": "https://oeee.cafe/inbox"
  },
  "followers": "https://oeee.cafe/ap/users/3609fd4e-d51d-4db8-9f04-4189815864dd/followers",
  "manuallyApprovesFollowers": false,
  "url": "https://oeee.cafe/@hongminhee"
}